diff --git a/docs/plans/2026-03-14-v0.4-implementation-plan.md b/docs/plans/2026-03-14-v0.4-implementation-plan.md new file mode 100644 index 0000000..2286838 --- /dev/null +++ b/docs/plans/2026-03-14-v0.4-implementation-plan.md @@ -0,0 +1,2184 @@ +# Magnum Opus v0.4 — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add local task management (VTODO files) and GTD triage (defer, file, discard, complete) to the email client. Emails and tasks appear as a unified stream — same perspectives, same triage actions. + +**Architecture:** TaskStore module manages VTODO `.ics` files on disk with a SQLite cache. Emails and tasks are unified via `ItemSummary` enum. Deferrals for emails use a `deferral` table; task deferrals use the `task.deferUntil` column (derived from VTODO `DTSTART`). Labels with an `isProject` flag provide project grouping. SyncEngine resurfaces deferred items on each sync cycle. + +**Tech Stack:** Swift 6 (strict concurrency), SwiftUI, GRDB.swift, iCalendar/VTODO (in-house parser) + +**Design Document:** `docs/plans/2026-03-14-v0.4-gtd-tasks-design.md` + +**Branch:** `feature/v0.4-gtd-tasks` from `main`. + +--- + +## File Structure (changes from v0.3) + +``` +MagnumOpus/ +├── Packages/ +│ └── MagnumOpusCore/ +│ ├── Package.swift ← EDIT: add TaskStore target +│ ├── Sources/ +│ │ ├── Models/ +│ │ │ ├── TaskSummary.swift ← NEW +│ │ │ ├── TaskStatus.swift ← NEW +│ │ │ ├── LabelInfo.swift ← NEW +│ │ │ └── ItemSummary.swift ← NEW +│ │ │ +│ │ ├── TaskStore/ ← NEW module +│ │ │ ├── VTODOParser.swift ← NEW: iCalendar line parser +│ │ │ ├── VTODOFormatter.swift ← NEW: TaskRecord → .ics string +│ │ │ ├── TaskStore.swift ← NEW: file I/O + cache +│ │ │ └── TaskRecord.swift ← NEW: GRDB record +│ │ │ +│ │ ├── MailStore/ +│ │ │ ├── DatabaseSetup.swift ← EDIT: add v3 migrations +│ │ │ ├── MailStore.swift ← EDIT: add label/deferral queries +│ │ │ └── Records/ +│ │ │ ├── LabelRecord.swift ← NEW +│ │ │ ├── ItemLabelRecord.swift ← NEW +│ │ │ └── DeferralRecord.swift ← NEW +│ │ │ +│ │ └── SyncEngine/ +│ │ └── SyncCoordinator.swift ← EDIT: add resurfacing check +│ │ +│ └── Tests/ +│ ├── TaskStoreTests/ ← NEW +│ │ ├── VTODOParserTests.swift +│ │ ├── VTODOFormatterTests.swift +│ │ └── TaskStoreTests.swift +│ └── MailStoreTests/ +│ └── LabelDeferralTests.swift ← NEW +│ +├── Apps/ +│ └── MagnumOpus/ +│ ├── ViewModels/ +│ │ ├── MailViewModel.swift ← EDIT: perspectives, GTD triage +│ │ └── TaskEditViewModel.swift ← NEW +│ └── Views/ +│ ├── SidebarView.swift ← EDIT: new perspectives +│ ├── ThreadListView.swift ← EDIT: unified items, GTD keys +│ ├── ThreadDetailView.swift ← EDIT: linked tasks inline +│ ├── DeferPicker.swift ← NEW +│ ├── LabelPicker.swift ← NEW +│ └── TaskEditView.swift ← NEW +``` + +**Dependency graph:** `SyncEngine` → `IMAPClient` + `MailStore` + `SMTPClient` + `TaskStore` → `Models`. + +--- + +## Chunk 1: Models & Schema + +New model types, database records, and migrations. No file I/O or UI — pure data layer. + +### Task 1: New Model Types + +**Files:** +- Create: `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift` +- Create: `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift` +- Create: `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift` +- Create: `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift` + +- [ ] **Step 1: Create TaskStatus enum** + +Create `Packages/MagnumOpusCore/Sources/Models/TaskStatus.swift`: + +```swift +public enum TaskStatus: String, Sendable, Codable { + case needsAction = "NEEDS-ACTION" + case inProcess = "IN-PROCESS" + case completed = "COMPLETED" + case cancelled = "CANCELLED" +} +``` + +- [ ] **Step 2: Create TaskSummary** + +Create `Packages/MagnumOpusCore/Sources/Models/TaskSummary.swift`: + +```swift +import Foundation + +public struct TaskSummary: Sendable, Identifiable, Equatable { + public var id: String + public var accountId: String + public var summary: String + public var description: String? + public var status: TaskStatus + public var priority: Int + public var dueDate: Date? + public var deferUntil: Date? + public var createdAt: Date + public var linkedMessageId: String? + public var categories: [String] + public var isSomeday: Bool + + public init( + id: String, + accountId: String, + summary: String, + description: String? = nil, + status: TaskStatus = .needsAction, + priority: Int = 0, + dueDate: Date? = nil, + deferUntil: Date? = nil, + createdAt: Date = Date(), + linkedMessageId: String? = nil, + categories: [String] = [], + isSomeday: Bool = false + ) { + self.id = id + self.accountId = accountId + self.summary = summary + self.description = description + self.status = status + self.priority = priority + self.dueDate = dueDate + self.deferUntil = deferUntil + self.createdAt = createdAt + self.linkedMessageId = linkedMessageId + self.categories = categories + self.isSomeday = isSomeday + } +} +``` + +- [ ] **Step 3: Create LabelInfo** + +Create `Packages/MagnumOpusCore/Sources/Models/LabelInfo.swift`: + +```swift +public struct LabelInfo: Sendable, Identifiable, Equatable { + public var id: String + public var name: String + public var isProject: Bool + public var color: String? + + public init(id: String, name: String, isProject: Bool = false, color: String? = nil) { + self.id = id + self.name = name + self.isProject = isProject + self.color = color + } +} +``` + +- [ ] **Step 4: Create ItemSummary** + +Create `Packages/MagnumOpusCore/Sources/Models/ItemSummary.swift`: + +```swift +import Foundation + +public enum ItemSummary: Sendable, Identifiable, Equatable { + case email(MessageSummary) + case task(TaskSummary) + + public var id: String { + switch self { + case .email(let msg): return "email-\(msg.id)" + case .task(let task): return "task-\(task.id)" + } + } + + public var title: String { + switch self { + case .email(let msg): return msg.subject ?? "(no subject)" + case .task(let task): return task.summary + } + } + + public var date: Date? { + switch self { + case .email(let msg): return msg.date + case .task(let task): return task.createdAt + } + } + + public var dueDate: Date? { + switch self { + case .email: return nil + case .task(let task): return task.dueDate + } + } + + public var isDeferred: Bool { + switch self { + case .email: return false // checked via deferral table + case .task(let task): return task.deferUntil != nil + } + } + + public var isSomeday: Bool { + switch self { + case .email: return false // checked via deferral table + case .task(let task): return task.isSomeday + } + } +} +``` + +- [ ] **Step 5: Verify Models compile** + +```bash +swift build --package-path Packages/MagnumOpusCore --target Models +``` + +- [ ] **Step 6: Commit** + +```bash +git add Packages/MagnumOpusCore/Sources/Models/ +git commit -m "add v0.4 model types: TaskStatus, TaskSummary, LabelInfo, ItemSummary" +``` + +--- + +### Task 2: Database Records & Migrations + +**Files:** +- Create: `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift` +- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift` +- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift` +- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift` +- Edit: `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift` +- Edit: `Packages/MagnumOpusCore/Package.swift` + +- [ ] **Step 1: Add TaskStore target to Package.swift** + +Edit `Packages/MagnumOpusCore/Package.swift`: + +```swift +// In products: +.library(name: "TaskStore", targets: ["TaskStore"]), + +// In targets: +.target( + name: "TaskStore", + dependencies: [ + "Models", + "MailStore", + .product(name: "GRDB", package: "GRDB.swift"), + ] +), + +// Add TaskStore to SyncEngine dependencies: +.target( + name: "SyncEngine", + dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient", "TaskStore"] +), + +// Add test target: +.testTarget(name: "TaskStoreTests", dependencies: ["TaskStore", "MailStore"]), +``` + +Create placeholder: `mkdir -p Packages/MagnumOpusCore/Sources/TaskStore` + +- [ ] **Step 2: Create TaskRecord** + +Create `Packages/MagnumOpusCore/Sources/TaskStore/TaskRecord.swift`: + +```swift +import Foundation +import GRDB + +public struct TaskRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "task" + + public var id: String + public var accountId: String + public var summary: String + public var description: String? + public var status: String + public var priority: Int + public var dueDate: String? + public var deferUntil: String? + public var createdAt: String + public var updatedAt: String + public var filePath: String + public var linkedMessageId: String? + public var isSomeday: Bool + + public init( + id: String, + accountId: String, + summary: String, + description: String? = nil, + status: String = "NEEDS-ACTION", + priority: Int = 0, + dueDate: String? = nil, + deferUntil: String? = nil, + createdAt: String, + updatedAt: String, + filePath: String, + linkedMessageId: String? = nil, + isSomeday: Bool = false + ) { + self.id = id + self.accountId = accountId + self.summary = summary + self.description = description + self.status = status + self.priority = priority + self.dueDate = dueDate + self.deferUntil = deferUntil + self.createdAt = createdAt + self.updatedAt = updatedAt + self.filePath = filePath + self.linkedMessageId = linkedMessageId + self.isSomeday = isSomeday + } +} +``` + +- [ ] **Step 3: Create LabelRecord** + +Create `Packages/MagnumOpusCore/Sources/MailStore/Records/LabelRecord.swift`: + +```swift +import GRDB + +public struct LabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "label" + + public var id: String + public var accountId: String + public var name: String + public var isProject: Bool + public var color: String? + + public init(id: String, accountId: String, name: String, isProject: Bool = false, color: String? = nil) { + self.id = id + self.accountId = accountId + self.name = name + self.isProject = isProject + self.color = color + } +} +``` + +- [ ] **Step 4: Create ItemLabelRecord** + +Create `Packages/MagnumOpusCore/Sources/MailStore/Records/ItemLabelRecord.swift`: + +```swift +import GRDB + +public struct ItemLabelRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "itemLabel" + + public var labelId: String + public var itemType: String // "email" or "task" + public var itemId: String + + public init(labelId: String, itemType: String, itemId: String) { + self.labelId = labelId + self.itemType = itemType + self.itemId = itemId + } +} +``` + +- [ ] **Step 5: Create DeferralRecord** + +Create `Packages/MagnumOpusCore/Sources/MailStore/Records/DeferralRecord.swift`: + +```swift +import GRDB + +public struct DeferralRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "deferral" + + public var id: String + public var messageId: String + public var deferUntil: String? // ISO8601, NULL = someday + public var originalMailbox: String? + public var createdAt: String + + public init( + id: String, + messageId: String, + deferUntil: String? = nil, + originalMailbox: String? = nil, + createdAt: String + ) { + self.id = id + self.messageId = messageId + self.deferUntil = deferUntil + self.originalMailbox = originalMailbox + self.createdAt = createdAt + } +} +``` + +- [ ] **Step 6: Add v3 migrations to DatabaseSetup.swift** + +Edit `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift` — add three new migrations after existing v2 ones: + +```swift +migrator.registerMigration("v3_task") { db in + try db.create(table: "task") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("summary", .text).notNull() + t.column("description", .text) + t.column("status", .text).notNull().defaults(to: "NEEDS-ACTION") + t.column("priority", .integer).notNull().defaults(to: 0) + t.column("dueDate", .text) + t.column("deferUntil", .text) + t.column("createdAt", .text).notNull() + t.column("updatedAt", .text).notNull() + t.column("filePath", .text).notNull() + t.column("linkedMessageId", .text) + t.column("isSomeday", .boolean).notNull().defaults(to: false) + } + try db.create(index: "idx_task_status", on: "task", columns: ["status"]) + try db.create(index: "idx_task_deferUntil", on: "task", columns: ["deferUntil"]) + try db.create(index: "idx_task_dueDate", on: "task", columns: ["dueDate"]) + try db.create(index: "idx_task_linkedMessageId", on: "task", columns: ["linkedMessageId"]) +} + +migrator.registerMigration("v3_label") { db in + try db.create(table: "label") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("name", .text).notNull() + t.column("isProject", .boolean).notNull().defaults(to: false) + t.column("color", .text) + } + try db.create(index: "idx_label_name", on: "label", columns: ["accountId", "name"], unique: true) + + try db.create(table: "itemLabel") { t in + t.column("labelId", .text).notNull().references("label", onDelete: .cascade) + t.column("itemType", .text).notNull() + t.column("itemId", .text).notNull() + t.primaryKey(["labelId", "itemType", "itemId"]) + } +} + +migrator.registerMigration("v3_deferral") { db in + try db.create(table: "deferral") { t in + t.primaryKey("id", .text) + t.column("messageId", .text).notNull().references("message", onDelete: .cascade) + t.column("deferUntil", .text) + t.column("originalMailbox", .text) + t.column("createdAt", .text).notNull() + t.uniqueKey(["messageId"]) + } + try db.create(index: "idx_deferral_until", on: "deferral", columns: ["deferUntil"]) +} +``` + +- [ ] **Step 7: Add migration and record round-trip tests** + +Create `Packages/MagnumOpusCore/Tests/MailStoreTests/LabelDeferralTests.swift`: + +```swift +import Testing +import Foundation +@testable import MailStore + +@Suite("V3 Migrations & Records") +struct LabelDeferralTests { + @Test("v3 migrations create task, label, itemLabel, deferral tables") + func v3TablesExist() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.read { db in + let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'") + #expect(tables.contains("task")) + #expect(tables.contains("label")) + #expect(tables.contains("itemLabel")) + #expect(tables.contains("deferral")) + } + } + + @Test("task table has expected columns") + func taskColumns() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.read { db in + let columns = try db.columns(in: "task").map(\.name) + #expect(columns.contains("summary")) + #expect(columns.contains("status")) + #expect(columns.contains("deferUntil")) + #expect(columns.contains("isSomeday")) + #expect(columns.contains("linkedMessageId")) + #expect(columns.contains("filePath")) + } + } + + @Test("LabelRecord round-trip") + func labelRoundTrip() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.write { db in + try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db) + try LabelRecord(id: "l1", accountId: "a1", name: "Work", isProject: true).insert(db) + } + let loaded = try db.read { db in try LabelRecord.fetchOne(db, key: "l1") } + #expect(loaded?.name == "Work") + #expect(loaded?.isProject == true) + } + + @Test("ItemLabelRecord round-trip") + func itemLabelRoundTrip() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.write { db in + try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db) + try LabelRecord(id: "l1", accountId: "a1", name: "Work").insert(db) + try ItemLabelRecord(labelId: "l1", itemType: "email", itemId: "msg1").insert(db) + } + let loaded = try db.read { db in + try ItemLabelRecord.fetchAll(db, sql: "SELECT * FROM itemLabel WHERE itemId = 'msg1'") + } + #expect(loaded.count == 1) + #expect(loaded.first?.itemType == "email") + } + + @Test("DeferralRecord round-trip with unique constraint") + func deferralRoundTrip() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.write { db in + try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db) + try db.execute(sql: """ + INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1) + """) + try db.execute(sql: """ + INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size) + VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100) + """) + try DeferralRecord(id: "d1", messageId: "m1", deferUntil: "2026-03-20T00:00:00Z", originalMailbox: "INBOX", createdAt: "2026-03-14T10:00:00Z").insert(db) + } + let loaded = try db.read { db in try DeferralRecord.fetchOne(db, key: "d1") } + #expect(loaded?.deferUntil == "2026-03-20T00:00:00Z") + #expect(loaded?.originalMailbox == "INBOX") + } + + @Test("deferral cascade deletes when message deleted") + func deferralCascade() throws { + let db = try DatabaseSetup.openInMemoryDatabase() + try db.write { db in + try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db) + try db.execute(sql: """ + INSERT INTO mailbox (id, accountId, name, uidValidity, uidNext) VALUES ('mb1', 'a1', 'INBOX', 1, 1) + """) + try db.execute(sql: """ + INSERT INTO message (id, accountId, mailboxId, uid, date, isRead, isFlagged, size) + VALUES ('m1', 'a1', 'mb1', 1, '2026-03-14', 0, 0, 100) + """) + try DeferralRecord(id: "d1", messageId: "m1", createdAt: "2026-03-14T10:00:00Z").insert(db) + try db.execute(sql: "DELETE FROM message WHERE id = 'm1'") + } + let count = try db.read { db in try DeferralRecord.fetchCount(db) } + #expect(count == 0) + } +} +``` + +- [ ] **Step 8: Verify build and run tests** + +```bash +swift build --package-path Packages/MagnumOpusCore +swift test --package-path Packages/MagnumOpusCore --filter LabelDeferralTests +``` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "add v0.4 schema: task, label, itemLabel, deferral tables with migrations and tests" +``` + +--- + +### Task 3: MailStore Query Extensions + +**Files:** +- Edit: `Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift` + +- [ ] **Step 1: Add label CRUD to MailStore** + +```swift +// MARK: - Labels + +public func insertLabel(_ label: LabelRecord) throws { + try dbWriter.write { db in try label.insert(db) } +} + +public func labels(accountId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .order(Column("name").asc) + .fetchAll(db) + } +} + +public func projectLabels(accountId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .filter(Column("isProject") == true) + .order(Column("name").asc) + .fetchAll(db) + } +} + +public func deleteLabel(id: String) throws { + _ = try dbWriter.write { db in try LabelRecord.deleteOne(db, key: id) } +} + +public func labelByName(_ name: String, accountId: String) throws -> LabelRecord? { + try dbWriter.read { db in + try LabelRecord + .filter(Column("accountId") == accountId) + .filter(Column("name") == name) + .fetchOne(db) + } +} +``` + +- [ ] **Step 2: Add itemLabel junction CRUD** + +```swift +// MARK: - Item Labels + +public func attachLabel(labelId: String, itemType: String, itemId: String) throws { + try dbWriter.write { db in + try ItemLabelRecord(labelId: labelId, itemType: itemType, itemId: itemId).insert(db) + } +} + +public func detachLabel(labelId: String, itemType: String, itemId: String) throws { + _ = try dbWriter.write { db in + try db.execute( + sql: "DELETE FROM itemLabel WHERE labelId = ? AND itemType = ? AND itemId = ?", + arguments: [labelId, itemType, itemId] + ) + } +} + +public func labelsForItem(itemType: String, itemId: String) throws -> [LabelRecord] { + try dbWriter.read { db in + try LabelRecord.fetchAll(db, sql: """ + SELECT label.* FROM label + JOIN itemLabel ON itemLabel.labelId = label.id + WHERE itemLabel.itemType = ? AND itemLabel.itemId = ? + ORDER BY label.name ASC + """, arguments: [itemType, itemId]) + } +} + +public func itemsWithLabel(labelId: String) throws -> [(itemType: String, itemId: String)] { + try dbWriter.read { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT itemType, itemId FROM itemLabel WHERE labelId = ? + """, arguments: [labelId]) + return rows.map { (itemType: $0["itemType"], itemId: $0["itemId"]) } + } +} +``` + +- [ ] **Step 3: Add deferral CRUD** + +```swift +// MARK: - Deferrals (email only) + +public func insertDeferral(_ deferral: DeferralRecord) throws { + try dbWriter.write { db in try deferral.insert(db) } +} + +public func deleteDeferral(id: String) throws { + _ = try dbWriter.write { db in try DeferralRecord.deleteOne(db, key: id) } +} + +public func deleteDeferralForMessage(messageId: String) throws { + _ = try dbWriter.write { db in + try db.execute(sql: "DELETE FROM deferral WHERE messageId = ?", arguments: [messageId]) + } +} + +public func deferralForMessage(messageId: String) throws -> DeferralRecord? { + try dbWriter.read { db in + try DeferralRecord + .filter(Column("messageId") == messageId) + .fetchOne(db) + } +} + +public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] { + try dbWriter.read { db in + try DeferralRecord.fetchAll(db, sql: """ + SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= ? + """, arguments: [beforeDate]) + } +} + +public func somedayDeferrals() throws -> [DeferralRecord] { + try dbWriter.read { db in + try DeferralRecord + .filter(Column("deferUntil") == nil) + .fetchAll(db) + } +} +``` + +- [ ] **Step 4: Add task cache queries to MailStore** + +The task table lives in the same SQLite database. Add queries for task records: + +```swift +// MARK: - Task Cache (used by perspectives) + +public func insertTask(_ task: TaskRecord) throws { + try dbWriter.write { db in try task.insert(db) } +} + +public func updateTask(_ task: TaskRecord) throws { + try dbWriter.write { db in try task.update(db) } +} + +public func deleteTask(id: String) throws { + _ = try dbWriter.write { db in try TaskRecord.deleteOne(db, key: id) } +} + +public func task(id: String) throws -> TaskRecord? { + try dbWriter.read { db in try TaskRecord.fetchOne(db, key: id) } +} + +public func inboxTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task + WHERE accountId = ? AND status = 'NEEDS-ACTION' + AND deferUntil IS NULL AND isSomeday = 0 + ORDER BY createdAt DESC + """, arguments: [accountId]) + } +} + +public func todayTasks(accountId: String, todayDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task + WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS') + AND dueDate = ? + """, arguments: [accountId, todayDate]) + } +} + +public func upcomingTasks(accountId: String, afterDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task + WHERE accountId = ? AND status IN ('NEEDS-ACTION', 'IN-PROCESS') + AND dueDate > ? + ORDER BY dueDate ASC + """, arguments: [accountId, afterDate]) + } +} + +public func somedayTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task WHERE accountId = ? AND isSomeday = 1 + ORDER BY createdAt DESC + """, arguments: [accountId]) + } +} + +public func archivedTasks(accountId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task WHERE accountId = ? AND status IN ('COMPLETED', 'CANCELLED') + ORDER BY updatedAt DESC + """, arguments: [accountId]) + } +} + +public func tasksLinkedToThread(threadId: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT task.* FROM task + WHERE task.linkedMessageId IN ( + SELECT message.messageId FROM message + JOIN threadMessage ON threadMessage.messageId = message.id + WHERE threadMessage.threadId = ? + ) + ORDER BY task.createdAt ASC + """, arguments: [threadId]) + } +} + +public func expiredDeferredTasks(beforeDate: String) throws -> [TaskRecord] { + try dbWriter.read { db in + try TaskRecord.fetchAll(db, sql: """ + SELECT * FROM task + WHERE deferUntil IS NOT NULL AND deferUntil <= ? AND isSomeday = 0 + """, arguments: [beforeDate]) + } +} + +public func clearTaskDeferral(id: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "UPDATE task SET deferUntil = NULL WHERE id = ?", + arguments: [id] + ) + } +} + +public func deleteAllTasks() throws { + _ = try dbWriter.write { db in try TaskRecord.deleteAll(db) } +} + +public func deleteAllItemLabelsForTasks() throws { + _ = try dbWriter.write { db in + try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'") + } +} +``` + +- [ ] **Step 5: Verify build and all tests pass** + +```bash +swift build --package-path Packages/MagnumOpusCore +swift test --package-path Packages/MagnumOpusCore +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "add MailStore queries: labels, item-labels, deferrals, task cache perspectives" +``` + +--- + +## Chunk 2: TaskStore Module + +VTODO parser, formatter, and file-backed task store with cache management. + +### Task 4: VTODO Parser + +**Files:** +- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift` +- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift` + +- [ ] **Step 1: Create VTODOParser** + +Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift`: + +```swift +import Foundation + +public enum VTODOParser { + /// Parse an iCalendar string containing a VTODO into a dictionary of properties. + public static func parse(_ icsContent: String) -> [String: String]? { + // Unfold: lines starting with space/tab are continuations + let unfolded = icsContent + .replacingOccurrences(of: "\r\n ", with: "") + .replacingOccurrences(of: "\r\n\t", with: "") + .replacingOccurrences(of: "\n ", with: "") + .replacingOccurrences(of: "\n\t", with: "") + + let lines = unfolded.components(separatedBy: .newlines) + + // Find VTODO block + guard let startIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "BEGIN:VTODO" }), + let endIdx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "END:VTODO" }), + startIdx < endIdx + else { return nil } + + var properties: [String: String] = [:] + for i in (startIdx + 1).. String { + text.replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\,", with: ",") + .replacingOccurrences(of: "\\;", with: ";") + .replacingOccurrences(of: "\\\\", with: "\\") + } + + /// Parse an iCalendar date string (DATE or DATE-TIME format) + public static func parseDate(_ value: String) -> Date? { + // DATE-TIME: 20260315T090000Z + let dtFormatter = DateFormatter() + dtFormatter.locale = Locale(identifier: "en_US_POSIX") + dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + dtFormatter.timeZone = TimeZone(identifier: "UTC") + if let date = dtFormatter.date(from: value) { return date } + + // DATE-TIME without Z (local time) + dtFormatter.dateFormat = "yyyyMMdd'T'HHmmss" + dtFormatter.timeZone = .current + if let date = dtFormatter.date(from: value) { return date } + + // DATE: 20260315 + dtFormatter.dateFormat = "yyyyMMdd" + dtFormatter.timeZone = .current + if let date = dtFormatter.date(from: value) { return date } + + return nil + } + + /// Format a Date into iCalendar DATE-TIME format (UTC) + public static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: date) + } + + /// Format a Date into iCalendar DATE format (no time) + public static func formatDateOnly(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd" + formatter.timeZone = .current + return formatter.string(from: date) + } +} +``` + +- [ ] **Step 2: Create VTODOParserTests** + +Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift`: + +```swift +import Testing +import Foundation +@testable import TaskStore + +@Suite("VTODOParser") +struct VTODOParserTests { + let sampleVTODO = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//MagnumOpus//v0.4//EN + BEGIN:VTODO + UID:test-123 + DTSTAMP:20260314T100000Z + CREATED:20260314T100000Z + LAST-MODIFIED:20260314T120000Z + SUMMARY:Buy groceries + DESCRIPTION:Milk\\, eggs\\, bread + STATUS:NEEDS-ACTION + PRIORITY:3 + DTSTART;VALUE=DATE:20260320 + DUE;VALUE=DATE:20260325 + CATEGORIES:Shopping,Personal + ATTACH:mid:abc-123@example.com + END:VTODO + END:VCALENDAR + """ + + @Test("parses all VTODO properties") + func parseProperties() { + let props = VTODOParser.parse(sampleVTODO) + #expect(props != nil) + #expect(props?["UID"] == "test-123") + #expect(props?["SUMMARY"] == "Buy groceries") + #expect(props?["STATUS"] == "NEEDS-ACTION") + #expect(props?["PRIORITY"] == "3") + #expect(props?["CATEGORIES"] == "Shopping,Personal") + #expect(props?["ATTACH"] == "mid:abc-123@example.com") + } + + @Test("parses DATE format") + func parseDateOnly() { + let date = VTODOParser.parseDate("20260315") + #expect(date != nil) + let cal = Calendar.current + #expect(cal.component(.year, from: date!) == 2026) + #expect(cal.component(.month, from: date!) == 3) + #expect(cal.component(.day, from: date!) == 15) + } + + @Test("parses DATE-TIME format with Z") + func parseDateTimeUTC() { + let date = VTODOParser.parseDate("20260315T090000Z") + #expect(date != nil) + } + + @Test("parses DATE-TIME format without Z") + func parseDateTimeLocal() { + let date = VTODOParser.parseDate("20260315T090000") + #expect(date != nil) + } + + @Test("returns nil for invalid content") + func invalidContent() { + #expect(VTODOParser.parse("not ical") == nil) + #expect(VTODOParser.parse("BEGIN:VCALENDAR\nEND:VCALENDAR") == nil) + } + + @Test("handles line unfolding") + func lineUnfolding() { + let folded = """ + BEGIN:VCALENDAR + BEGIN:VTODO + UID:test + DESCRIPTION:This is a very long description that has been + folded across multiple lines + END:VTODO + END:VCALENDAR + """ + let props = VTODOParser.parse(folded) + #expect(props?["DESCRIPTION"] == "This is a very long description that has been folded across multiple lines") + } + + @Test("date round-trip") + func dateRoundTrip() { + let now = Date() + let formatted = VTODOParser.formatDate(now) + let parsed = VTODOParser.parseDate(formatted) + #expect(parsed != nil) + // Within 1 second tolerance (formatting drops sub-seconds) + #expect(abs(now.timeIntervalSince(parsed!)) < 1.0) + } +} +``` + +- [ ] **Step 3: Run parser tests** + +```bash +swift test --package-path Packages/MagnumOpusCore --filter VTODOParserTests +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "add VTODO parser with line unfolding, date parsing, tests" +``` + +--- + +### Task 5: VTODO Formatter + +**Files:** +- Create: `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift` +- Create: `Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift` + +- [ ] **Step 1: Create VTODOFormatter** + +Create `Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift`: + +```swift +import Foundation +import Models + +public enum VTODOFormatter { + /// Format a TaskRecord into an iCalendar VTODO string + public static func format( + uid: String, + summary: String, + description: String? = nil, + status: String = "NEEDS-ACTION", + priority: Int = 0, + dueDate: Date? = nil, + deferUntil: Date? = nil, + categories: [String] = [], + linkedMessageId: String? = nil, + isSomeday: Bool = false, + createdAt: Date, + updatedAt: Date + ) -> String { + var lines: [String] = [] + lines.append("BEGIN:VCALENDAR") + lines.append("VERSION:2.0") + lines.append("PRODID:-//MagnumOpus//v0.4//EN") + lines.append("BEGIN:VTODO") + lines.append("UID:\(uid)") + lines.append("DTSTAMP:\(VTODOParser.formatDate(updatedAt))") + lines.append("CREATED:\(VTODOParser.formatDate(createdAt))") + lines.append("LAST-MODIFIED:\(VTODOParser.formatDate(updatedAt))") + lines.append("SUMMARY:\(escapeText(summary))") + + if let desc = description, !desc.isEmpty { + lines.append("DESCRIPTION:\(escapeText(desc))") + } + + lines.append("STATUS:\(status)") + lines.append("PRIORITY:\(priority)") + + if let defer_ = deferUntil { + lines.append("DTSTART;VALUE=DATE:\(VTODOParser.formatDateOnly(defer_))") + } + + if let due = dueDate { + lines.append("DUE;VALUE=DATE:\(VTODOParser.formatDateOnly(due))") + } + + if !categories.isEmpty { + lines.append("CATEGORIES:\(categories.joined(separator: ","))") + } + + if let msgId = linkedMessageId { + lines.append("ATTACH:mid:\(msgId)") + } + + if isSomeday { + lines.append("X-MAGNUM-SOMEDAY:TRUE") + } + + lines.append("END:VTODO") + lines.append("END:VCALENDAR") + + // Fold lines longer than 75 octets per RFC 5545 + return lines.map(foldLine).joined(separator: "\r\n") + "\r\n" + } + + static func escapeText(_ text: String) -> String { + text.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: ",", with: "\\,") + .replacingOccurrences(of: ";", with: "\\;") + .replacingOccurrences(of: "\n", with: "\\n") + } + + static func foldLine(_ line: String) -> String { + let maxOctets = 75 + let utf8 = Array(line.utf8) + guard utf8.count > maxOctets else { return line } + + var result = "" + var offset = 0 + var isFirst = true + while offset < utf8.count { + let limit = isFirst ? maxOctets : maxOctets - 1 // continuation has leading space + let end = min(offset + limit, utf8.count) + let chunk = utf8[offset.. URL { + taskDirectory.appendingPathComponent("\(id).ics") + } + + private func rewriteFile(for record: TaskRecord) throws { + let isoFormatter = ISO8601DateFormatter() + let icsContent = VTODOFormatter.format( + uid: record.id, + summary: record.summary, + description: record.description, + status: record.status, + priority: record.priority, + dueDate: record.dueDate.flatMap { isoFormatter.date(from: $0) }, + deferUntil: record.deferUntil.flatMap { isoFormatter.date(from: $0) }, + categories: [], // Will be populated from itemLabel junction if needed + linkedMessageId: record.linkedMessageId, + isSomeday: record.isSomeday, + createdAt: isoFormatter.date(from: record.createdAt) ?? Date(), + updatedAt: isoFormatter.date(from: record.updatedAt) ?? Date() + ) + let filePath = URL(fileURLWithPath: record.filePath) + try icsContent.write(to: filePath, atomically: true, encoding: .utf8) + } +} +``` + +Note: `TaskStore` needs `import MailStore` for `LabelRecord` and `ItemLabelRecord` access in `rebuildCache`. Add this import. Alternatively, if circular dependencies are an issue, the rebuild logic that touches labels can live in the SyncEngine (which imports both). Adjust Package.swift if needed — TaskStore may need to depend on MailStore, or the label-rebuild logic moves to SyncEngine. + +- [ ] **Step 2: Create TaskStoreTests** + +Create `Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift`: + +```swift +import Testing +import Foundation +@testable import TaskStore +@testable import MailStore +import Models + +@Suite("TaskStore") +struct TaskStoreTests { + func makeTestDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("MagnumOpusTests-\(UUID().uuidString)") + .appendingPathComponent("tasks") + return dir + } + + func makeStore() throws -> (TaskStore, any DatabaseWriter) { + let db = try DatabaseSetup.openInMemoryDatabase() + let dir = try makeTestDir() + let store = TaskStore(taskDirectory: dir, dbWriter: db) + // Seed account + try db.write { db in + try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db) + } + return (store, db) + } + + @Test("writeTask creates .ics file and cache record") + func writeTask() throws { + let (store, db) = try makeStore() + try store.writeTask(id: "t1", accountId: "a1", summary: "Buy milk") + + // Verify cache + let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") } + #expect(record?.summary == "Buy milk") + #expect(record?.status == "NEEDS-ACTION") + + // Verify file + let filePath = record!.filePath + #expect(FileManager.default.fileExists(atPath: filePath)) + let content = try String(contentsOfFile: filePath, encoding: .utf8) + #expect(content.contains("SUMMARY:Buy milk")) + } + + @Test("updateStatus changes cache and file") + func updateStatus() throws { + let (store, db) = try makeStore() + try store.writeTask(id: "t1", accountId: "a1", summary: "Test") + try store.updateStatus(id: "t1", status: .completed) + + let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") } + #expect(record?.status == "COMPLETED") + + let content = try String(contentsOfFile: record!.filePath, encoding: .utf8) + #expect(content.contains("STATUS:COMPLETED")) + } + + @Test("deleteTask removes file and cache") + func deleteTask() throws { + let (store, db) = try makeStore() + try store.writeTask(id: "t1", accountId: "a1", summary: "Test") + let filePath = try db.read { db in try TaskRecord.fetchOne(db, key: "t1")!.filePath } + + try store.deleteTask(id: "t1") + + let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") } + #expect(record == nil) + #expect(!FileManager.default.fileExists(atPath: filePath)) + } + + @Test("rebuildCache restores tasks from .ics files") + func rebuildCache() throws { + let (store, db) = try makeStore() + + // Create tasks + try store.writeTask(id: "t1", accountId: "a1", summary: "Task One", categories: ["Work"]) + try store.writeTask(id: "t2", accountId: "a1", summary: "Task Two") + + // Clear cache (simulate corruption) + _ = try db.write { db in try TaskRecord.deleteAll(db) } + + // Rebuild + try store.rebuildCache(accountId: "a1") + + // Verify restored + let tasks = try db.read { db in try TaskRecord.fetchAll(db) } + #expect(tasks.count == 2) + #expect(tasks.contains(where: { $0.summary == "Task One" })) + #expect(tasks.contains(where: { $0.summary == "Task Two" })) + } + + @Test("updateDeferral changes defer date") + func updateDeferral() throws { + let (store, db) = try makeStore() + try store.writeTask(id: "t1", accountId: "a1", summary: "Test") + + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! + try store.updateDeferral(id: "t1", deferUntil: tomorrow, isSomeday: false) + + let record = try db.read { db in try TaskRecord.fetchOne(db, key: "t1") } + #expect(record?.deferUntil != nil) + #expect(record?.isSomeday == false) + } +} +``` + +- [ ] **Step 3: Run TaskStore tests** + +```bash +swift test --package-path Packages/MagnumOpusCore --filter TaskStoreTests +``` + +- [ ] **Step 4: Run all tests to check for regressions** + +```bash +swift test --package-path Packages/MagnumOpusCore +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "add TaskStore: file I/O, cache management, rebuild from .ics files" +``` + +--- + +## Chunk 3: Deferral Resurfacing & SyncEngine + +### Task 7: Deferral Resurfacing in SyncCoordinator + +**Files:** +- Edit: `Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift` + +- [ ] **Step 1: Add resurfacing check to performSync** + +Add a `resurfaceDeferrals()` method to SyncCoordinator that: +1. Gets current ISO8601 date string +2. Queries `expiredDeferrals(beforeDate:)` from MailStore → delete each deferral record +3. Queries `expiredDeferredTasks(beforeDate:)` from MailStore → clear deferUntil for each task +4. If TaskStore is available, rewrite the .ics file for each resurfaced task + +Call `resurfaceDeferrals()` at the start of `performSync()`, before flushing the action queue. + +```swift +// Add to SyncCoordinator: +private let taskStore: TaskStore? + +// Update init: +public init( + accountConfig: AccountConfig, + imapClient: any IMAPClientProtocol, + store: MailStore, + actionQueue: ActionQueue? = nil, + taskStore: TaskStore? = nil +) { ... } + +// Add method: +private func resurfaceDeferrals() { + let isoFormatter = ISO8601DateFormatter() + let now = isoFormatter.string(from: Date()) + + do { + // Resurface emails + let expiredDeferrals = try store.expiredDeferrals(beforeDate: now) + for deferral in expiredDeferrals { + try store.deleteDeferral(id: deferral.id) + } + + // Resurface tasks + let expiredTasks = try store.expiredDeferredTasks(beforeDate: now) + for task in expiredTasks { + try store.clearTaskDeferral(id: task.id) + try taskStore?.updateDeferral(id: task.id, deferUntil: nil, isSomeday: false) + } + } catch { + // Resurfacing is non-fatal — log and continue with sync + print("[SyncCoordinator] resurfacing failed: \(error)") + } +} +``` + +- [ ] **Step 2: Call resurfaceDeferrals in performSync** + +```swift +private func performSync() async throws { + // Resurface deferred items + resurfaceDeferrals() + + // Flush pending actions + if let queue = actionQueue { + await queue.flush() + } + + // ... existing IMAP sync code ... +} +``` + +- [ ] **Step 3: Verify build and existing tests pass** + +```bash +swift build --package-path Packages/MagnumOpusCore +swift test --package-path Packages/MagnumOpusCore +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "add deferral resurfacing to SyncCoordinator: emails and tasks" +``` + +--- + +## Chunk 4: App UI — Perspectives & Task Views + +### Task 8: Sidebar Perspectives + +**Files:** +- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift` +- Edit: `Apps/MagnumOpus/Views/SidebarView.swift` + +- [ ] **Step 1: Update Perspective enum in MailViewModel** + +Replace the existing Perspective enum with: + +```swift +enum Perspective: String, CaseIterable, Identifiable { + case inbox + case today + case upcoming + case projects + case someday + case archive + + var id: String { rawValue } + + var label: String { + switch self { + case .inbox: return "Inbox" + case .today: return "Today" + case .upcoming: return "Upcoming" + case .projects: return "Projects" + case .someday: return "Someday" + case .archive: return "Archive" + } + } + + var systemImage: String { + switch self { + case .inbox: return "tray" + case .today: return "star" + case .upcoming: return "calendar" + case .projects: return "folder" + case .someday: return "archivebox" + case .archive: return "checkmark.circle" + } + } +} +``` + +- [ ] **Step 2: Add unified item loading per perspective** + +Add methods to MailViewModel that load `[ItemSummary]` for each perspective, combining emails and tasks from MailStore queries. Each perspective uses the appropriate queries defined in Task 3. + +- [ ] **Step 3: Update SidebarView with new perspectives** + +Update SidebarView to show all 6 perspectives with icons and unread/item counts. The Projects perspective shows project labels as sub-items. + +- [ ] **Step 4: Verify app compiles** + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "add sidebar perspectives: inbox, today, upcoming, projects, someday, archive" +``` + +--- + +### Task 9: Task Creation Views + +**Files:** +- Create: `Apps/MagnumOpus/ViewModels/TaskEditViewModel.swift` +- Create: `Apps/MagnumOpus/Views/TaskEditView.swift` + +- [ ] **Step 1: Create TaskEditViewModel** + +`@Observable @MainActor` class with: +- `title: String`, `body: String`, `dueDate: Date?`, `hasDueDate: Bool` +- `linkedMessageId: String?` (set when creating from email) +- `save() throws` → calls `TaskStore.writeTask()` +- Constructor takes `accountConfig`, `taskStore`, optional `linkedMessageId` and pre-filled `title` + +- [ ] **Step 2: Create TaskEditView** + +Compact form view: +- Title TextField (required) +- Body TextEditor (optional, expandable) +- Due date toggle + DatePicker +- Save button (⌘⏎), Cancel button +- Sheet presentation on iOS, popover on macOS + +- [ ] **Step 3: Wire task creation to keyboard shortcuts** + +In ContentView or appropriate parent: +- `⌘⇧N` → open TaskEditView (standalone) +- `t` (when thread selected) → open TaskEditView with linkedMessageId and pre-filled title + +- [ ] **Step 4: Verify app compiles** + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "add task creation: TaskEditViewModel, TaskEditView, keyboard shortcuts" +``` + +--- + +### Task 10: Defer Picker & Label Picker + +**Files:** +- Create: `Apps/MagnumOpus/Views/DeferPicker.swift` +- Create: `Apps/MagnumOpus/Views/LabelPicker.swift` + +- [ ] **Step 1: Create DeferPicker** + +Compact popover with: +- "Tomorrow" button → defer to tomorrow +- "Next Week" button → defer to next Monday +- "Pick Date" → inline DatePicker +- "Someday" button → defer to someday + +```swift +struct DeferPicker: View { + let onDefer: (Date?) -> Void // nil = someday + @Environment(\.dismiss) private var dismiss + @State private var showDatePicker = false + @State private var customDate = Date() + + var body: some View { + VStack(spacing: 8) { + Button("Tomorrow") { + onDefer(Calendar.current.date(byAdding: .day, value: 1, to: Date())) + dismiss() + } + Button("Next Week") { + onDefer(nextMonday()) + dismiss() + } + if showDatePicker { + DatePicker("", selection: $customDate, displayedComponents: .date) + .datePickerStyle(.graphical) + Button("Defer to Selected Date") { + onDefer(customDate) + dismiss() + } + } else { + Button("Pick Date...") { showDatePicker = true } + } + Divider() + Button("Someday") { + onDefer(nil) + dismiss() + } + } + .padding() + #if os(macOS) + .frame(width: 250) + #endif + } + + private func nextMonday() -> Date { + let cal = Calendar.current + let today = Date() + let weekday = cal.component(.weekday, from: today) + // Monday = 2 in Calendar + let daysUntilMonday = (9 - weekday) % 7 + return cal.date(byAdding: .day, value: daysUntilMonday == 0 ? 7 : daysUntilMonday, to: today)! + } +} +``` + +- [ ] **Step 2: Create LabelPicker** + +Compact popover with: +- Searchable list of existing project labels +- Type-to-create: "Create project: " option when no match +- On select: callback with label + +```swift +struct LabelPicker: View { + let labels: [LabelInfo] + let onSelect: (LabelInfo) -> Void + let onCreate: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + var filtered: [LabelInfo] { + if searchText.isEmpty { return labels } + return labels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + var showCreateOption: Bool { + !searchText.isEmpty && !labels.contains(where: { $0.name.lowercased() == searchText.lowercased() }) + } + + var body: some View { + VStack { + TextField("Search or create...", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + List { + if showCreateOption { + Button("Create project: \(searchText)") { + onCreate(searchText) + dismiss() + } + } + ForEach(filtered) { label in + Button(label.name) { + onSelect(label) + dismiss() + } + } + } + } + #if os(macOS) + .frame(width: 250, height: 300) + #endif + } +} +``` + +- [ ] **Step 3: Verify app compiles** + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "add DeferPicker, LabelPicker views for GTD triage" +``` + +--- + +### Task 11: GTD Triage Actions in MailViewModel + +**Files:** +- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift` +- Edit: `Apps/MagnumOpus/Views/ThreadListView.swift` + +- [ ] **Step 1: Add GTD triage methods to MailViewModel** + +```swift +// MARK: - GTD Triage + +func deferSelectedItem(until date: Date?) async { + // If date is nil → someday + guard let item = selectedItem else { return } + switch item { + case .email(let msg): + let deferral = DeferralRecord( + id: UUID().uuidString, + messageId: msg.id, + deferUntil: date.map { ISO8601DateFormatter().string(from: $0) }, + originalMailbox: selectedMailbox?.name, + createdAt: ISO8601DateFormatter().string(from: Date()) + ) + try? store?.insertDeferral(deferral) + case .task(let task): + try? taskStore?.updateDeferral(id: task.id, deferUntil: date, isSomeday: date == nil) + } + autoAdvance() +} + +func fileSelectedItem(to label: LabelInfo) async { + guard let item = selectedItem else { return } + let (itemType, itemId) = itemTypeAndId(item) + try? store?.attachLabel(labelId: label.id, itemType: itemType, itemId: itemId) + // For tasks, also update VTODO CATEGORIES + if case .task(let task) = item { + let allLabels = (try? store?.labelsForItem(itemType: "task", itemId: task.id)) ?? [] + let catNames = allLabels.map(\.name) + // Rewrite .ics with updated categories via TaskStore + // (TaskStore.rewriteFile would need a categories parameter, or + // read labels from DB during rewrite. Simplest: re-call writeTask + // with current values + updated categories.) + } + autoAdvance() +} + +func discardSelectedItem() async { + guard let item = selectedItem else { return } + switch item { + case .email: + await archiveSelectedThread() // existing v0.3 action + case .task(let task): + try? taskStore?.updateStatus(id: task.id, status: .cancelled) + } + autoAdvance() +} + +func completeSelectedItem() async { + guard let item = selectedItem else { return } + switch item { + case .email: + await archiveSelectedThread() + case .task(let task): + try? taskStore?.updateStatus(id: task.id, status: .completed) + } + autoAdvance() +} +``` + +- [ ] **Step 2: Add GTD keyboard shortcuts to ThreadListView** + +Add toolbar buttons and keyboard shortcuts for the new GTD actions: +- `d` → show DeferPicker +- `⇧D` → defer to someday immediately +- `p` → show LabelPicker +- `⌘⏎` → complete item + +Update `⌫` behavior: in GTD perspectives, call `discardSelectedItem()` instead of `deleteSelectedThread()`. + +- [ ] **Step 3: Verify app compiles** + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "add GTD triage actions: defer, file to project, discard, complete" +``` + +--- + +### Task 12: Thread Detail — Linked Tasks + +**Files:** +- Edit: `Apps/MagnumOpus/Views/ThreadDetailView.swift` + +- [ ] **Step 1: Query linked tasks for the current thread** + +When displaying a thread, also query `store.tasksLinkedToThread(threadId:)` and interleave task items in the timeline: + +```swift +// In thread detail, after loading messages: +let linkedTasks = try? store.tasksLinkedToThread(threadId: thread.id) +``` + +- [ ] **Step 2: Render tasks inline with distinct style** + +Show tasks with: +- Checkbox icon (filled if completed, empty if active) +- Task summary as title +- Status badge (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) +- Tapping a task could toggle its status + +- [ ] **Step 3: Verify app compiles** + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "show linked tasks inline in thread detail view" +``` + +--- + +## Chunk 5: Integration & Polish + +### Task 13: End-to-End Wiring + +**Files:** +- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift` +- Edit: `Apps/MagnumOpus/ContentView.swift` + +- [ ] **Step 1: Initialize TaskStore in MailViewModel.setup()** + +```swift +// In setup(config:credentials:): +let taskDir = Self.taskDirectory(accountId: config.id) +// Note: MailStore needs a public `databaseWriter` accessor added. Currently `dbWriter` +// is internal. Add `public var databaseWriter: any DatabaseWriter { dbWriter }` to MailStore. +let taskStore = TaskStore(taskDirectory: taskDir, dbWriter: store.databaseWriter) +self.taskStore = taskStore +``` + +- [ ] **Step 2: Pass TaskStore to SyncCoordinator** + +```swift +let coordinator = SyncCoordinator( + accountConfig: config, + imapClient: imapClient, + store: store, + actionQueue: queue, + taskStore: taskStore +) +``` + +- [ ] **Step 3: Wire TaskEditView presentation** + +Connect `⌘⇧N` and `t` shortcuts to TaskEditView presentation in ContentView. + +- [ ] **Step 4: Verify full flow works** + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "wire end-to-end: TaskStore in MailViewModel, SyncCoordinator, task creation" +``` + +--- + +### Task 14: Final Tests & Cleanup + +- [ ] **Step 1: Run all package tests** + +```bash +swift test --package-path Packages/MagnumOpusCore +``` + +Fix any failures. + +- [ ] **Step 2: Verify app builds for macOS and iOS** + +- [ ] **Step 3: Remove any leftover placeholder files** + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "fix test failures, verify builds for macOS and iOS" +``` + +--- + +### Task 15: Version Bump + +- [ ] **Step 1: Bump CalVer to 2026.03.14+1** + +Update version in relevant config files. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "bump calver to 2026.03.14+1, v0.4: GTD tasks, unified triage, deferrals, labels" +``` + +--- + +## Summary + +| Chunk | Tasks | Focus | +|-------|-------|-------| +| 1: Models & Schema | 1–3 | Model types, records, migrations, MailStore queries | +| 2: TaskStore Module | 4–6 | VTODO parser, formatter, file I/O, cache rebuild | +| 3: Deferral & Sync | 7 | Resurfacing in SyncCoordinator | +| 4: App UI | 8–12 | Perspectives, task creation, defer/label pickers, GTD triage, thread detail | +| 5: Integration | 13–15 | Wiring, tests, version bump | + +**Parallelizable:** Chunks 1 and 2 (schema + TaskStore) are partially independent — the VTODO parser/formatter (Tasks 4-5) have no schema dependency and can run alongside Task 2. Chunk 3 depends on both. Chunk 4 depends on everything. Chunk 5 depends on everything.