Files
MagnumOpus/docs/plans/2026-03-14-v0.4-implementation-plan.md
2026-03-14 08:33:21 +01:00

63 KiB
Raw Permalink Blame History

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: SyncEngineIMAPClient + MailStore + SMTPClient + TaskStoreModels.


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:

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:

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:

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:

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
swift build --package-path Packages/MagnumOpusCore --target Models
  • Step 6: Commit
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:

// 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:

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:

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:

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:

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:

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:

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
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore --filter LabelDeferralTests
  • Step 9: Commit
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

// 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
// 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
// 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:

// 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
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore
  • Step 6: Commit
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:

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)..<endIdx {
			let line = lines[i].trimmingCharacters(in: .whitespaces)
			guard !line.isEmpty else { continue }

			// Split on first colon — property name may include parameters (e.g., DTSTART;VALUE=DATE:20260315)
			guard let colonIdx = line.firstIndex(of: ":") else { continue }
			let rawName = String(line[..<colonIdx])
			let value = String(line[line.index(after: colonIdx)...])

			// Strip parameters from name (e.g., "DTSTART;VALUE=DATE" → "DTSTART")
			let name = rawName.split(separator: ";").first.map(String.init) ?? rawName
			// Unescape iCalendar text values
			properties[name] = unescapeText(value)
		}

		return properties
	}

	/// Unescape iCalendar text values
	public static func unescapeText(_ text: String) -> 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:

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
swift test --package-path Packages/MagnumOpusCore --filter VTODOParserTests
  • Step 4: Commit
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:

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..<end]
			if isFirst {
				result += String(bytes: chunk, encoding: .utf8) ?? ""
				isFirst = false
			} else {
				result += "\r\n " + (String(bytes: chunk, encoding: .utf8) ?? "")
			}
			offset = end
		}
		return result
	}
}
  • Step 2: Create VTODOFormatterTests

Create Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift:

import Testing
import Foundation
@testable import TaskStore

@Suite("VTODOFormatter")
struct VTODOFormatterTests {
	@Test("formats minimal VTODO")
	func minimalFormat() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-1",
			summary: "Buy milk",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("BEGIN:VCALENDAR"))
		#expect(result.contains("BEGIN:VTODO"))
		#expect(result.contains("UID:test-1"))
		#expect(result.contains("SUMMARY:Buy milk"))
		#expect(result.contains("STATUS:NEEDS-ACTION"))
		#expect(result.contains("PRIORITY:0"))
		#expect(result.contains("END:VTODO"))
		#expect(result.contains("END:VCALENDAR"))
	}

	@Test("includes optional fields when set")
	func optionalFields() {
		let now = Date()
		let due = Calendar.current.date(byAdding: .day, value: 7, to: now)!
		let result = VTODOFormatter.format(
			uid: "test-2",
			summary: "Review PR",
			description: "Check the auth changes",
			status: "IN-PROCESS",
			priority: 1,
			dueDate: due,
			categories: ["Work", "Code"],
			linkedMessageId: "msg-abc@example.com",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("DESCRIPTION:Check the auth changes"))
		#expect(result.contains("STATUS:IN-PROCESS"))
		#expect(result.contains("PRIORITY:1"))
		#expect(result.contains("DUE;VALUE=DATE:"))
		#expect(result.contains("CATEGORIES:Work,Code"))
		#expect(result.contains("ATTACH:mid:msg-abc@example.com"))
	}

	@Test("includes someday flag")
	func somedayFlag() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-3",
			summary: "Learn piano",
			isSomeday: true,
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("X-MAGNUM-SOMEDAY:TRUE"))
	}

	@Test("escapes special characters")
	func specialChars() {
		let now = Date()
		let result = VTODOFormatter.format(
			uid: "test-4",
			summary: "Meeting, 3pm; room 5",
			description: "Line 1\nLine 2",
			createdAt: now,
			updatedAt: now
		)
		#expect(result.contains("SUMMARY:Meeting\\, 3pm\\; room 5"))
		#expect(result.contains("DESCRIPTION:Line 1\\nLine 2"))
	}

	@Test("round-trip: format then parse")
	func roundTrip() {
		let now = Date()
		let formatted = VTODOFormatter.format(
			uid: "rt-1",
			summary: "Round trip test",
			status: "NEEDS-ACTION",
			priority: 5,
			categories: ["Test"],
			createdAt: now,
			updatedAt: now
		)
		let parsed = VTODOParser.parse(formatted)
		#expect(parsed?["UID"] == "rt-1")
		#expect(parsed?["SUMMARY"] == "Round trip test")
		#expect(parsed?["STATUS"] == "NEEDS-ACTION")
		#expect(parsed?["PRIORITY"] == "5")
		#expect(parsed?["CATEGORIES"] == "Test")
	}
}
  • Step 3: Run formatter tests
swift test --package-path Packages/MagnumOpusCore --filter VTODOFormatterTests
  • Step 4: Commit
git add -A
git commit -m "add VTODO formatter with escaping, line folding, round-trip tests"

Task 6: TaskStore (File I/O + Cache)

Files:

  • Create: Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift

  • Create: Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift

  • Step 1: Create TaskStore

Create Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift:

import Foundation
import Models
import GRDB

public struct TaskStore: Sendable {
	private let taskDirectory: URL
	private let dbWriter: any DatabaseWriter

	public init(taskDirectory: URL, dbWriter: any DatabaseWriter) {
		self.taskDirectory = taskDirectory
		self.dbWriter = dbWriter
	}

	// MARK: - Write

	/// Create or update a task: write .ics file + update SQLite cache
	public func writeTask(
		id: String,
		accountId: String,
		summary: String,
		description: String? = nil,
		status: TaskStatus = .needsAction,
		priority: Int = 0,
		dueDate: Date? = nil,
		deferUntil: Date? = nil,
		linkedMessageId: String? = nil,
		categories: [String] = [],
		isSomeday: Bool = false
	) throws {
		let now = Date()
		let filePath = taskFilePath(id: id)

		// Preserve original createdAt if updating an existing task
		let existingCreatedAt: Date? = try? dbWriter.read { db in
			try TaskRecord.fetchOne(db, key: id)
		}.flatMap { record in
			ISO8601DateFormatter().date(from: record.createdAt)
		}
		let createdAt = existingCreatedAt ?? now

		// Ensure directory exists
		try FileManager.default.createDirectory(at: taskDirectory, withIntermediateDirectories: true)

		// Write .ics file
		let icsContent = VTODOFormatter.format(
			uid: id,
			summary: summary,
			description: description,
			status: status.rawValue,
			priority: priority,
			dueDate: dueDate,
			deferUntil: deferUntil,
			categories: categories,
			linkedMessageId: linkedMessageId,
			isSomeday: isSomeday,
			createdAt: createdAt,
			updatedAt: now
		)
		try icsContent.write(to: filePath, atomically: true, encoding: .utf8)

		// Update cache
		let isoFormatter = ISO8601DateFormatter()
		let record = TaskRecord(
			id: id,
			accountId: accountId,
			summary: summary,
			description: description,
			status: status.rawValue,
			priority: priority,
			dueDate: dueDate.map { isoFormatter.string(from: $0) },
			deferUntil: deferUntil.map { isoFormatter.string(from: $0) },
			createdAt: isoFormatter.string(from: createdAt),
			updatedAt: isoFormatter.string(from: now),
			filePath: filePath.path,
			linkedMessageId: linkedMessageId,
			isSomeday: isSomeday
		)
		try dbWriter.write { db in
			try record.save(db)  // insert or update
		}
	}

	/// Update task status (and rewrite .ics file)
	public func updateStatus(id: String, status: TaskStatus) throws {
		guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
		record.status = status.rawValue
		let isoFormatter = ISO8601DateFormatter()
		record.updatedAt = isoFormatter.string(from: Date())
		try dbWriter.write { db in try record.update(db) }
		try rewriteFile(for: record)
	}

	/// Update task defer date (and rewrite .ics file)
	public func updateDeferral(id: String, deferUntil: Date?, isSomeday: Bool) throws {
		guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { return }
		let isoFormatter = ISO8601DateFormatter()
		record.deferUntil = deferUntil.map { isoFormatter.string(from: $0) }
		record.isSomeday = isSomeday
		record.updatedAt = isoFormatter.string(from: Date())
		try dbWriter.write { db in try record.update(db) }
		try rewriteFile(for: record)
	}

	/// Delete task: remove .ics file + cache record
	public func deleteTask(id: String) throws {
		let filePath = taskFilePath(id: id)
		try? FileManager.default.removeItem(at: filePath)
		_ = try dbWriter.write { db in try TaskRecord.deleteOne(db, key: id) }
	}

	// MARK: - Cache Rebuild

	/// Scan task directory, rebuild SQLite cache from .ics files
	public func rebuildCache(accountId: String) throws {
		// Clear existing cache
		_ = try dbWriter.write { db in
			try TaskRecord.filter(Column("accountId") == accountId).deleteAll(db)
			try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'")
		}

		let fm = FileManager.default
		guard fm.fileExists(atPath: taskDirectory.path) else { return }

		let files = try fm.contentsOfDirectory(at: taskDirectory, includingPropertiesForKeys: nil)
			.filter { $0.pathExtension == "ics" }

		let isoFormatter = ISO8601DateFormatter()

		for file in files {
			let content = try String(contentsOf: file, encoding: .utf8)
			guard let props = VTODOParser.parse(content) else { continue }
			guard let uid = props["UID"] else { continue }

			let record = TaskRecord(
				id: uid,
				accountId: accountId,
				summary: props["SUMMARY"] ?? "(untitled)",
				description: props["DESCRIPTION"],
				status: props["STATUS"] ?? "NEEDS-ACTION",
				priority: Int(props["PRIORITY"] ?? "0") ?? 0,
				dueDate: props["DUE"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
				deferUntil: props["DTSTART"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) },
				createdAt: props["CREATED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
					?? isoFormatter.string(from: Date()),
				updatedAt: props["LAST-MODIFIED"].flatMap(VTODOParser.parseDate).map { isoFormatter.string(from: $0) }
					?? isoFormatter.string(from: Date()),
				filePath: file.path,
				linkedMessageId: props["ATTACH"]?.hasPrefix("mid:") == true
					? String(props["ATTACH"]!.dropFirst(4)) : nil,
				isSomeday: props["X-MAGNUM-SOMEDAY"] == "TRUE"
			)
			try dbWriter.write { db in try record.insert(db) }

			// Rebuild labels from CATEGORIES
			if let cats = props["CATEGORIES"], !cats.isEmpty {
				let categoryNames = cats.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
				try dbWriter.write { db in
					for catName in categoryNames {
						// Find or create label
						var label = try LabelRecord.filter(Column("accountId") == accountId)
							.filter(Column("name") == catName)
							.fetchOne(db)
						if label == nil {
							label = LabelRecord(id: UUID().uuidString, accountId: accountId, name: catName, isProject: true)
							try label!.insert(db)
						}
						try ItemLabelRecord(labelId: label!.id, itemType: "task", itemId: uid).insert(db)
					}
				}
			}
		}
	}

	// MARK: - Helpers

	private func taskFilePath(id: String) -> 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:

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
swift test --package-path Packages/MagnumOpusCore --filter TaskStoreTests
  • Step 4: Run all tests to check for regressions
swift test --package-path Packages/MagnumOpusCore
  • Step 5: Commit
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.

// 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
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
swift build --package-path Packages/MagnumOpusCore
swift test --package-path Packages/MagnumOpusCore
  • Step 4: Commit
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:

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

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

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

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

// 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

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:

// 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

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()

// 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
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

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

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
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 13 Model types, records, migrations, MailStore queries
2: TaskStore Module 46 VTODO parser, formatter, file I/O, cache rebuild
3: Deferral & Sync 7 Resurfacing in SyncCoordinator
4: App UI 812 Perspectives, task creation, defer/label pickers, GTD triage, thread detail
5: Integration 1315 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.