import Foundation // MARK: - One label's standing on a board /// One label the board actually uses, and how many cards use it. public struct LabelTally: Sendable, Equatable { /// The label as it is **spelled on the board** — the first spelling the derivation met, in board /// order (lanes left to right, each lane's cards top to bottom, then the trash). /// /// Case-insensitive uniqueness is a per-*card* rule (`CardLabels`), so two cards may perfectly /// legally spell one label two ways. The universe has to pick one to offer, and the first is the /// only choice that does not depend on how many cards happen to carry each variant — a count-based /// pick would make a menu row's capitalisation flicker as cards are tagged. public let name: String /// How many **cards** carry it — never how many times it appears, which is the same number: a /// card's own list is deduplicated by the reading. public let count: Int public init(name: String, count: Int) { self.name = name self.count = count } } // MARK: - The universe /// **Every label the board uses, with its frequency** — the "used labels" universe both label /// surfaces draw from (the sidebar's autocomplete and the context menu's twelve rows plus its More… /// dialog). /// /// ### Derived from the snapshot, and there is no other store /// /// The owner's ruling, and the format's own posture: the files *are* the board, so the set of labels /// in use is a fact about the cards rather than a list the app maintains beside them. A label exists /// because a card carries it and stops existing when the last card drops it — there is no rename, no /// palette, no orphan to garbage-collect, and an agent that hand-writes `labels: [spike]` into an /// `index.md` has created a label as fully as the menu can. /// /// **The trash counts.** `BoardModel.trash` is walked beside `lanes` because a trashed card is "an /// ordinary card in a special place" (03-board-ui.md § Trash) and, more to the point, because the /// alternative is worse: deleting the only card carrying `spike` would silently evict the label from /// the autocomplete, and restoring the card would bring it back — a vocabulary that flickers with the /// trash is not a vocabulary. `trashedLanes` is deliberately *not* walked: a trashed lane is an opaque /// unit whose cards the snapshot never loads (`TrashedLane`), so there is nothing there to count. /// /// ### Cost /// /// One pass over every live and trashed card, reading a field the loader already parsed — the same /// cost class as any other whole-board derivation, and paid **once per snapshot** (`BoardStore` /// caches it, equality-gated) rather than once per view body. That is what keeps the context menu's /// builder free of O(board) work: everything a card face reads is this value, already built. public struct LabelIndex: Sendable, Equatable { /// Every used label, **ordered by frequency descending, then case-insensitively alphabetically** /// — a total, snapshot-only order, so two runs over the same board produce the identical array. /// /// The alphabetical tie-break is here rather than left to the ranking because this ordering has to /// be deterministic on its own: it is what `universe` shows and what the ranking starts from, and /// a derivation whose ties fell out of dictionary iteration order would make the More… dialog /// reshuffle itself between reloads that changed nothing. public let tallies: [LabelTally] public init(tallies: [LabelTally]) { self.tallies = tallies } /// The empty board's index — no cards, or no card carrying a label. public static let empty = LabelIndex(tallies: []) /// Every used label's display name, in `tallies`' order. public var names: [String] { tallies.map(\.name) } public var isEmpty: Bool { tallies.isEmpty } /// The universe as the More… dialog lists it: **alphabetically, case-insensitively**, because a /// dialog whose whole job is "find the one you mean among all of them" is a lookup, and a /// frequency order is a lookup you cannot do. public var alphabetical: [String] { tallies.map(\.name).sorted { Self.precedesAlphabetically($0, $1) } } /// Whether the board already uses a case-insensitively equal name — the create field's "this is /// not new" test. public func contains(_ name: String) -> Bool { CardLabels.contains(name, in: names) } /// The board's own spelling of a name, when it has one — so a user who types `BUG` into the create /// field adds the `bug` the rest of the board already says, rather than minting a second variant. public func canonicalSpelling(of name: String) -> String? { guard let name = CardLabels.normalized(name) else { return nil } let key = CardLabels.canonical(name) return tallies.first { CardLabels.canonical($0.name) == key }?.name } // MARK: Derivation /// The universe of `snapshot`, live cards and trash alike. public static func derive(from snapshot: BoardModel) -> LabelIndex { var order: [String] = [] var counts: [String: Int] = [:] var spellings: [String: String] = [:] func absorb(_ card: Card) { for name in card.labels.value ?? [] { let key = CardLabels.canonical(name) if spellings[key] == nil { spellings[key] = name order.append(key) } counts[key, default: 0] += 1 } } for lane in snapshot.lanes { for card in lane.cards { absorb(card) } } for card in snapshot.trash { absorb(card) } let tallies = order.map { key in LabelTally(name: spellings[key] ?? key, count: counts[key] ?? 0) } return LabelIndex(tallies: tallies.sorted(by: precedes)) } /// Frequency descending, then case-insensitively alphabetical, then by raw spelling — a **total** /// order, which is what makes the derivation reproducible. private static func precedes(_ lhs: LabelTally, _ rhs: LabelTally) -> Bool { if lhs.count != rhs.count { return lhs.count > rhs.count } return precedesAlphabetically(lhs.name, rhs.name) } /// Case-insensitive alphabetical, with the raw spelling as the last resort so `Bug` and `bug` /// never compare equal and the sort stays total. static func precedesAlphabetically(_ lhs: String, _ rhs: String) -> Bool { let folded = CardLabels.canonical(lhs).compare(CardLabels.canonical(rhs)) if folded != .orderedSame { return folded == .orderedAscending } return lhs < rhs } } // MARK: - The menu's twelve /// **"The most frequently and recently used labels"** — the owner's phrase for the card context /// menu's twelve rows, turned into arithmetic. /// /// ### The ranking, exactly /// /// 1. **Frequency, descending** — how many of the board's cards (live and trashed) carry the label /// (`LabelIndex`). /// 2. **Recency, as the tie-break** — among labels used by the same number of cards, the one this /// user applied most recently comes first (`LabelRecents`, an app-side MRU updated on every apply). /// A label the MRU has never seen sorts behind every label it has. /// 3. **Case-insensitively alphabetical**, then raw spelling — so the order is *total* and a menu /// never reshuffles between two openings that changed nothing. /// /// Then the first twelve. /// /// ### Why frequency leads and recency follows, and what that costs /// /// This is the ruling as given, implemented as given. It is worth being explicit about the /// consequence, because it is visible: on a board with more than twelve labels in heavy use, a label /// **invented today** will not appear in these rows until enough cards carry it to beat the /// twelfth-most-common one — even though it is by far the most *recently* used. The recency half only /// separates labels that are already tied on frequency, which on a board with an even spread of usage /// is often most of them, and on a lopsided board is almost none. /// /// The alternative — a blended score, or reserving a slot or two for pure recency — would surface a /// fresh label immediately at the cost of a menu whose contents move under the user's hand. Neither /// is obviously right, so the literal reading of the ruling is what shipped, and the observation is /// journaled for the owner rather than quietly designed around. **More…** is the escape hatch either /// way: every used label is one row further in, and the create field is beside it. public enum LabelRanking { /// How many rows the context menu's `labels` submenu shows before its separator — the owner's /// "up to 12". public static let menuLimit = 12 /// The top `limit` of `index`, ranked as the type comment describes. /// /// - Parameter recents: most-recent-first, `LabelRecents.labels`' own order. Entries naming labels /// the board no longer uses are simply never matched — the MRU is app-wide and a board is not /// obliged to know about labels from another one. public static func ranked(_ index: LabelIndex, recents: [String], limit: Int = menuLimit) -> [String] { guard limit > 0 else { return [] } // Position in the MRU, by canonical name — built once rather than searched per comparison, so // the sort stays O(n log n) instead of O(n² ) in the number of distinct labels. var recency: [String: Int] = [:] for (position, name) in recents.enumerated() { let key = CardLabels.canonical(name) if recency[key] == nil { recency[key] = position } } let ordered = index.tallies.sorted { lhs, rhs in if lhs.count != rhs.count { return lhs.count > rhs.count } let left = recency[CardLabels.canonical(lhs.name)] ?? Int.max let right = recency[CardLabels.canonical(rhs.name)] ?? Int.max if left != right { return left < right } return LabelIndex.precedesAlphabetically(lhs.name, rhs.name) } return ordered.prefix(limit).map(\.name) } }