import Foundation // MARK: - BoardDiff /// **What changed between two snapshots**, as identities grouped by what happened to them — the /// summarizer 10-accessibility.md ▸ Live board announcements asks for ("one polite digest per /// reload debounce — 'Board changed: 2 cards edited, 1 card added'"). /// /// ### Snapshots, not a journal /// /// The watcher reports *that* the tree changed, never *what* (`WatcherEvent`), and 02-architecture.md /// is explicit about why that is the right shape: "a value-type snapshot comparison is both cheaper /// and more trustworthy than trying to reconstruct a tree mutation from a stream of paths that may /// have been coalesced, dropped, or reordered". So this is a comparison of two `BoardModel` values /// and nothing else — no FSEvents paths, no receipts, no git. That is also what makes it work on a /// no-git board, which 10 requires in as many words ("on no-git boards the same classifier runs /// without the committer"). /// /// ### Why it lives in `LiveStore/` and not beside the announcement /// /// 10 says announcements "reuse the auto-committer's summarizer" and 06-history-undo.md says the /// committer synthesizes its commit messages from the same comparison. The committer is Pro-edition /// work (pro-m1) and does not exist yet, so the summarizer is built here first, in the layer both /// consumers can reach: base compiles `Kanban/`, Pro compiles `Kanban/` *plus* `KanbanPro/` /// (12-editions.md ▸ Targets), so a type in the store layer is available to the committer by /// construction while a type in `KanbanPro/` would not be available to the announcer. It imports /// `Foundation` alone and touches no view, no window, and no `NSAccessibility` — the phrasing is /// `AccessibilityPhrases`' job, and pro-m1's message composer will phrase the very same counts /// differently without either of them knowing about the other. /// /// The per-category **identity sets** rather than bare tallies are for that second consumer: a /// spoken digest only ever needs `count`, but a semantic commit message needs to name the items, and /// a summarizer that threw the ids away would have to be rewritten rather than extended. /// /// ### Two counting rules worth stating /// /// - **Implied events don't steal the subject** (06-history-undo.md's composer discipline, which 10 /// applies to speech in as many words). A lane that vanished takes its cards with it, and a board /// that says "1 lane deleted, 5 cards deleted" has reported one event twice. So a card is only /// counted as added or deleted when its lane was *there on both sides*; cards arriving with a new /// lane and cards leaving with a deleted one are the lane's event, not their own. /// - **Every changed identity lands in exactly one bucket**, with `moved` outranking `edited`. An /// agent that re-files a card *and* retitles it made one change to the board, and two fragments /// counting the same card would read as two cards. Position wins because it is the change the /// board's shape shows. public struct BoardDiff: Sendable, Equatable { /// One kind's four buckets. Disjoint by construction — see the type's note on precedence. public struct Changes: Sendable, Equatable { /// Identities present in the new snapshot and not the old — minus the ones whose arrival is /// implied by their lane's. public var added: Set = [] /// Identities present on both sides whose *rendered content* differs — title, body, style, /// attachments, lane width. Deliberately not `modified`, `modified-by`, or unknown keys: a /// digest describes what the board looks like, and a bumped timestamp changes nothing a /// user could see. Deliberately not `order` either, which is the `moved` axis. public var edited: Set = [] /// Identities present on both sides that changed position — a different `order`, or (for a /// card) a different lane. public var moved: Set = [] /// Identities present in the old snapshot and not the new — minus the ones whose departure /// is implied by their lane's. "Deleted" rather than "removed" because that is what it is /// from the board's side: deletion is a move into `.trash/` (01-storage-format.md § /// Deletion), and a folder moved clean out of the board reads the same way to a user. public var deleted: Set = [] public init() {} public var isEmpty: Bool { added.isEmpty && edited.isEmpty && moved.isEmpty && deleted.isEmpty } } public var cards = Changes() public var lanes = Changes() /// Whether the board side of the snapshot differs **at all** — the backstop for changes no /// bucket counts: a renamed board, an edited board description, a bumped stamp. /// /// **The trash is excluded on purpose.** A card entering the trash already counts as a card /// deleted (it left the board's universe) and one leaving it counts as added, which is the /// user's own reading of both; churn *inside* the trash — a purge, an Empty Trash run by an /// agent — moves nothing on the board and is not worth interrupting for. See the report's note: /// a visible trash column changing under a VoiceOver user is a design gap 10 does not rule on. public var boardChanged = false public init() {} /// Nothing to say: no counted change, and no uncounted one either. public var isSilent: Bool { !boardChanged && cards.isEmpty && lanes.isEmpty } // MARK: - The comparison /// The whole summarizer: two snapshots in, one grouped diff out. Pure, total, and free of any /// notion of *why* the tree changed — `WatchOrigin` decides whether anyone is told /// (`BoardAnnouncer`), never this. public static func between(_ old: BoardModel, _ new: BoardModel) -> BoardDiff { var diff = BoardDiff() diff.boardChanged = boardSideDiffers(old, new) let oldLanes = laneIndex(of: old) let newLanes = laneIndex(of: new) for id in newLanes.keys where oldLanes[id] == nil { diff.lanes.added.insert(id) } for id in oldLanes.keys where newLanes[id] == nil { diff.lanes.deleted.insert(id) } for (id, newLane) in newLanes { guard let oldLane = oldLanes[id] else { continue } if oldLane.order != newLane.order { diff.lanes.moved.insert(id) } else if contentDiffers(oldLane, newLane) { diff.lanes.edited.insert(id) } } let oldCards = cardIndex(of: old) let newCards = cardIndex(of: new) for (id, entry) in newCards where oldCards[id] == nil { // The implied-arrival rule: a card that came in with a brand-new lane is the lane's // event. guard !diff.lanes.added.contains(entry.lane) else { continue } diff.cards.added.insert(id) } for (id, entry) in oldCards where newCards[id] == nil { // The implied-departure rule, and the one the vanishing-focus announcement leans on: // when a lane goes, the announcement names the lane and its count, not five cards. guard !diff.lanes.deleted.contains(entry.lane) else { continue } diff.cards.deleted.insert(id) } for (id, newEntry) in newCards { guard let oldEntry = oldCards[id] else { continue } if oldEntry.lane != newEntry.lane || oldEntry.card.order != newEntry.card.order { diff.cards.moved.insert(id) } else if contentDiffers(oldEntry.card, newEntry.card) { diff.cards.edited.insert(id) } } return diff } // MARK: - Indices private static func laneIndex(of snapshot: BoardModel) -> [ItemID: Lane] { Dictionary(uniqueKeysWithValues: snapshot.lanes.map { ($0.id, $0) }) } /// Every board-side card, with the lane it sits in. The trash is not walked: its cards are not /// on the board, and the two crossings that matter (in and out) are already visible as a /// departure and an arrival from this universe. private static func cardIndex(of snapshot: BoardModel) -> [ItemID: (lane: ItemID, card: Card)] { var index: [ItemID: (lane: ItemID, card: Card)] = [:] for lane in snapshot.lanes { for card in lane.cards { index[card.id] = (lane.id, card) } } return index } // MARK: - Content /// A card's rendered content, field by field rather than by whole-value equality. /// /// Spelled out because whole-value equality would fold in `order` (the `moved` axis) and the /// `modified`/`modified-by` stamps, so every reorder would also read as an edit and every /// touch-only rewrite would announce a change nobody could see. A rendered field added to `Card` /// belongs on this list — the cost of the explicitness, recorded so it is not rediscovered. private static func contentDiffers(_ old: Card, _ new: Card) -> Bool { old.schema != new.schema || old.title != new.title || old.body != new.body || old.background != new.background || old.icon != new.icon || old.iconColor != new.iconColor || old.attachments != new.attachments } /// A lane's rendered content — the card's list, minus attachments (a lane has none) plus /// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards* /// are diffed as cards, never folded in here. private static func contentDiffers(_ old: Lane, _ new: Lane) -> Bool { old.schema != new.schema || old.title != new.title || old.body != new.body || old.background != new.background || old.icon != new.icon || old.iconColor != new.iconColor || old.width != new.width } /// Whether anything on the board side differs, trash excluded. /// /// `trash` is the one `var` on `BoardModel`, which is what makes "everything except the trash" /// expressible as value equality rather than as a second field-by-field list that would go /// stale the moment the model grows a field. private static func boardSideDiffers(_ old: BoardModel, _ new: BoardModel) -> Bool { var oldSansTrash = old var newSansTrash = new oldSansTrash.trash = [] newSansTrash.trash = [] return oldSansTrash != newSansTrash } }