fix v0.4 spec: address 7 review issues (deferral storage, cache rebuild, key conflict, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:19:19 +01:00
parent 90b679870f
commit 2e502cc08b
+54 -22
View File
@@ -138,13 +138,17 @@ CREATE TABLE task (
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
filePath 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_status ON task(status);
CREATE INDEX idx_task_deferUntil ON task(deferUntil); CREATE INDEX idx_task_deferUntil ON task(deferUntil);
CREATE INDEX idx_task_dueDate ON task(dueDate); 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 ### Migration v3_label: Labels and item-label junction
```sql ```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 ```sql
CREATE TABLE deferral ( CREATE TABLE deferral (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
itemType TEXT NOT NULL, -- "email" or "task" messageId TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE,
itemId TEXT NOT NULL,
deferUntil TEXT, -- ISO8601 date, NULL = someday 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, createdAt TEXT NOT NULL,
UNIQUE(itemType, itemId) UNIQUE(messageId)
); );
CREATE INDEX idx_deferral_until ON deferral(deferUntil); 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 ## TaskStore Module
@@ -261,25 +273,35 @@ Writes standard iCalendar from a TaskRecord. Line folding at 75 octets per RFC 5
**Deferring a task:** **Deferring a task:**
1. Update VTODO `DTSTART` property, rewrite `.ics` file 1. Update VTODO `DTSTART` property, rewrite `.ics` file
2. Update `task.deferUntil` in SQLite cache 2. Update `task.deferUntil` in SQLite cache
3. Create `deferral` record for unified query 3. Task disappears from Inbox (query checks `task.deferUntil`), appears in Today when date arrives
4. Task disappears from Inbox, 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:** **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 - 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 ### 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 ```sql
-- Emails
SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') 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: For each matched email deferral:
- Delete the deferral record → item reappears in Today perspective - Delete the deferral record → email reappears in Inbox
- For tasks: clear `deferUntil` in the task record and VTODO file
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. 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 | | Perspective | Content | Query Logic |
|-------------|---------|-------------| |-------------|---------|-------------|
| Inbox | Unprocessed items | Emails in INBOX mailbox (no deferral) + tasks with status=NEEDS-ACTION (no deferral) | | Inbox | Unprocessed items | Emails in INBOX (no deferral) + tasks with status=NEEDS-ACTION, deferUntil is NULL, isSomeday=0 |
| Today | Due/deferred to today | Items with deferUntil ≤ today OR dueDate = today. Includes resurfaced deferrals. | | Today | Due today | Emails/tasks with dueDate = today |
| Upcoming | Future dated items | Items with deferUntil > today OR dueDate > today, ordered by date | | 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 | | 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 | | Archive | Done/filed | Emails in Archive folder + tasks with status=COMPLETED or CANCELLED |
### Unified Query ### Unified Query
@@ -306,11 +328,11 @@ Each perspective runs a query that unions emails and tasks:
-- Example: Inbox perspective -- Example: Inbox perspective
SELECT 'email' as itemType, id, subject as title, date, ... SELECT 'email' as itemType, id, subject as title, date, ...
FROM message WHERE mailboxId = :inboxId 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 UNION ALL
SELECT 'task' as itemType, id, summary as title, createdAt as date, ... SELECT 'task' as itemType, id, summary as title, createdAt as date, ...
FROM task WHERE status = 'NEEDS-ACTION' 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 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 | | Read/unread | `⇧⌘U` | IMAP STORE ±\Seen |
| Move to folder | `⇧⌘M` | IMAP MOVE | | 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 ### 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`. 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 ## Label Management
@@ -411,7 +440,10 @@ Pressing `p` opens a compact popover:
### Storage ### 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.
--- ---