import Foundation import Observation /// The labels this user applied lately, most-recent-first — the **tie-break** half of "most /// frequently and recently used" (`LabelRanking`; `FrontmatterKeys.labels`, activated 2026-08-09). /// /// `StyleRecents`' shape exactly, for its reasons: the list *rule* is a static pure function, this /// object is its persistence and its observability, and `defaults` is injectable so a test drives a /// suite of its own rather than the user's. /// /// ### App-wide, not per board — and it does not matter much /// /// A board's label *universe* is board data, derived from its own cards (`LabelIndex`). This is not /// that. It is a memory of what this **user** reached for, and it exists only to order labels that are /// already tied on frequency within one board's universe — so an entry naming a label another board /// uses simply never matches anything here and is inert. App-wide is `StyleRecents`' own posture ("no /// board owns the list"), it needs no per-board storage key, and it survives a board being closed and /// reopened, which a window-lived list would not. /// /// ### What enters it /// /// **Applications, not removals.** Taking `bug` off a card is not evidence the user is reaching for /// `bug`; recording it would push a label the user is actively getting rid of to the front of the very /// menu they are trying to leave. So `LabelCommand` records on the add half of a toggle and on a /// create, and never on a remove — the same "the None well is not a colour" carve-out `StyleRecents` /// makes one field over. @MainActor @Observable public final class LabelRecents { /// How many the list keeps. **Twice the menu's twelve**, deliberately: unlike `StyleRecents`' six, /// this list is never *displayed* — it only breaks ties inside a twelve-row menu — so its useful /// depth is "enough to order a tie group that could fill the menu", and a cap equal to the menu /// size would leave the thirteenth-most-recent label indistinguishable from one never used at all. public static let cap = 24 /// The labels, most-recent-first. Display spellings, exactly as written to frontmatter. public private(set) var labels: [String] @ObservationIgnored private let defaults: UserDefaults /// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test /// must be able to hold its own without touching the user's. public init(defaults: UserDefaults = .standard) { self.defaults = defaults // Anything but an array of strings reads as an empty list rather than as an error — // `StyleRecents`' rule and its reason: a hand-edited or truncated preference must never be why // a context menu cannot open. labels = (defaults.array(forKey: AppPreferences.labelRecentsKey) as? [String]) ?? [] } /// Records `name` as the most recently applied label and persists the new list. public func record(_ name: String) { let updated = Self.updated(labels, with: name) guard updated != labels else { return } labels = updated defaults.set(labels, forKey: AppPreferences.labelRecentsKey) } /// The list rule, as a pure function: `name` to the front, its **case-insensitive** earlier /// occurrence removed, the tail truncated to `cap`. /// /// Case-insensitive because that is what a label's identity is (`CardLabels.canonical`), and a list /// holding both `Bug` and `bug` would spend two of its slots ordering one label against itself. The /// spelling kept is the one just applied, which is the board's own by construction — every write /// path resolves a typed name against `LabelIndex.canonicalSpelling(of:)` before it lands. /// /// A name that trims to nothing returns the list unchanged: it is not a label anybody applied. public static func updated(_ list: [String], with name: String, cap: Int = cap) -> [String] { guard let name = CardLabels.normalized(name) else { return list } let key = CardLabels.canonical(name) var updated = list.filter { CardLabels.canonical($0) != key } updated.insert(name, at: 0) return Array(updated.prefix(cap)) } }