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
MainActorfor read; backgroundModelActorfor batch writes. - CloudKit sync is SwiftData-managed.
Next: Implementation guide