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 index 911823c..eaef99f 100644 --- a/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md +++ b/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md @@ -138,13 +138,17 @@ CREATE TABLE task ( createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, filePath TEXT NOT NULL, - linkedMessageId TEXT + linkedMessageId TEXT, + isSomeday INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_task_status ON task(status); CREATE INDEX idx_task_deferUntil ON task(deferUntil); CREATE INDEX idx_task_dueDate ON task(dueDate); +CREATE INDEX idx_task_linkedMessageId ON task(linkedMessageId); ``` +`updatedAt` is populated from VTODO `LAST-MODIFIED` during parsing and updated alongside it during writes. + ### Migration v3_label: Labels and item-label junction ```sql @@ -165,21 +169,29 @@ CREATE TABLE itemLabel ( ); ``` -### Migration v3_deferral: Deferral tracking +### Migration v3_deferral: Email deferral tracking + +The `deferral` table is for **emails only**. Tasks use `task.deferUntil` and `task.isSomeday` (derived from the VTODO file, survives cache rebuilds). ```sql CREATE TABLE deferral ( id TEXT PRIMARY KEY, - itemType TEXT NOT NULL, -- "email" or "task" - itemId TEXT NOT NULL, + messageId TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE, deferUntil TEXT, -- ISO8601 date, NULL = someday - originalMailbox TEXT, -- for emails: restore to this mailbox on resurface + originalMailbox TEXT, -- restore to this mailbox on resurface createdAt TEXT NOT NULL, - UNIQUE(itemType, itemId) + UNIQUE(messageId) ); CREATE INDEX idx_deferral_until ON deferral(deferUntil); ``` +Add `isSomeday` to the task table (in v3_task migration): + +```sql +-- Added to the task CREATE TABLE above: + isSomeday INTEGER NOT NULL DEFAULT 0, +``` + --- ## TaskStore Module @@ -261,25 +273,35 @@ Writes standard iCalendar from a TaskRecord. Line folding at 75 octets per RFC 5 **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 +3. Task disappears from Inbox (query checks `task.deferUntil`), appears in Today when date arrives + +Note: Tasks do NOT use the `deferral` table. The `task.deferUntil` column is authoritative — it's derived from the VTODO file (source of truth) and survives cache rebuilds. The `deferral` table exists only for emails, which have no native defer field. **Deferring to Someday:** -- Same as above but `deferUntil = NULL` in deferral record +- For emails: create deferral record with `deferUntil = NULL` +- For tasks: set a `someday` flag (use `DTSTART` with a sentinel value, or a dedicated `X-MAGNUM-SOMEDAY:TRUE` property in the VTODO, plus `task.isSomeday` in cache) - Item only appears in Someday perspective, never resurfaces automatically -- User manually pulls it back by removing the deferral +- User manually pulls it back by clearing the deferral/someday flag ### Resurfacing -On each sync cycle (and on app launch), the SyncEngine checks: +On each sync cycle (and on app launch), the SyncEngine checks both sources: ```sql +-- Emails SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') + +-- Tasks +SELECT * FROM task WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') AND isSomeday = 0 ``` -For each matched deferral: -- Delete the deferral record → item reappears in Today perspective -- For tasks: clear `deferUntil` in the task record and VTODO file +For each matched email deferral: +- Delete the deferral record → email reappears in Inbox + +For each matched task: +- Clear `deferUntil` in the task record and VTODO file → task reappears in Inbox + +**Resurfaced items go to Inbox, not Today.** The Today perspective shows items with `dueDate = today` (a deadline, not a deferral). This is the GTD distinction: a deferred item was "hidden until ready to process" — when it resurfaces, it goes back to the inbox for triage, just like a new email would. 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. @@ -291,11 +313,11 @@ This is a simple poll, not a timer. Items may resurface slightly late (by up to | 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 | +| Inbox | Unprocessed items | Emails in INBOX (no deferral) + tasks with status=NEEDS-ACTION, deferUntil is NULL, isSomeday=0 | +| Today | Due today | Emails/tasks with dueDate = today | +| Upcoming | Future dated items | Items with 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 | +| Someday | Parked indefinitely | Emails with deferral where deferUntil IS NULL + tasks with isSomeday=1 | | Archive | Done/filed | Emails in Archive folder + tasks with status=COMPLETED or CANCELLED | ### Unified Query @@ -306,11 +328,11 @@ Each perspective runs a query that unions emails and tasks: -- 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') + AND id NOT IN (SELECT messageId FROM deferral) 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') + AND deferUntil IS NULL AND isSomeday = 0 ORDER BY date DESC ``` @@ -340,7 +362,12 @@ The unified query returns `ItemSummary` values. GRDB ValueObservation on both ta | 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. +**v0.3 → v0.4 behavioral change for `⌫`:** In v0.3, `⌫` always meant "delete" (move to Trash). In v0.4, the behavior depends on the active perspective: + +- **GTD perspectives** (Inbox, Today, Upcoming, Projects, Someday): `⌫` means "discard" — archive the email or cancel the task. This matches GTD: discarding isn't destroying, it's removing from the action queue. +- **Mailbox folder views** (when browsing a specific IMAP folder): `⌫` retains v0.3 behavior — "delete" (move to Trash). + +This is a deliberate change. The `e` key (archive) remains available in all views as an alternative. ### Defer Picker @@ -394,6 +421,8 @@ SELECT * FROM task WHERE linkedMessageId IN ( Tasks render with a distinct visual style (checkbox icon, status badge) but are inline in the timeline, sorted by `createdAt`. +**Orphaned links:** If a linked email is deleted from the server during IMAP sync, the task's `linkedMessageId` becomes orphaned. The task remains visible as a standalone item — it simply no longer appears in any thread detail view. The `ATTACH:mid:` property stays in the VTODO file (harmless). No user notification needed. + --- ## Label Management @@ -411,7 +440,10 @@ Pressing `p` opens a compact popover: ### 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. +Labels are local-only in v0.4. + +- **Task labels** are written to the VTODO `CATEGORIES` property, so they survive a cache rebuild. During `rebuildCache()`, the TaskStore parses `CATEGORIES` from each VTODO file and populates the `itemLabel` junction table. +- **Email labels** are stored only in the `itemLabel` junction table in SQLite. They do NOT survive a cache rebuild. This is a known v0.4 limitation — emails have no standard mechanism for custom labels outside of IMAP keywords (which not all servers support). Acceptable for v0.4; can be addressed later with IMAP keywords or a local manifest file. ---