diff --git a/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md b/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md new file mode 100644 index 0000000..911823c --- /dev/null +++ b/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md @@ -0,0 +1,484 @@ +# Magnum Opus v0.4 — GTD Tasks & Unified Triage + +**Goal:** Add local task management (VTODO files) and GTD triage (defer, file, discard) to the email client. Emails and tasks are the same thing from the user's perspective — a unified list of items to process. No CalDAV sync yet (v0.6); tasks are local VTODO files with SQLite cache. + +**Builds on:** v0.3 functional email client (IMAP sync, SMTP compose, triage actions, ActionQueue). + +--- + +## Core Concept + +Every item in the system — whether it arrived as an email or was created as a standalone task — is something the user must process. The user sees one unified stream, not "emails" and "tasks" as separate concepts. Triage actions work identically on both. + +**Two backing stores, one UX:** + +| Item type | Storage | Source | +|-----------|---------|--------| +| Email | IMAP message (existing) | Arrives via IMAP sync | +| Task | VTODO `.ics` file + SQLite cache | Created by user | + +An email can spawn a task that belongs to it (linked via `ATTACH:mid:` in the VTODO). The thread detail view shows both emails and linked tasks in one timeline. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SwiftUI Apps │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │DeferPicker │ │TaskEditView│ │ SidebarView │ │ +│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ +│ └────────────────┼───────────────┘ │ +│ ┌─────┴──────┐ │ +│ │ ViewModels │ │ +│ └─────┬──────┘ │ +├─────────────────────────┼────────────────────────────────────┤ +│ MagnumOpusCore (Swift Package) │ +│ ┌─────┴──────┐ │ +│ │ SyncEngine │ │ +│ │ │ │ +│ ┌─────────┼─────────┐ │ │ +│ │ ActionQueue │ │ │ +│ │ + DeferralCheck │ │ │ +│ └──┬──────────┬─────┘ │ │ +│ ┌────────┘ └──────┐ │ │ +│ │ SMTPClient │ │IMAPClient│ │ ┌──────────┐ ┌─────────┐ │ +│ └────────────┘ └─────────┘ │ │ MailStore │ │TaskStore│ │ +│ │ │(GRDB+FTS5)│ │(VTODO )│ │ +│ │ └───────────┘ └─────────┘ │ +└───────────────────────────────┼─────────────────────────────┘ + │ + ┌─────────────────┼──────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌─────────────┐ + │ IMAP │ │ SMTP │ │ ~/.../tasks │ + │ Server │ │ Server │ │ (VTODO .ics)│ + └──────────┘ └──────────┘ └─────────────┘ +``` + +--- + +## Data Model + +### Unified Item + +The UI operates on a union type that wraps both emails and tasks: + +```swift +public enum ItemSummary: Sendable, Identifiable { + case email(MessageSummary) + case task(TaskSummary) + + var id: String { ... } + var title: String { ... } // subject or summary + var date: Date? { ... } // message date or created date + var isRead: Bool { ... } + var deferral: Deferral? { ... } + var labels: [LabelInfo] { ... } +} +``` + +### TaskSummary (new) + +```swift +public struct TaskSummary: Sendable, Identifiable { + public var id: String + public var accountId: String + public var summary: String + public var description: String? + public var status: TaskStatus + public var priority: Int // 0 = undefined, 1 = highest, 9 = lowest + public var dueDate: Date? + public var deferUntil: Date? // DTSTART in VTODO + public var createdAt: Date + public var linkedMessageId: String? // mid: reference to email + public var categories: [String] +} + +public enum TaskStatus: String, Sendable, Codable { + case needsAction = "NEEDS-ACTION" + case inProcess = "IN-PROCESS" + case completed = "COMPLETED" + case cancelled = "CANCELLED" +} +``` + +### Labels + +Labels are user-created tags attachable to any item. A label with `isProject = true` appears in the Projects sidebar perspective. + +```swift +public struct LabelInfo: Sendable, Identifiable { + public var id: String + public var name: String + public var isProject: Bool + public var color: String? // hex color, optional +} +``` + +--- + +## Schema Changes + +### Migration v3_task: Task cache table + +```sql +CREATE TABLE task ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL REFERENCES account(id), + summary TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'NEEDS-ACTION', + priority INTEGER NOT NULL DEFAULT 0, + dueDate TEXT, + deferUntil TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + filePath TEXT NOT NULL, + linkedMessageId TEXT +); +CREATE INDEX idx_task_status ON task(status); +CREATE INDEX idx_task_deferUntil ON task(deferUntil); +CREATE INDEX idx_task_dueDate ON task(dueDate); +``` + +### Migration v3_label: Labels and item-label junction + +```sql +CREATE TABLE label ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL REFERENCES account(id), + name TEXT NOT NULL, + isProject INTEGER NOT NULL DEFAULT 0, + color TEXT +); +CREATE UNIQUE INDEX idx_label_name ON label(accountId, name); + +CREATE TABLE itemLabel ( + labelId TEXT NOT NULL REFERENCES label(id) ON DELETE CASCADE, + itemType TEXT NOT NULL, -- "email" or "task" + itemId TEXT NOT NULL, + PRIMARY KEY (labelId, itemType, itemId) +); +``` + +### Migration v3_deferral: Deferral tracking + +```sql +CREATE TABLE deferral ( + id TEXT PRIMARY KEY, + itemType TEXT NOT NULL, -- "email" or "task" + itemId TEXT NOT NULL, + deferUntil TEXT, -- ISO8601 date, NULL = someday + originalMailbox TEXT, -- for emails: restore to this mailbox on resurface + createdAt TEXT NOT NULL, + UNIQUE(itemType, itemId) +); +CREATE INDEX idx_deferral_until ON deferral(deferUntil); +``` + +--- + +## TaskStore Module + +New module in MagnumOpusCore. Manages VTODO files and their SQLite cache. + +### VTODO File Format + +Standard iCalendar, minimal properties for v0.4: + +``` +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagnumOpus//v0.4//EN +BEGIN:VTODO +UID: +DTSTAMP: +CREATED: +LAST-MODIFIED: +SUMMARY:Task title +DESCRIPTION:Optional body text +STATUS:NEEDS-ACTION +PRIORITY:0 +DTSTART: +DUE: +CATEGORIES:ProjectName,AnotherLabel +ATTACH:mid: +END:VTODO +END:VCALENDAR +``` + +### File Storage + +Tasks stored at: `~/Library/Application Support/MagnumOpus//tasks/.ics` + +The directory is created on first task creation. When CalDAV arrives (v0.6), vdirsyncer syncs this directory with the server. + +### Public API + +```swift +public struct TaskStore: Sendable { + /// Directory where .ics files live + private let taskDirectory: URL + + // File operations + public func writeTask(_ task: TaskRecord) throws // write .ics + update cache + public func deleteTask(id: String) throws // delete .ics + remove from cache + public func rebuildCache() throws // scan directory, rebuild SQLite + + // VTODO parsing + public static func parseVTODO(_ icsContent: String) -> TaskRecord? + public static func formatVTODO(_ task: TaskRecord) -> String +} +``` + +### VTODO Parser + +Simple line-based parser (no external dependency). Handles: +- Unfolding (lines starting with space/tab are continuations) +- Property extraction: `SUMMARY`, `DESCRIPTION`, `STATUS`, `PRIORITY`, `DTSTART`, `DUE`, `CATEGORIES`, `ATTACH`, `UID`, `DTSTAMP`, `CREATED`, `LAST-MODIFIED` +- Date parsing: `DTSTART;VALUE=DATE:20260315` and `DTSTART:20260315T090000Z` formats + +### VTODO Formatter + +Writes standard iCalendar from a TaskRecord. Line folding at 75 octets per RFC 5545. + +--- + +## Deferral System + +### How Deferral Works + +**Deferring an email:** +1. Create `deferral` record (itemType="email", deferUntil=date, originalMailbox=current) +2. Email disappears from Inbox perspective (filtered by query) +3. Email stays in IMAP mailbox — no server-side change +4. When deferUntil ≤ now: delete deferral record → email reappears in Today + +**Deferring a task:** +1. Update VTODO `DTSTART` property, rewrite `.ics` file +2. Update `task.deferUntil` in SQLite cache +3. Create `deferral` record for unified query +4. Task disappears from Inbox, appears in Today when date arrives + +**Deferring to Someday:** +- Same as above but `deferUntil = NULL` in deferral record +- Item only appears in Someday perspective, never resurfaces automatically +- User manually pulls it back by removing the deferral + +### Resurfacing + +On each sync cycle (and on app launch), the SyncEngine checks: + +```sql +SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') +``` + +For each matched deferral: +- Delete the deferral record → item reappears in Today perspective +- For tasks: clear `deferUntil` in the task record and VTODO file + +This is a simple poll, not a timer. Items may resurface slightly late (by up to the sync interval, default 5 minutes). Acceptable for v0.4. + +--- + +## Sidebar Perspectives + +### Perspective Definitions + +| Perspective | Content | Query Logic | +|-------------|---------|-------------| +| Inbox | Unprocessed items | Emails in INBOX mailbox (no deferral) + tasks with status=NEEDS-ACTION (no deferral) | +| Today | Due/deferred to today | Items with deferUntil ≤ today OR dueDate = today. Includes resurfaced deferrals. | +| Upcoming | Future dated items | Items with deferUntil > today OR dueDate > today, ordered by date | +| Projects | Grouped by project | Items with a label where isProject=true, grouped by label name | +| Someday | Parked indefinitely | Items with a deferral where deferUntil IS NULL | +| Archive | Done/filed | Emails in Archive folder + tasks with status=COMPLETED or CANCELLED | + +### Unified Query + +Each perspective runs a query that unions emails and tasks: + +```sql +-- Example: Inbox perspective +SELECT 'email' as itemType, id, subject as title, date, ... +FROM message WHERE mailboxId = :inboxId + AND id NOT IN (SELECT itemId FROM deferral WHERE itemType = 'email') +UNION ALL +SELECT 'task' as itemType, id, summary as title, createdAt as date, ... +FROM task WHERE status = 'NEEDS-ACTION' + AND id NOT IN (SELECT itemId FROM deferral WHERE itemType = 'task') +ORDER BY date DESC +``` + +The unified query returns `ItemSummary` values. GRDB ValueObservation on both tables drives reactive UI updates. + +--- + +## Triage Actions + +### Unified Triage (works on any item) + +| Action | Key | Email Effect | Task Effect | +|--------|-----|-------------|-------------| +| Defer to date | `d` | Create deferral record, hide from inbox | Set DTSTART in VTODO + deferral record | +| Defer to Someday | `⇧D` | Create deferral (deferUntil=NULL) | Same | +| File to project | `p` | Attach project label | Set CATEGORIES in VTODO + label | +| Discard | `⌫` | Archive email (move to Archive folder) | Set STATUS=CANCELLED in VTODO | +| Complete | `⌘⏎` | Archive email | Set STATUS=COMPLETED in VTODO | + +### Existing v0.3 Email Triage (unchanged) + +| Action | Key | Effect | +|--------|-----|--------| +| Archive | `e` | IMAP MOVE to Archive | +| Delete | `⌫` | IMAP MOVE to Trash | +| Flag/unflag | `s` | IMAP STORE ±\Flagged | +| Read/unread | `⇧⌘U` | IMAP STORE ±\Seen | +| Move to folder | `⇧⌘M` | IMAP MOVE | + +Note: `⌫` does double duty — for emails in mail folders it deletes (trash), in GTD context it discards (archive). The behavior depends on the active perspective. In Inbox/Today/Upcoming perspectives, `⌫` means "discard" (archive). In mailbox folder views, `⌫` means "delete" (trash). This matches the GTD mental model: discarding isn't destroying, it's filing away. + +### Defer Picker + +Pressing `d` opens a compact popover/sheet with: +- **Tomorrow** — quick button +- **Next Week** (next Monday) — quick button +- **Pick Date** — date picker for custom date +- **Someday** — equivalent to `⇧D` + +Selecting any option immediately defers and dismisses the picker. + +### Create Task + +`⌘⇧N` opens a minimal form: +- Title (required) +- Body (optional, TextEditor) +- Due date (optional, date picker) + +Creates a VTODO file, updates cache. Task appears in Inbox. + +### Link Task to Email + +When viewing an email thread, `t` creates a new task pre-linked to the thread: +- Title pre-filled with email subject +- VTODO gets `ATTACH:mid:` +- Task appears in Inbox AND in the thread's detail view + +--- + +## Thread Detail Integration + +The thread detail view currently shows messages in chronological order. v0.4 extends this to also show linked tasks: + +``` +Thread: "Q1 Planning" +├── Email from Alice (Jan 5) +├── Your reply (Jan 6) +├── [Task] Draft Q1 budget — NEEDS-ACTION +├── Email from Bob (Jan 8) +└── [Task] Review Bob's proposal — COMPLETED ✓ +``` + +Linked tasks are queried via: +```sql +SELECT * FROM task WHERE linkedMessageId IN ( + SELECT messageId FROM message + JOIN threadMessage ON threadMessage.messageId = message.id + WHERE threadMessage.threadId = :threadId +) +``` + +Tasks render with a distinct visual style (checkbox icon, status badge) but are inline in the timeline, sorted by `createdAt`. + +--- + +## Label Management + +### Creating Labels + +Labels are created inline — when filing to a project (`p`), the user sees existing labels and can type a new name. No separate label management UI in v0.4. + +### Label Picker + +Pressing `p` opens a compact popover: +- Searchable list of existing project labels +- Type-to-create: entering a name that doesn't exist offers "Create project: " +- Selecting a label attaches it and dismisses the picker + +### Storage + +Labels are local-only in v0.4. For tasks, the label names are also written to the VTODO `CATEGORIES` property so they survive a cache rebuild. + +--- + +## Testing Strategy + +### New Test Targets + +- **TaskStoreTests** — VTODO parsing/formatting, file I/O, cache rebuild +- Extended **MailStoreTests** — label CRUD, deferral CRUD, unified queries +- Extended **SyncEngineTests** — deferral resurfacing on sync + +### Key Test Scenarios + +1. Parse standard VTODO → correct TaskRecord fields +2. Format TaskRecord → valid iCalendar output, round-trip fidelity +3. Create task → .ics file written, cache updated, appears in Inbox query +4. Defer email → deferral record created, email hidden from Inbox, appears in Today when date arrives +5. Defer task → VTODO DTSTART updated, deferral record created, same visibility behavior +6. Defer to Someday → appears only in Someday perspective +7. Resurface check → items with deferUntil ≤ now have deferral removed +8. File to project → label created/attached, item appears in Projects perspective +9. Link task to email → task appears in thread detail view +10. Complete task → STATUS=COMPLETED in file, appears in Archive +11. Cache rebuild → delete cache, rebuild from .ics files, all tasks restored + +--- + +## Dependencies + +No new external dependencies. v0.4 uses the same packages as v0.3: + +| Package | Purpose | +|---------|---------| +| swift-nio-imap | IMAP protocol (existing) | +| swift-nio | Networking (existing) | +| swift-nio-ssl | TLS (existing) | +| GRDB.swift | SQLite, FTS5, ValueObservation (existing) | + +VTODO parsing is built in-house (simple line-based format, ~200 lines). + +--- + +## v0.4 Scope + +### In + +- TaskStore module (VTODO parser/formatter, file I/O, SQLite cache) +- Standalone task creation (⌘⇧N) +- Task linked to email thread (t key from thread view) +- Unified item model (ItemSummary wrapping email or task) +- GTD triage: defer to date, defer to someday, file to project, discard, complete +- Defer picker (tomorrow, next week, pick date, someday) +- Deferral resurfacing on sync cycle +- Labels with project flag +- Label picker for filing +- Sidebar perspectives: Inbox, Today, Upcoming, Projects, Someday, Archive +- Thread detail shows linked tasks inline +- Schema migrations for task, label, itemLabel, deferral tables + +### Out (Deferred) + +- CalDAV sync (v0.6) +- Task dependencies / blocking (v0.6) +- Delegation / waiting loop (v0.5) +- Forecast / calendar view (v0.6, needs VEVENT) +- Tags / GTD contexts sidebar (v0.5+) +- Defer to time-of-day / notifications (v0.5+) +- Task search in FTS5 (v0.5) +- Rich text task descriptions (future) +- Multiple accounts (future)