PlanBoard — Architecture

Project structure

PlanBoard/
  Targets/
    PlanBoard-iOS/                  # iOS + iPadOS target
      App.swift
      Info.plist
    PlanBoard-macOS/                # macOS target
      App.swift
      Info.plist
    PlanBoardWidget-iOS/            # iOS widget extension
    PlanBoardWidget-macOS/          # macOS menu bar widget
  Packages/
    PlanBoardCore/                  # models, SwiftData schema, errors
    PlanBoardSync/                  # CloudKit configuration wrappers
    PlanBoardShared/                # SwiftUI views shared across all platforms
    PlanBoardiOS/                   # iOS/iPad-specific views and modifiers
    PlanBoardMac/                   # macOS-specific views, toolbar, menu bar
    PlanBoardIntents/               # AppIntents

The split between PlanBoardShared, PlanBoardiOS, and PlanBoardMac is the heart of the cross-platform strategy. See platform-decision-record.md for the per-decision rationale.

Data model (SwiftData)

@Model public final class Board {
    @Attribute(.unique) public var id: UUID
    public var name: String
    public var sortOrder: Int
    public var createdAt: Date
    @Relationship(deleteRule: .cascade, inverse: \Column.board)
    public var columns: [Column] = []
    public init(id: UUID = UUID(), name: String, sortOrder: Int = 0) {
        self.id = id; self.name = name; self.sortOrder = sortOrder; self.createdAt = .now
    }
}

@Model public final class Column {
    public var name: String
    public var sortOrder: Int
    public var board: Board?
    @Relationship(deleteRule: .cascade, inverse: \Card.column)
    public var cards: [Card] = []
    public init(name: String, sortOrder: Int) {
        self.name = name; self.sortOrder = sortOrder
    }
}

@Model public final class Card {
    @Attribute(.unique) public var id: UUID
    public var title: String
    public var notes: String?
    public var dueDate: Date?
    public var priority: Int                  // 0=low, 1=med, 2=high
    public var colorTag: String?              // hex
    public var sortOrder: Int
    public var column: Column?
    public var createdAt: Date
    public init(id: UUID = UUID(), title: String, sortOrder: Int = 0) {
        self.id = id; self.title = title; self.sortOrder = sortOrder
        self.priority = 0; self.createdAt = .now
    }
}

CloudKit-backed:

let config = ModelConfiguration(
    cloudKitDatabase: .private("iCloud.com.yourorg.planboard")
)

SwiftUI composition strategy

Three layers:

Layer 1: Shared views (90% of UI)

// In PlanBoardShared
public struct CardView: View {
    @Bindable public var card: Card
    public init(card: Card) { self.card = card }

    public var body: some View {
        VStack(alignment: .leading) {
            Text(card.title).font(.headline)
            if let due = card.dueDate {
                Label(due.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar")
                    .font(.caption)
            }
        }
        .padding(8)
        .background(.regularMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

This compiles unchanged on iOS, iPadOS, macOS. The card looks at home on all three.

Layer 2: Platform-conditional modifiers

// In PlanBoardShared
extension View {
    public func planboardNavigationStyle() -> some View {
        #if os(macOS)
        self.navigationSplitViewStyle(.balanced)
        #else
        self.navigationSplitViewStyle(.automatic)
        #endif
    }

    public func planboardCardHover() -> some View {
        #if os(macOS)
        self.onHover { _ in /* visual feedback */ }
        #else
        self
        #endif
    }
}

Layer 3: Platform-specific shells

PlanBoardiOS/RootView.swift and PlanBoardMac/RootView.swift are distinct files. Each composes the shared views inside a platform-native shell. Mac gets a NavigationSplitView with Toolbar, CommandMenu, and a MenuBarExtra. iOS gets a NavigationStack (iPhone) or NavigationSplitView (iPad).

CloudKit sync

Same engine as NoteSync but simpler — no shared zones, only private DB. SwiftData’s auto-sync handles most of it; we add a manual CKContainer.requestApplicationPermission flow + a “Sync Now” button.

Widget + menu bar item

iOS widget (WidgetKit):

struct PlanBoardWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "PlanBoard", provider: TopCardsProvider()) { entry in
            TopCardsView(entry: entry)
        }
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

macOS menu bar (MenuBarExtra is built into SwiftUI on macOS 13+):

@main struct PlanBoardMacApp: App {
    var body: some Scene {
        WindowGroup { RootView() }
        MenuBarExtra("PlanBoard", systemImage: "rectangle.3.group") {
            MenuBarContent()
        }
        .menuBarExtraStyle(.window)
    }
}

Same TopCardsView is reused in both — the only platform delta is the host.

AppIntents

One shared package. Available to Shortcuts on every platform. Each intent uses @MainActor and reads/writes the SwiftData store via a shared BoardStore actor.

ADRs

ADR-001: Native macOS, not Catalyst

Same rationale as NoteSync ADR-001. Catalyst would have been faster but produces a less Mac-feeling app. For a portfolio piece whose point is “I can ship a Mac app that feels native,” Catalyst defeats the demonstration.

ADR-002: Three layers of view sharing (shared / conditional / platform shell)

Alternatives considered: (a) all #if os(...) everywhere — works, but conditionals leak into every file, hard to read. (b) Two completely separate UI codebases — defeats the universal-binary goal, doubles maintenance. (c) Our approach: shared views by default, conditional modifiers where the change is small, platform shells where the change is structural. Hits the sweet spot.

ADR-003: SwiftData + CloudKit (auto-sync), not custom engine

Unlike NoteSync (which needed conflict-resolution hooks), PlanBoard’s data is mostly add/move/delete operations on small records. Conflicts are rare and SwiftData’s last-writer-wins is acceptable. Choosing the easy path here is the right tradeoff for the scope.

ADR-004: MenuBarExtra over a separate menu bar app

MenuBarExtra lives inside the main app — same process, same SwiftData container, no extension boundary to cross. Simpler than building a dedicated menu bar app talking to the main via IPC.

ADR-005: platform-decision-record.md as a first-class artifact

Every #if os(...) block in the codebase has a corresponding entry in platform-decision-record.md with a date and one-line rationale. Reviewable, dated, evolvable.

Threading

  • All views @MainActor.
  • SwiftData operations on MainActor for read; background ModelActor for batch writes.
  • CloudKit sync is SwiftData-managed.

Next: Implementation guide