add v0.4 design spec: GTD tasks, unified triage, deferrals, labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:15:55 +01:00
parent f3da0784b9
commit 90b679870f

View File

@@ -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:<message-id>` 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:<uuid>
DTSTAMP:<iso8601>
CREATED:<iso8601>
LAST-MODIFIED:<iso8601>
SUMMARY:Task title
DESCRIPTION:Optional body text
STATUS:NEEDS-ACTION
PRIORITY:0
DTSTART:<defer-until-date>
DUE:<due-date>
CATEGORIES:ProjectName,AnotherLabel
ATTACH:mid:<message-id>
END:VTODO
END:VCALENDAR
```
### File Storage
Tasks stored at: `~/Library/Application Support/MagnumOpus/<accountId>/tasks/<taskId>.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:<message-id>`
- 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: <name>"
- 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)