Files
lanework/Kanban/LiveStore/LabelRecents.swift
T
rzen da37ed61bf A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's
`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
2026-08-09 12:06:01 -04:00

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))
}
}