`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never interpreted, drawn only as an anonymous row in the Details section beside `assignees` and `due`. The owner's cards claim it for first-party use, so it joins the schema — read leniently (a list of names, a bare scalar coercing to one, a mapping malformed and preserved), written canonically (a quoted flow list in the order the user arranged, no auto-sort), and removed outright when the last label goes, the way an expanded lane drops `collapsed`. Identity is case-insensitive and display is case-preserving, so a card carries `bug` once however many ways the board spells it, and entries the reading cannot name ride through the write untouched at the tail. The section sits second, above Details — which is the point rather than a layout preference: Details is where keys the app does *not* own are shown, and this key just stopped being one. Rows rather than chips, because the sidebar is twenty-six characters wide. The add field autocompletes against the board's own used-labels universe, derived from every live and trashed card with no store beside the files, and says out loud when Return would mint a word the board has never used. Writes ride a `.relabel` operation of their own, because the commit composer has said "Relabel card 'X'" since long before there was a control to press — and now the undo row says it too. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
78 lines
4.2 KiB
Swift
78 lines
4.2 KiB
Swift
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))
|
|
}
|
|
}
|