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:
484
docs/plans/2026-03-14-v0.4-gtd-tasks-design.md
Normal file
484
docs/plans/2026-03-14-v0.4-gtd-tasks-design.md
Normal 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)
|
||||
Reference in New Issue
Block a user