# 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.