asterism branch T-2192/stats-page commits 25 unpushed files 25 touched lines +5566 / -14 unit tests green UI tests 81 passed

Pre-push review: T-2192/stats-page

A third tab for a personal reading tracker — two lifetime totals, the selected period's figures beside them, a bar graph of reading activity, and a per-work breakdown of a selected day. Spec, implementation, four review passes, and a round of fixes from testing the built app on a real phone.

At a glance

  • Entirely app-layer. No entity, no schema version, no CloudKit surface, no new repository read. The one AsterismCore addition is a debug-only UI-test fixture.
  • The graph counts first captures, not last shares. Re-reading an old chapter floats it in Recent but must not move it in the graph, or every past period rewrites itself on every re-read.
  • Three chart constructions were built as specced, measured, and changed. A categorical scale over Int band indices traps at runtime; chartXSelection never fires inside a ScrollView; chartGesture fires but is a gesture, so nothing can invoke it and no selected state reaches assistive technology.
  • Two bugs survived every automated test and died on first contact with a real phone. Both are now fixed, and one of them is why a 31-month seeded fixture now exists.
  • An accessibility identifier on a SwiftUI.Tab is inert — on all three tabs, not just the new one. That was suspected before this change and is now measured and written down.

Verdict

Ready to push

All 16 spec tasks are implemented and every acceptance criterion is satisfied or explicitly reserved. make test-quick, make test-ui (81 tests) and make test-core all pass, and make build-ios is clean with no new compiler warnings, verified against a forced recompile with a known-warning control.

Two rounds of correction happened before this point and both are worth noting rather than hiding. A four-agent review found a stale-render bug, a vacuous test assertion, and a design document that still prescribed a chart construction its own decision log records as trapping at runtime. Then running the built app on a real phone found two more that no test could have: a placeholder-date filter that did not filter, and an All-time graph that could not be scrolled and would have mis-targeted taps if it could.

What remains is not code. Three requirements — the cyan accent, the graph not being a glass surface, and the selected bar reading as distinct at a glance — are settled by eye on a device, because this repo has no snapshot testing. prerequisites.md reserved them for the author from the outset.

Review findings

10 raised · 8 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Asterism is a personal reading tracker. Each time you share a chapter into it, it stores a note: what you read, which work (a series, book or site) it belongs to, and when you first captured it. It had two tabs — Recent and Works — and neither answered how much have I been reading?

This adds a third tab, Stats, showing:

  • Two lifetime totals — how many notes and how many works your library holds.
  • The same two figures for the stretch you are looking at, so “12 notes this week, across 3 works” sits beside the library's whole size.
  • A bar graph over a period you pick: This week, Last week, This month, Last month, or All time. The four bounded periods draw a bar per day; All time draws a bar per calendar month.
  • A breakdown of one day. Tap a bar and the page lists which works that day's notes came from, biggest first. Tap a row and you land on that work in the Works tab.

All time behaves differently on purpose: tapping a month bar does not select it, it opens that month as a screen of days.

Nothing new is stored. Every number is arithmetic over data the app already held in memory.

Why It Matters

Recent now stops at your newest 100 rows, so scrolling it does not even reach the end of your library. No screen reported on the library as a whole any more. Stats is that screen, and it answers something the app never could: was that day one serial, or five different things?

It is also cheap. Because it stores nothing and reads nothing from the database, it cannot slow down capture, sync or startup — it only works while you are looking at it.

Key Concepts

  • Snapshot. The app periodically reads the whole library and publishes a read-only copy in memory. Stats derives entirely from two of those copies and never touches the database.
  • First capture versus re-share. A note has a date it was first captured and a date it was last shared. Re-reading updates the second. The graph counts the first, so revisiting something from March does not drag it into today's bar — otherwise March's total would change every time you went back. A caption says so, rather than leaving you to guess.
  • Half-open interval. A bar covers “from this instant, up to but not including the next bar's”. That is how a note captured at exactly midnight lands in exactly one day — never zero, never two.
  • Calendar arithmetic. “One day later” is asked of the calendar, never computed as “plus 24 hours”, so a daylight-saving day of 23 or 25 hours is still one whole bar.
  • Placeholder dates. A note iCloud has not finished delivering carries a stand-in date of 1 January 1970. Those count in the totals but are left off the graph — “when was this read” has no honest answer yet, and one of them would stretch All time to about 680 mostly-empty bars.

Architecture

Two new files carry the feature. StatsDerivation.swift holds the value types, the StatsNavigation state machine, the derivation keys, the pure derivation and the axis arithmetic. StatsView.swift holds the root screen, the chart, the band controls, the breakdown and the month screen. ContentView.swift gains a third Tab and a cross-tab route; AppLibraryModel.swift gains snapshotGeneration and a staged refreshAll().

Sourcing is load-bearing. Note counts, dates and work attribution come from recentPresentation.allRows; the works total comes from worksSnapshot.works. WorksSnapshot groups by work, so a note whose duplicate rows point at two works appears under both, and each fragment computes its own earliest date over its own subset. allRows has already collapsed that globally, once. A real-store test asserts the wrong source gives a different answer (7 versus 6), so the obvious simplification fails a test rather than violating a comment.

Patterns

  • Presentation logic as a value type. StatsNavigation owns every transition and derives scope rather than storing it, so an opened month and a period selection cannot disagree. No app-layer test instantiates a SwiftUI view, so a rule left in a body is a rule nothing can check.
  • Two derivations, two keys. StatsInputKey projects into a graphIdentity and a breakdownIdentity. Bars do not depend on the selection, so changing the selected day cannot re-bucket the library.
  • An explicit gate, not just a key. .task(id:) alone delivers neither cost requirement under either possible tab-lifecycle behaviour, and that lifecycle is undocumented — so presentation is passed in explicitly and the last computed key is stored.
  • Key on a counter, not the data. snapshotGeneration is an Int bumped once per completed refresh; keying on the snapshots would deep-compare the whole library to decide whether to walk the whole library. Zero doubles as “no refresh has completed”, which is what separates an unread library from an empty one.
  • Atomic publication. refreshAll() stages both reads into locals and publishes all four values with no await between them, so a throw in the second read leaves the previous cycle standing whole instead of pairing a new presentation with an old works snapshot.

Trade-offs

  • Counted on first capture, so re-reads never appear; a graph keyed on last-shared would rewrite its own history.
  • All time buckets by month and drills down rather than bucketing adaptively, which would invent a constant and change the axis unit under the reader.
  • All time fits every month on screen rather than scrolling. The scrolling path was built, measured, and removed — see the expert tab.
  • The selection outline is bound to Differentiate Without Color. Everyday selection reads from fill strength alone, which is one colour rendered two ways rather than an independent channel; the requirement was rewritten to say that plainly instead of being left quietly contradicted.
  • One graph cache on the root screen, so it renders no graph while a month is pushed. Visible only in the tail of a pop animation; the second cache was declined and the trade recorded.

Deep dive

Bucketing. buckets(in:unit:calendar:captureDates:) walks the span by stepping to each interval's own end rather than adding a component, which is what makes a 23- or 25-hour day exactly one step. Counting filters to the span before decomposing — safe not by luck but because every span here is unit-aligned (week and month intervals open on a day boundary, All time's bounds are month starts), so an out-of-span capture cannot decompose onto an in-span bucket start. The argument lives in the comment, so the next maintainer adding a non-aligned scope has to break it deliberately.

Ordering. precedes is a genuine total order: category rank, count descending, localizedStandardCompare on title, then uuidString. Title alone is not a total order precisely in the unresolved-duplicate case. The invariant test asserts order against shuffled input rather than by deriving twice, because Swift's Dictionary iteration order is randomised per process, not per call — two derivations in one test see the same order, so a dictionary-order dependency would pass.

Classification is by entry.workID and workDisplayTitle only, never by row.attention: .workMissing ranks below three site-level causes, so a note whose site is also missing would fall out of the unresolved category and silently break the breakdown's sum.

Isolation. Every type in StatsDerivation.swift is nonisolated. The target builds with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so members would otherwise be implicitly main-actor while the Sendable values they read are not.

What the phone found that the tests could not

The placeholder filter did not filter. The schema defaults firstCapturedAt to the epoch exactly, and the derivation excluded dates <= epoch. Real rows sat seconds to hours after it, passed the test, and dragged All time back to January 1970 followed by ~680 empty months — the precise outcome the decision existed to prevent. The floor is now 1971-01-01: the app did not exist in 1970, so a whole-year window absorbs every placeholder shape without a fiddly boundary, and the first plausible real capture is fifty-five years later.

The All-time graph could not be scrolled, and would have mis-targeted taps if it could. bandControls is installed via .chartOverlay, which is not inside the scrollable content, while positionRange(forX:) returns positions in the scroll content space. Only bands 0–23 landed over the visible plot; they covered it entirely and intercepted the horizontal drag. A seeded 31-month fixture reproduced it exactly: tapping band 30 opened January 2025 instead of the August 2026 it named, and a drag moved nothing. The resolution is the fallback the design wrote down for this case — All time fits every band on screen rather than scrolling — because the mis-targeting is compensable but the swallowed pan is not: no supported API places a control inside a chart's scrolling content. Removing the scroll also removed the only justification for the All-time accessibility representation, so the control a test taps is now the control a reader taps.

This is the case for device testing stated as plainly as it can be: both bugs were reachable only with a library shaped unlike any fixture, and the second was recorded as an unverified risk before the phone confirmed it.

Edge cases

  • Time-zone change with a day selected leaves the selection set but matching no bar, so the breakdown withholds itself. That is the honest rendering, and the next tap sets rather than toggles, so one gesture restores it.
  • First-capture stability is qualified: backup import reassigns the date, the published value is the earliest across a note identity's rows so it can move earlier as CloudKit rows arrive, and duplicate reconciliation deletes losing identities silently — a past bar can lose a note with the page open.
  • Placeholder notes are invisible; nothing on the page says a note was left off the graph.
  • The breakdown reports current ownership, so a merge or rename changes what an old day reports.
  • The M4 scale fixture dates all 5,000 entries at epoch + n seconds, so under the new floor it now holds no usable capture dates at all. Nothing measures Stats over it, but it is no longer usable for Stats work.

Important changes — detailed

AppLibraryModel: publish one refresh cycle atomically

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. A throw in the second read used to publish a new presentation beside the previous works snapshot: a pair that never existed in the store, with nothing to say the cycle had moved. Every consumer could observe it, not just Stats.

What to look at. AppLibraryModel.swift:354-374

Takeaway. If two awaited reads feed one published state, stage both into locals and publish in a single synchronous run. Otherwise every reader can observe a combination that never existed at any instant.
Rationale. Decision log Q37. The two reads deliberately remain two store instants (Q13); only the publication becomes atomic, and the requirement claims one completed cycle rather than one store instant.

AppLibraryModel: add snapshotGeneration as key and unread sentinel

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. Lets a derived surface key on an Int instead of deep-comparing the library, and makes 'no refresh has completed' distinguishable from 'library is empty' — otherwise identical values, since both snapshots initialise empty.

What to look at. AppLibraryModel.swift:30 and :371

Takeaway. A monotonic generation counter is cheaper and more expressive than Equatable snapshots — but it obliges every publisher to bump it, which is a real new invariant.
Rationale. Q27 for the cost argument, Q29 for the unread-versus-empty distinction.

Chart: replace both built-in Swift Charts interactions with per-band Buttons

Asterism/Asterism/Views/StatsView.swift

Why it matters. chartXSelection never fires inside a ScrollView, and the page must be one because the breakdown has to scroll. chartGesture fires but is a gesture, so it exposes no selected state and nothing can invoke it — failing two accessibility requirements at once.

What to look at. StatsView.swift bandControls / barElement

Takeaway. If a UI test or VoiceOver must *activate* a chart element, it needs a real control. A gesture and an accessibility representation are each half an answer; XCUI activates by coordinate, so a representation is readable by a test but not drivable by one.
Rationale. Q46, with the measurements recorded in docs/agent-notes/testing.md.

Chart: the categorical x domain must be strings, not Int indices

Asterism/Asterism/Views/StatsView.swift

Why it matters. The literal construction the design prescribed traps at runtime rather than degrading. Anyone following the unamended design document would have reintroduced the crash.

What to look at. StatsView.swift baseChart, band(_:)

Takeaway. type: .category does not coerce a plottable — the plottable's own primitive decides the scale, and a category scale requires a String primitive.
Rationale. Q45. Measured: seven bands tile a 382 pt plot with zero gap between adjacent positionRange values.

Derivation: widen the placeholder-date floor to 1971

Asterism/Asterism/ViewModels/StatsDerivation.swift

Why it matters. The old rule excluded dates at or before the epoch, but real half-arrived rows sit seconds to hours after it. They passed the filter and dragged All time back to January 1970 followed by ~680 empty months — exactly what the rule existed to prevent.

What to look at. StatsDerivation.swift:238-251, :355

Takeaway. A sentinel filter written against the declared default value will miss data shaped slightly differently. Pick a window wide enough to absorb every plausible variant, justified by domain impossibility rather than by a tuned constant.
Rationale. Decision 6, amended in place after the author found it on a real device. The app did not exist in 1970, so a whole-year floor cannot clip a real capture.

Chart: remove the All-time scrolling path

Asterism/Asterism/Views/StatsView.swift

Why it matters. The overlay holding the touch targets is not inside the scrollable content, so it both swallowed the pan and would have mis-targeted taps. A seeded 31-month fixture reproduced it: band 30 opened January 2025 rather than the August 2026 it named.

What to look at. StatsView.swift — ScrollableBands, visibleBandLimit and the All-time accessibilityRepresentation deleted

Takeaway. When a control layer and the content it labels live in different coordinate spaces, no amount of position compensation fixes a gesture the control layer is eating. Prefer the documented fallback over a partially-corrected mechanism.
Rationale. Q49, recorded as an unverified risk during review and confirmed on device; resolved via Decision 3's own stated fallback, which the design says costs tap comfort only.

Header: show the selected period's figures beside the lifetime totals

Asterism/Asterism/ViewModels/StatsDerivation.swift

Why it matters. Requested after device testing: the lifetime pair alone did not answer 'how much this week'. Derived in StatsDerivation and carried on StatsGraph rather than computed in the view, so it stays testable.

What to look at. StatsDerivation.swift:94, :118, :294-302

Takeaway. Deriving a new display figure in the pure layer rather than the view costs one struct field and buys tests for free — the same discipline that made every other rule here checkable.
Rationale. Q55. The works figure counts distinct non-nil workIDs, so a note with no work counts toward notes and toward no work; stated in the requirement rather than left implicit.

Key decisions

The graph counts first captures, not last shares.

The library holds no read log — an Entry carries three dates, all overwritten in place — so lastSharedAt would move a chapter out of its original bar on every re-read and rewrite past periods. Re-reads are therefore invisible to the graph, and a caption says so. (Decision 1)

Notes come from the presentation; works come from the works snapshot.

WorksSnapshot must never be flattened for note statistics: it groups by work, so a note whose duplicate rows point at two works appears under both, and each fragment carries its own earliest-capture date. The presentation has already collapsed that globally. A real-store test exists to catch a future maintainer undoing it. (Decision 5)

The selection outline is bound to Differentiate Without Color.

The author did not want a dark outline around the selected bar in everyday use. Selection now reads from fill strength, with the outline appearing only under the system setting. This weakens the original requirement — a full-strength versus dimmed cyan is one colour rendered two ways, not an independent channel — so the requirement was rewritten to state the shipping contract rather than being left quietly contradicted. (Q54)

All time fits its bands rather than scrolling.

Confirmed broken on device and reproduced by a seeded 31-month fixture. Tracking the scroll offset would have fixed tap targeting while leaving the graph unscrollable, because the overlay covers the plot and no supported API puts a control inside a chart's scrolling content. The design's own fallback costs no behaviour — only bar width, on a library old enough to need it. (Q49, Q56)

An accessibility identifier on a SwiftUI.Tab is inert.

Measured on iOS 26: the tab-bar button publishes its label and symbol and no identifier, and moving the identifier onto a custom label: view does not change it. This was suspected of the two pre-existing tabs and is now confirmed for all three. Tests reach tabs by label. (Q48)

The root screen holds one graph cache.

So it renders no graph while a month is pushed, and until the pop re-derives. The push covers the root, so the blank is visible only in the tail of a pop animation on a library large enough for the derivation to outlast it. A second cache was declined on the repo's stated preference for the simpler shape. (Q50)

Review findings

SeverityAreaFindingResolution
majorStatsDerivation.swift — placeholder datesThe rule excluding un-hydrated notes tested for dates at or before the 1970 epoch, but real rows sit seconds to hours after it. They passed the filter and dragged All time back to January 1970 with ~680 empty months — the exact outcome the rule existed to prevent. Found by the author on a real device; no fixture had this shape.Floor widened to 1971-01-01, justified by the app not existing in 1970 rather than by a tuned constant. Decision 6 amended in place; tests cover the epoch, epoch+5s, 1970-06-01 and the 1971 boundary.
majorStatsView.swift — All-time scrollingThe band controls live in .chartOverlay, outside the scrollable content, while positionRange(forX:) returns content-space positions. Only bands 0–23 landed over the visible plot, they covered it entirely and swallowed the horizontal pan, and had the pan worked a tap would have opened the wrong month. Recorded as an unverified risk during review, then confirmed on device.Reproduced with a new seeded 31-month fixture (band 30 opened January 2025 instead of August 2026), then resolved by taking the design's documented fallback: All time fits every band on screen. The scrolling path, the band limit and the now-unjustified accessibility representation were removed.
majorStatsView.swift — breakdown renderingThe breakdown gated on 'a day is selected' but never checked the cached breakdown was about that day, so between the tap and the re-derivation the previous day's heading, total and rows rendered under the newly highlighted bar. Not one frame on a large library.Gated on breakdown.day == selectedDay, symmetric with the graph's existing scope guard. Verified empirically that the UI tests still see the breakdown after a tap, since an over-strict comparison would have been worse than the bug.
majorspecs/stats-page/design.mdThe design still prescribed .chartXScale(domain: bars.map(\.index), type: .category) — which the decision log records as trapping at runtime — plus two other constructions the implementation had overturned. A reader following it would reintroduce the crash.All three annotated in place in the repo's existing 'Superseded in part' style, and the Chart Assumptions table given a measured outcomes section.
majorAsterismUITests/StatsUITests.swiftAn assertion checked for the absence of the identifier 'stats-breakdown', which no view publishes. It passed whether or not a breakdown was on screen — a test that could never fail.Pointed at the real identifier, stats-breakdown-day, which the following line then asserts does appear after the tap.
minorStatsDerivation.swift — bucketing costbuckets() ran a full Calendar.dateInterval decomposition for every dated note in the library regardless of scope: a This-week graph over a 3,000-note library did 3,000 calendar computations to fill 7 buckets and discarded 2,993.Prefiltered on the span bounds before decomposing. Behaviour-preserving because every span here is unit-aligned, so no out-of-span capture can map onto an in-span bucket start.
minorStatsView.swift — untestable arithmeticThe axis arithmetic and the derivation keys sat as private statics inside the view — the exact shape the spec's own Q41 exists to prevent — leaving two requirements with no test. A named read-discipline test the design specified was also never written.Moved beside StatsDerivation and tested: value-axis and date-axis arithmetic, the key identity split, and a read-discipline test asserting a mock provider's call log is unchanged across every reachable screen state.
minorStatsView.swift — duplication and reuseThe bar element was built twice in two view trees with no runtime way to detect drift; the visible total noun bypassed Pluralisation two lines from where the spoken label used it; the all-time check was written three times across two files.One shared bar-element builder, a new Pluralisation.noun the existing count() is expressed in terms of, and a single StatsScope.isAllTime.
minorContentView.swift — showWork duplicationshowWork repeats showWorksRoot's four-line clearing sequence verbatim, differing only in the omitted reset-token bump.Skipped deliberately. Deduplicating needs a boolean parameter, and trading two self-documenting methods for one flag-driven one reads worse; the behavioural divergence is deliberate and both doc comments are load-bearing.
minorStatsView.swift — root graph cacheThe root screen holds one graph cache, so it renders no graph while a month is pushed and until the pop re-derives.Skipped. A second cache adds view state that must stay in step with the derivation key, for a blank visible only in the tail of a pop animation. Recorded as Q50 so the trade is visible rather than accidental.

Per-file diffs

Click to expand.

Asterism/Asterism/ViewModels/StatsDerivation.swift Added +452
diff --git a/Asterism/Asterism/ViewModels/StatsDerivation.swift b/Asterism/Asterism/ViewModels/StatsDerivation.swiftnew file mode 100644index 0000000..c5c975e--- /dev/null+++ b/Asterism/Asterism/ViewModels/StatsDerivation.swift@@ -0,0 +1,506 @@+import AsterismCore+import Foundation++// The Stats page's derivation (`specs/stats-page/`), and the state machine that+// drives it. Presentation logic over two published DTOs, deliberately outside+// any `body` — the same thing `RecentRowCap.swift` and `SearchFilters.swift`+// beside it already are, and for the reason `recent-window-cap` Q26 gives:+// no app-layer test instantiates a SwiftUI view, so a rule left in `body` is a+// rule nothing can check.+//+// `nonisolated` on every type here, following `RecentSyncPresentation`'s note:+// the app target builds with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, so+// each member would otherwise be implicitly `@MainActor` while the `Sendable`+// values it reads are not. Nothing in this file can legitimately need the main+// actor — it is arithmetic over immutable snapshots — and stating that once+// keeps the next member added here from re-introducing the warning.++// MARK: - Value types++/// The five periods, and the whole of the selection (Req 3.1).+nonisolated enum StatsPeriod: String, CaseIterable, Identifiable, Sendable {+    case thisWeek, lastWeek, thisMonth, lastMonth, allTime++    var id: String { rawValue }+}++/// What a graph covers. `.month` is reachable only from `.allTime`, and the+/// period control continues to read All time while one is open (Req 5.4).+nonisolated enum StatsScope: Equatable, Sendable {+    case period(StatsPeriod)+    case month(start: Date)++    /// The one scope that buckets by month, navigates rather than selects, and+    /// can outgrow its viewport. Named once here because three places ask it,+    /// and three copies of a `case` pattern are three chances to disagree.+    var isAllTime: Bool { self == .period(.allTime) }+}++nonisolated enum StatsBarUnit: Equatable, Sendable { case day, month }++/// `index` is the x value; the scale is declared categorical over these+/// indices. `start` is the bucket's inclusive lower bound, its upper bound the+/// next bucket's `start` (Req 3.4).+nonisolated struct StatsBar: Identifiable, Equatable, Sendable {+    let index: Int+    let start: Date+    let count: Int++    var id: Int { index }+}++/// `Hashable`, not merely `Equatable`: it is the `Identifiable` id below, and+/// `Identifiable` constrains `ID: Hashable`.+nonisolated enum StatsCategory: Hashable, Sendable {+    case work(id: UUID, title: String)+    /// Holds a work reference that resolves to no title (Req 6.4).+    case unresolvedWork+    /// Holds no work reference at all (Req 6.3).+    case unattached++    /// Q20's fixed positions: the two unresolved categories are held last and+    /// second-to-last whatever their counts, so the ordering rules are a total+    /// order and one day always produces one list (Req 6.6).+    fileprivate var orderingRank: Int {+        switch self {+        case .work: 0+        case .unresolvedWork: 1+        case .unattached: 2+        }+    }+}++nonisolated struct StatsBreakdownRow: Identifiable, Equatable, Sendable {+    let category: StatsCategory+    let count: Int++    var id: StatsCategory { category }+}++nonisolated struct StatsBreakdown: Equatable, Sendable {+    /// The start of the day this describes, normalised through the calendar the+    /// derivation was given, so two callers naming the same day cannot produce+    /// two breakdowns.+    let day: Date+    let total: Int+    /// Empty when `total == 0`; Req 6.9's message renders from that.+    let rows: [StatsBreakdownRow]+}++/// What the *selected span* holds, beside the lifetime totals (Req 2.10, Q55).+///+/// The pair mirrors the lifetime pair and is derived over exactly the notes the+/// bars count — the ones carrying a usable capture date inside the span.+nonisolated struct StatsPeriodFigures: Equatable, Sendable {+    /// The notes first captured inside the span.+    let notes: Int+    /// The **distinct non-nil `workID`s** among those notes. A note holding no+    /// work reference counts in `notes` and towards no work, so the two figures+    /// answer two different questions and neither is a subset count of the+    /// other.+    let works: Int+}++/// The graph and the totals. `bars` is empty only when the scope resolves no+/// span at all (Reqs 3.6, 4.7); a span with no reading is a full set of+/// zero-count bars (Req 4.6), and the two render differently.+nonisolated struct StatsGraph: Equatable, Sendable {+    let scope: StatsScope+    let unit: StatsBarUnit+    let bars: [StatsBar]+    let totalNotes: Int+    let totalWorks: Int+    /// Excluded from every bar for want of a usable capture date (Decision 6).+    /// No on-screen consumer; it exists for the Req 4.10 invariant test.+    let undatedNoteCount: Int+    /// Req 2.10. Nil for All time alone, where the pair would merely restate the+    /// lifetime totals less the undated notes (Q55).+    let periodFigures: StatsPeriodFigures?+}++// MARK: - Navigation++/// The whole state machine: which period is selected, which month is open, and+/// which day is selected (Reqs 5.4, 5.5, 6.7, 6.8).+///+/// A value type rather than rules in `StatsView.body`, per Q41 and the+/// `RecentDisplayPlan` precedent: no app-layer test instantiates a SwiftUI view,+/// so a transition left in `body` is a transition nothing can check.+///+/// `scope` is **derived** from the stored fields and never stored, so an opened+/// month and a period selection cannot disagree.+nonisolated struct StatsNavigation: Equatable, Sendable {+    /// Req 3.2: the page opens on This week, and `@State` re-initialisation is+    /// what keeps that true across launches.+    private(set) var period: StatsPeriod = .thisWeek+    /// The start of the calendar month opened from All time (Decision 3).+    private(set) var openedMonth: Date?+    /// Req 6.7: no day is selected until the reader selects one.+    private(set) var selectedDay: Date?++    var scope: StatsScope { openedMonth.map(StatsScope.month) ?? .period(period) }++    /// Req 5.5. A selection is a question about one bar, and carrying it across+    /// a period change would point it at a different bar (Q8).+    mutating func select(period: StatsPeriod) {+        self.period = period+        openedMonth = nil+        selectedDay = nil+    }++    /// Reqs 5.2, 5.3: an All-time bar navigates rather than selecting.+    mutating func open(month: Date) {+        openedMonth = month+    }++    /// Req 5.4: the route out of an opened month returns to the period that+    /// opened it, which is why `period` is untouched here.+    mutating func closeMonth() {+        openedMonth = nil+        selectedDay = nil+    }++    /// Req 6.8: selecting the already-selected bar clears the breakdown.+    mutating func toggle(day: Date) {+        selectedDay = selectedDay == day ? nil : day+    }++    /// Req 6.8's other clearing route: the tap on the page around the chart+    /// (Q21). The categorical scale tiles the plot, so there is no "outside a+    /// bar" within it.+    mutating func clearSelection() {+        selectedDay = nil+    }+}++// MARK: - Derivation keys++/// The temporal half of the derivation key (Q39).+///+/// The day stamp alone is insufficient for Req 3.7: moving between zones that+/// share a UTC offset but differ in week rules changes what "This week" means+/// without changing `startOfDay`.+nonisolated struct StatsTemporalKey: Equatable {+    let timeZoneIdentifier: String+    let firstWeekday: Int+    let startOfDay: Date++    static func current() -> StatsTemporalKey {+        let calendar = Calendar.current+        return StatsTemporalKey(+            timeZoneIdentifier: calendar.timeZone.identifier,+            firstWeekday: calendar.firstWeekday,+            startOfDay: calendar.startOfDay(for: Date()))+    }+}++/// What a derived value depends on (Q27, Q35). Keyed on `snapshotGeneration`+/// rather than on the snapshots themselves: `.task(id:)` over the snapshots+/// would run a deep `Equatable` comparison of the whole library on every update+/// — paying a full pass to decide whether to do a full pass.+nonisolated struct StatsInputKey: Equatable {+    let generation: Int+    let scope: StatsScope+    let selectedDay: Date?+    let temporal: StatsTemporalKey++    /// The bars do not depend on the selection.+    var graphIdentity: GraphIdentity {+        GraphIdentity(generation: generation, scope: scope, temporal: temporal)+    }++    /// The breakdown does not depend on the scope.+    var breakdownIdentity: BreakdownIdentity {+        BreakdownIdentity(generation: generation, day: selectedDay, temporal: temporal)+    }++    struct GraphIdentity: Equatable {+        let generation: Int+        let scope: StatsScope+        let temporal: StatsTemporalKey+    }++    struct BreakdownIdentity: Equatable {+        let generation: Int+        let day: Date?+        let temporal: StatsTemporalKey+    }+}++// MARK: - The derivation++/// Pure and total over its parameters: no `Date()`, no `Calendar.current`, no+/// repository (Req 1.5). Every bucket boundary is calendar arithmetic through+/// `dateInterval(of:for:)`, never fixed-length day maths, so a daylight-saving+/// day of 23 or 25 hours is one whole day (Req 3.3).+nonisolated enum StatsDerivation {++    /// 1971-01-01T00:00:00Z — 365 days after the schema's `firstCapturedAt`+    /// default, 1970 not being a leap year. A note captured **before** this has+    /// no usable capture date (Req 3.8, Decision 6).+    ///+    /// A whole-year floor rather than the epoch instant itself, because the+    /// epoch instant alone did not do the job Decision 6 exists for. The schema+    /// default *is* the epoch, but observed placeholder rows in a real library+    /// sit seconds to hours after it — enough to pass a `<= epoch` test, and+    /// enough to drag All time back to January 1970 and draw ~680 empty months,+    /// which is the exact outcome the decision was written to prevent. A year is+    /// wide enough to absorb every placeholder shape without a fiddly boundary+    /// and cannot clip a real capture: this app did not exist in 1970, and its+    /// first plausible capture is some 55 years later.+    static let usableCaptureFloor = Date(timeIntervalSince1970: 365 * 24 * 60 * 60)++    /// The bars for `scope`, and the two lifetime totals beside them.+    ///+    /// Takes the `RecentPresentation` whole and reads `allRows` **inside**.+    /// `allRows` is a computed `groups.flatMap(\.rows)`, so taking it in+    /// `ContentView.body` would flatten the whole library on every republish,+    /// for every tab, whether or not Stats is on screen — the Req 8.1 cost+    /// re-entering through the parameter list (`RecentRowCap.swift:31` records+    /// the same hazard).+    ///+    /// `works` is the array rather than a count for the reason Decision 5+    /// gives: the works total comes from the *other* snapshot, and a bare `Int`+    /// would let a future maintainer feed it a number derived from `allRows`.+    static func graph(+        presentation: RecentPresentation,+        works: [WorkSnapshot],+        scope: StatsScope,+        calendar: Calendar,+        now: Date+    ) -> StatsGraph {+        let rows = presentation.allRows+        // Each row is already one note identity, dated by the earliest capture+        // across its stored rows, so rows are counted directly and identity is+        // never re-derived (Req 4.3).+        let captureDates = rows.compactMap { usableCaptureDate($0) }+        let unit = barUnit(for: scope)+        let resolvedSpan = span(+            for: scope, calendar: calendar, now: now, captureDates: captureDates)+        let bars =+            resolvedSpan.map {+                buckets(in: $0, unit: unit, calendar: calendar, captureDates: captureDates)+            } ?? []++        return StatsGraph(+            scope: scope,+            unit: unit,+            bars: bars,+            totalNotes: rows.count,+            totalWorks: works.count,+            undatedNoteCount: rows.count - captureDates.count,+            // Req 2.10: every scope but All time, where the pair would restate+            // the lifetime totals less the undated notes (Q55).+            periodFigures: scope.isAllTime+                ? nil : resolvedSpan.map { periodFigures(in: $0, rows: rows) })+    }++    /// Req 2.10's pair, over exactly the notes the bars count.+    ///+    /// Derived here rather than in the view for the reason Q41 gives about the+    /// navigation: a rule left in `body` is a rule nothing can check.+    private static func periodFigures(+        in span: DateInterval, rows: [RecentPresentationRow]+    ) -> StatsPeriodFigures {+        var notes = 0+        var workIDs: Set<UUID> = []+        for row in rows {+            guard let captured = usableCaptureDate(row),+                captured >= span.start, captured < span.end+            else { continue }+            notes += 1+            // A note with no work reference counts here and towards no work.+            if let workID = row.entry.workID { workIDs.insert(workID) }+        }+        return StatsPeriodFigures(notes: notes, works: workIDs.count)+    }++    /// The per-work composition of one day (Req 6.1).+    ///+    /// Separate from `graph` rather than folded into it, because+    /// `chartXSelection` updates its binding continuously during a drag and a+    /// single derivation keyed on the selected day would re-bucket every note in+    /// the library on every frame (Q35).+    static func breakdown(+        presentation: RecentPresentation,+        day: Date,+        calendar: Calendar+    ) -> StatsBreakdown {+        guard let interval = calendar.dateInterval(of: .day, for: day) else {+            return StatsBreakdown(day: day, total: 0, rows: [])+        }++        var counts: [StatsCategory: Int] = [:]+        var total = 0+        for row in presentation.allRows {+            guard let captured = usableCaptureDate(row),+                captured >= interval.start, captured < interval.end+            else { continue }+            total += 1+            counts[category(of: row), default: 0] += 1+        }++        let rows = counts+            .map { StatsBreakdownRow(category: $0.key, count: $0.value) }+            .sorted(by: precedes)++        return StatsBreakdown(day: interval.start, total: total, rows: rows)+    }++    // MARK: - Classification++    /// Nil where the row carries no usable capture date (Req 3.8).+    private static func usableCaptureDate(_ row: RecentPresentationRow) -> Date? {+        let captured = row.entry.firstCapturedAt+        return captured < usableCaptureFloor ? nil : captured+    }++    /// Never from `row.attention`, which reports `.workMissing` only when no+    /// site-level cause outranks it — so a note whose site is also missing would+    /// fall out of Req 6.4's category and silently break Req 6.5's sum (Q22).+    private static func category(of row: RecentPresentationRow) -> StatsCategory {+        guard let workID = row.entry.workID else { return .unattached }+        guard let title = row.workDisplayTitle else { return .unresolvedWork }+        return .work(id: workID, title: title)+    }++    /// Count descending, then display title, then work UUID, with the two+    /// unresolved categories held last and second-to-last (Q20, Reqs 6.1, 6.6).+    private static func precedes(_ left: StatsBreakdownRow, _ right: StatsBreakdownRow) -> Bool {+        let leftRank = left.category.orderingRank+        let rightRank = right.category.orderingRank+        if leftRank != rightRank { return leftRank < rightRank }+        if left.count != right.count { return left.count > right.count }++        guard case .work(let leftID, let leftTitle) = left.category,+            case .work(let rightID, let rightTitle) = right.category+        else {+            // The two unresolved categories are one row each, so equal ranks+            // outside `.work` mean the same row.+            return false+        }+        // Q9: display title alone is not a total order — two works can carry the+        // same title, which is exactly the unresolved-duplicate case.+        let titleOrder = leftTitle.localizedStandardCompare(rightTitle)+        if titleOrder != .orderedSame { return titleOrder == .orderedAscending }+        return leftID.uuidString < rightID.uuidString+    }++    // MARK: - Spans and buckets++    private static func barUnit(for scope: StatsScope) -> StatsBarUnit {+        scope.isAllTime ? .month : .day+    }++    /// The half-open interval a scope covers, or nil where it resolves none —+    /// All time over a library holding no usable capture date (Req 3.6).+    private static func span(+        for scope: StatsScope, calendar: Calendar, now: Date, captureDates: [Date]+    ) -> DateInterval? {+        switch scope {+        case .month(let start):+            return calendar.dateInterval(of: .month, for: start)+        case .period(.thisWeek):+            return calendar.dateInterval(of: .weekOfYear, for: now)+        case .period(.lastWeek):+            return calendar.date(byAdding: .weekOfYear, value: -1, to: now)+                .flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) }+        case .period(.thisMonth):+            return calendar.dateInterval(of: .month, for: now)+        case .period(.lastMonth):+            // Month arithmetic, not a fixed length: subtracting 30 days from the+            // 31st lands in the same month again.+            return calendar.date(byAdding: .month, value: -1, to: now)+                .flatMap { calendar.dateInterval(of: .month, for: $0) }+        case .period(.allTime):+            // Whole calendar months, earliest usable through latest (Req 3.5,+            // Q12): a span ending *at* the latest capture would exclude the+            // newest note, because every bound is half-open.+            guard let earliest = captureDates.min(), let latest = captureDates.max(),+                let first = calendar.dateInterval(of: .month, for: earliest),+                let last = calendar.dateInterval(of: .month, for: latest)+            else { return nil }+            return DateInterval(start: first.start, end: last.end)+        }+    }++    /// One bar per unit inside the span, zero-count ones included (Req 4.6).+    /// Load-bearing twice: it is the requirement, and it is what keeps the+    /// chart's categorical x domain hole-free (Q33).+    private static func buckets(+        in span: DateInterval, unit: StatsBarUnit, calendar: Calendar, captureDates: [Date]+    ) -> [StatsBar] {+        let component: Calendar.Component = unit == .day ? .day : .month++        var starts: [Date] = []+        var cursor = span.start+        // Stepping by the unit's own interval rather than by adding a component+        // is what makes a 23- or 25-hour day one step (Req 3.3): the interval's+        // `end` *is* the next bucket's opening instant.+        while cursor < span.end, let interval = calendar.dateInterval(of: component, for: cursor) {+            starts.append(interval.start)+            guard interval.end > cursor else { break }+            cursor = interval.end+        }++        // Each capture lands in the bucket its own interval starts at, so a+        // capture on a boundary instant belongs to the later bucket by+        // construction (Req 3.4).+        //+        // The span filter comes first because `captureDates` is every dated note+        // in the library whatever the scope, and a calendar decomposition per+        // row is the expensive half of this function. It changes no count: every+        // span here is unit-aligned — week and month intervals both open on a day+        // boundary, and All time's bounds are month starts — so a capture outside+        // the span cannot decompose onto a bucket start inside it. Those rows are+        // exactly the ones the lookup below would discard anyway.+        var counts: [Date: Int] = [:]+        for captured in captureDates where captured >= span.start && captured < span.end {+            guard let interval = calendar.dateInterval(of: component, for: captured) else { continue }+            counts[interval.start, default: 0] += 1+        }++        return starts.enumerated().map { index, start in+            StatsBar(index: index, start: start, count: counts[start] ?? 0)+        }+    }++    // MARK: - Axis arithmetic++    /// The value axis's top. Never zero: an all-empty span still draws an axis,+    /// and a `0...0` domain has no extent to draw it over.+    static func upperBound(_ graph: StatsGraph) -> Int {+        max(graph.bars.map(\.count).max() ?? 0, 1)+    }++    /// Req 4.8's whole-number ticks, from a zero baseline. Charts draws+    /// half-unit ticks by default for small maxima, which would label a count of+    /// notes as "1.5".+    static func valueAxisTicks(_ graph: StatsGraph) -> [Int] {+        let upper = upperBound(graph)+        let step = max(1, Int((Double(upper) / 4).rounded(.up)))+        return Array(stride(from: 0, through: upper, by: step))+    }++    /// Req 4.9: the first and last bar of the span are always labelled, plus+    /// enough intermediate ones to stay readable without crowding the axis.+    ///+    /// Returns the bars themselves rather than their indices, so the caller+    /// reads each label off the bar it belongs to instead of parsing the band+    /// name back into an index (Q45).+    static func dateAxisTicks(_ graph: StatsGraph) -> [StatsBar] {+        guard let first = graph.bars.first, let last = graph.bars.last,+            first.index != last.index+        else { return graph.bars }++        let step = max(1, (last.index - first.index) / 3)+        var ticks = stride(from: first.index, to: last.index, by: step)+            .compactMap { index in graph.bars.first { $0.index == index } }+        // The stride can land one short of the end; the last bar is required.+        if let penultimate = ticks.last, last.index - penultimate.index < step / 2 {+            ticks.removeLast()+        }+        ticks.append(last)+        return ticks+    }+}
Asterism/Asterism/Views/StatsView.swift Added +746
diff --git a/Asterism/Asterism/Views/StatsView.swift b/Asterism/Asterism/Views/StatsView.swiftnew file mode 100644index 0000000..5d61fa6--- /dev/null+++ b/Asterism/Asterism/Views/StatsView.swift@@ -0,0 +1,769 @@+import AsterismCore+import Charts+import ConstellationKit+import SwiftUI++/// The Stats tab (`specs/stats-page/`): two lifetime totals, a bar graph of+/// reading activity over a selected period, and a per-work breakdown of a+/// selected day.+///+/// Everything on the screen is a pure function of two snapshots the app already+/// publishes (Decision 5). The screen issues no library read of its own — not on+/// construction, not on tab selection, not on a period change, and not on a day+/// change (Req 1.5).+///+/// **The derivation is gated, not merely keyed** (Q36). `.task(id:)` alone+/// satisfies neither Req 8.1 nor Req 8.2: if a deselected tab's task is+/// cancelled, re-entry re-derives an unchanged key; if it is not, a+/// `snapshotGeneration` bump derives while the page is off screen. So the view+/// carries an explicit `isPresented` and a stored `lastDerivedKey`, and nothing+/// derives on a `body` evaluation.+struct StatsView: View {+    /// Taken whole, never pre-flattened: `allRows` is a computed+    /// `groups.flatMap(\.rows)`, so reading it in `ContentView.body` would+    /// flatten the library on every republish for every tab — the Req 8.1 cost+    /// re-entering through the parameter list.+    let presentation: RecentPresentation+    /// The *other* snapshot. An array rather than a count, per Decision 5: the+    /// works total may not be derived from `allRows`.+    let works: [WorkSnapshot]+    /// Zero means no refresh cycle has completed, which Req 1.7 requires be+    /// distinguishable from a genuinely empty library (Q29).+    let snapshotGeneration: Int+    /// `selectedTab == .stats`. Req 8.1's gate.+    let isPresented: Bool+    /// Req 6.10's cross-tab route.+    let onOpenWork: (UUID) -> Void++    /// The whole state machine (Q41). `@State` in a tab survives a tab switch,+    /// which is Req 1.9, and is re-initialised on launch, which is Req 3.2.+    @State private var navigation = StatsNavigation()+    @State private var graph: StatsGraph?+    @State private var breakdown: StatsBreakdown?+    @State private var lastDerivedKey: StatsInputKey?+    /// Req 3.7. Held as state rather than read in `body`, because midnight and a+    /// zone change are notifications rather than view updates.+    @State private var temporal = StatsTemporalKey.current()+    /// Q54: the selected band's outline is an accessibility affordance, not a+    /// standing part of the graph's look.+    @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor++    var body: some View {+        NavigationStack {+            rootScreen+                .navigationTitle("Stats")+                // Q30: the opened month is a push, which supplies Req 5.4's back+                // affordance and Req 5.3's title without hand-built controls.+                .navigationDestination(item: openedMonth) { month in+                    monthScreen(month: month)+                }+        }+        // Q36's gate. The work inside is synchronous main-actor arithmetic —+        // `.task` is a lifecycle hook here, not an offload — so no republish can+        // land mid-derivation.+        .task(id: inputKey) { deriveIfNeeded() }+        // `.task(id:)` fires on appearance and on a key change; the tab becoming+        // selected is neither, and tab lifecycle for the value-based `Tab` API is+        // undocumented (Q36), so presentation gets its own trigger.+        .onChange(of: isPresented) { _, _ in deriveIfNeeded() }+        // Midnight, DST, carrier time, and the ones queued while suspended.+        .onReceive(+            NotificationCenter.default.publisher(+                for: UIApplication.significantTimeChangeNotification)+        ) { _ in+            temporal = .current()+        }+        // Q39: two zones can share a UTC offset and differ in week rules, which+        // changes what "This week" means without moving the day stamp.+        .onReceive(+            NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)+        ) { _ in+            temporal = .current()+        }+    }++    // MARK: - Screens++    private var rootScreen: some View {+        ScrollView {+            ZStack {+                clearingTapLayer+                VStack(alignment: .leading, spacing: 20) {+                    if snapshotGeneration == 0 {+                        unreadState+                    } else if let graph {+                        figures(graph, for: .period(navigation.period))+                        periodControl+                        // Req 5.5/Q42: a period change leaves any opened month,+                        // so the root always asks for the *period's* graph.+                        // While a month is open the derived graph is the+                        // month's and this renders nothing — the screen is+                        // covered by the push in any case, and the alternative+                        // is one frame of the wrong graph under the right+                        // period's name.+                        graphAndBreakdown(graph, for: .period(navigation.period))+                    } else {+                        ProgressView()+                            .frame(maxWidth: .infinity)+                            .accessibilityIdentifier("stats-deriving")+                    }+                }+                .padding(16)+                .frame(maxWidth: .infinity, alignment: .leading)+            }+        }+        .scrollContentBackground(.hidden)+        // Req 7.2: the same fixed layer Recent and Works use, applied on the+        // screen's own root rather than outside the stack, where it would render+        // behind the stack's own opaque backing.+        .background { ConstellationBackground() }+        .accessibilityIdentifier("stats-root")+    }++    /// Req 5.3's screen: the same graph and breakdown over one calendar month,+    /// named by the month it covers.+    private func monthScreen(month: Date) -> some View {+        ScrollView {+            ZStack {+                clearingTapLayer+                VStack(alignment: .leading, spacing: 20) {+                    if let graph {+                        // Req 2.10: an opened month is a bounded scope, so it+                        // carries the same header the root does — the lifetime+                        // pair and the month's own pair beside it.+                        figures(graph, for: .month(start: month))+                        graphAndBreakdown(graph, for: .month(start: month))+                    }+                }+                .padding(16)+                .frame(maxWidth: .infinity, alignment: .leading)+            }+        }+        .scrollContentBackground(.hidden)+        .background { ConstellationBackground() }+        .navigationTitle(Self.monthText(month))+        .navigationBarTitleDisplayMode(.inline)+        .accessibilityIdentifier("stats-month-screen")+    }++    /// Req 1.7 / Q29: both snapshots initialise empty, so an unread library and+    /// an empty one are the same values. The generation is what tells them apart.+    private var unreadState: some View {+        ContentUnavailableView {+            Label("No Reading Yet Recorded", systemImage: "chart.bar")+        } description: {+            Text("The library has not finished its first read, so there is nothing to report.")+        }+        .accessibilityIdentifier("stats-unread")+    }++    // MARK: - The header figures++    /// Reqs 2.1, 2.2, 2.5, 2.9, 2.10. The lifetime pair is always shown and+    /// neither half of it responds to the period; the selected span's own pair+    /// joins it wherever the scope is not All time (Q55). Every noun agrees with+    /// its number through `Pluralisation`.+    ///+    /// `ViewThatFits` rather than a fixed `HStack`: Req 7.8 forbids a total+    /// truncating, and at the accessibility Dynamic Type sizes even two of these+    /// do not sit side by side. Three candidates rather than the original two —+    /// all four across, two rows of two, then one column — because a fourth+    /// figure makes the intermediate shape the one that usually fits.+    private func figures(_ graph: StatsGraph, for scope: StatsScope) -> some View {+        // The cached graph can still describe the scope the screen has just left+        // (Q50/Q51), and a period figure under the wrong period's name is a wrong+        // number rather than a stale one. The lifetime pair is scope-independent+        // and needs no such guard.+        let period = graph.scope == scope ? graph.periodFigures : nil+        let phrase = Self.scopePhrase(scope)+        return ViewThatFits(in: .horizontal) {+            HStack(alignment: .top, spacing: 12) {+                lifetimeTiles(graph)+                periodTiles(period, phrase: phrase)+            }+            VStack(alignment: .leading, spacing: 12) {+                HStack(alignment: .top, spacing: 12) { lifetimeTiles(graph) }+                if period != nil {+                    HStack(alignment: .top, spacing: 12) { periodTiles(period, phrase: phrase) }+                }+            }+            VStack(alignment: .leading, spacing: 12) {+                lifetimeTiles(graph)+                periodTiles(period, phrase: phrase)+            }+        }+    }++    @ViewBuilder+    private func lifetimeTiles(_ graph: StatsGraph) -> some View {+        figureTile(+            graph.totalNotes, "note", "notes", phrase: nil, identifier: "stats-total-notes")+        figureTile(+            graph.totalWorks, "work", "works", phrase: nil, identifier: "stats-total-works")+    }++    /// Req 2.10's pair. `works` counts the distinct works the span's notes came+    /// from, so a note attached to nothing is in the left figure and in no work.+    @ViewBuilder+    private func periodTiles(_ figures: StatsPeriodFigures?, phrase: String) -> some View {+        if let figures {+            figureTile(+                figures.notes, "note", "notes", phrase: phrase,+                identifier: "stats-period-notes")+            figureTile(+                figures.works, "work", "works", phrase: phrase,+                identifier: "stats-period-works")+        }+    }++    /// One figure. `phrase` names the span for the period pair and is nil for the+    /// lifetime pair, which is what tells the two apart on screen and in speech.+    private func figureTile(+        _ count: Int, _ singular: String, _ plural: String, phrase: String?, identifier: String+    ) -> some View {+        let noun = Pluralisation.noun(count, singular, plural)+        let spoken = Pluralisation.count(count, singular, plural)+        return VStack(alignment: .leading, spacing: 2) {+            Text("\(count)")+                .font(AsterismTypography.serif(.title, weight: .semibold))+                .foregroundStyle(AsterismColors.primaryText)+            Text(phrase.map { "\(noun) \($0)" } ?? noun)+                .font(.caption)+                .foregroundStyle(AsterismColors.secondaryText)+        }+        // No line limit anywhere above: Req 7.8 forbids truncating a total, so+        // the label wraps rather than clipping at the accessibility sizes.+        .fixedSize(horizontal: false, vertical: true)+        .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget, alignment: .leading)+        .padding(.vertical, 10)+        .padding(.horizontal, 14)+        .constellationCard()+        // Req 7.6: the value in words, as one element.+        .accessibilityElement(children: .ignore)+        .accessibilityLabel(phrase.map { "\(spoken) \($0)" } ?? spoken)+        .accessibilityIdentifier(identifier)+    }++    // MARK: - Period control++    /// Q31: a `Menu`, not a segmented picker. Five labels of "This month" length+    /// cannot fit a segmented control at the accessibility Dynamic Type sizes,+    /// which Req 7.8 forbids clipping, and a menu is one 44 pt target at every+    /// size (Req 7.4).+    private var periodControl: some View {+        Menu {+            ForEach(StatsPeriod.allCases) { period in+                Button {+                    navigation.select(period: period)+                } label: {+                    if period == navigation.period {+                        Label(period.title, systemImage: "checkmark")+                    } else {+                        Text(period.title)+                    }+                }+            }+        } label: {+            HStack(spacing: 6) {+                Text(navigation.period.title)+                    .font(.subheadline.weight(.semibold))+                    .fixedSize(horizontal: false, vertical: true)+                Image(systemName: "chevron.up.chevron.down")+                    .font(.caption2)+            }+            .foregroundStyle(AsterismColors.cyan)+            .padding(.vertical, 8)+            .padding(.horizontal, 14)+            .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+            .constellationCard(cornerRadius: AsterismLayout.buttonRadius)+        }+        .accessibilityIdentifier("stats-period-menu")+        .accessibilityLabel("Period, \(navigation.period.title)")+    }++    // MARK: - Graph and breakdown++    @ViewBuilder+    private func graphAndBreakdown(_ graph: StatsGraph, for scope: StatsScope) -> some View {+        if graph.scope == scope {+            graphSection(graph)+            breakdownSection()+        }+    }++    private func graphSection(_ graph: StatsGraph) -> some View {+        VStack(alignment: .leading, spacing: 10) {+            if graph.bars.isEmpty {+                // Req 3.6: All time over a library holding no usable capture+                // date resolves no span at all.+                emptyGraph("No note carries a capture date yet, so there is nothing to plot.")+            } else if graph.bars.allSatisfy({ $0.count == 0 }) {+                // Req 4.7: a span whose every bucket is empty is stated rather+                // than drawn as an empty chart frame.+                emptyGraph("No notes were first captured in this period.")+            } else {+                chart(graph)+            }++            // Req 4.11 / Q11: the page says what it counts rather than leaving+            // the reader to infer it.+            Text("Counted by when a note was first captured. Re-reading does not move a bar.")+                .font(.caption)+                .foregroundStyle(AsterismColors.secondaryText)+                .fixedSize(horizontal: false, vertical: true)+                .accessibilityIdentifier("stats-graph-caption")+        }+        .frame(maxWidth: .infinity, alignment: .leading)+    }++    /// Req 6.8's other clearing route (Q21). The categorical scale tiles the+    /// plot, so there is no "outside a bar" *within* it — the clearing tap is+    /// the page area around the chart.+    ///+    /// A sibling **behind** the content rather than a gesture on an ancestor of+    /// it: a tap gesture wrapping the chart wins the tap outright and the plot+    /// stops receiving anything at all. Behind the content, a tap on a band hits+    /// the band's own control and only the surrounding space falls through here.+    private var clearingTapLayer: some View {+        Color.clear+            .contentShape(Rectangle())+            .onTapGesture { navigation.clearSelection() }+    }++    private func emptyGraph(_ message: String) -> some View {+        Text(message)+            .font(.subheadline)+            .foregroundStyle(AsterismColors.secondaryText)+            .fixedSize(horizontal: false, vertical: true)+            .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)+            .padding(14)+            .constellationCard()+            .accessibilityIdentifier("stats-graph-empty")+    }++    /// Reqs 6.1, 6.2, 6.9, 6.11. Rendered inside the page's own `ScrollView`, so+    /// a long day scrolls rather than being truncated to a fixed row count.+    ///+    /// Q51: the cached breakdown has to *be* the selected day's, not merely+    /// exist. Between `toggle(day:)` and the re-derivation `.task(id:)` schedules+    /// there is a window — one derivation long, not one frame, on a large library+    /// — in which the previous day's heading, total and rows would otherwise+    /// render under the newly highlighted bar. Symmetric with the graph's own+    /// `graph.scope == scope` guard.+    @ViewBuilder+    private func breakdownSection() -> some View {+        if let breakdown, breakdown.day == navigation.selectedDay {+            VStack(alignment: .leading, spacing: 8) {+                Text(Self.dayText(breakdown.day))+                    .font(AsterismTypography.serifHeading)+                    .foregroundStyle(AsterismColors.primaryText)+                    .fixedSize(horizontal: false, vertical: true)+                    .accessibilityIdentifier("stats-breakdown-day")+                Text(Pluralisation.count(breakdown.total, "note", "notes"))+                    .font(.caption)+                    .foregroundStyle(AsterismColors.secondaryText)+                    .accessibilityIdentifier("stats-breakdown-total")++                if breakdown.rows.isEmpty {+                    // Req 6.9: a zero-value bar says so rather than showing an+                    // empty list.+                    Text("No notes were first captured on this day.")+                        .font(.subheadline)+                        .foregroundStyle(AsterismColors.secondaryText)+                        .fixedSize(horizontal: false, vertical: true)+                        .padding(.vertical, 10)+                        .accessibilityIdentifier("stats-breakdown-empty")+                } else {+                    ForEach(breakdown.rows) { row in+                        breakdownRow(row)+                    }+                }+            }+            .frame(maxWidth: .infinity, alignment: .leading)+        }+    }++    /// Req 6.10. The route is offered only where the work resolves in the+    /// *current* snapshot — read here rather than baked into `StatsBreakdown`,+    /// so a work merged away since the derivation carries no route. The rows of+    /// one day are few, so the linear lookup costs less than the set it would+    /// otherwise build on every update.+    @ViewBuilder+    private func breakdownRow(_ row: StatsBreakdownRow) -> some View {+        let name = Self.categoryName(row.category)+        if case .work(let workID, _) = row.category, works.contains(where: { $0.id == workID }) {+            Button {+                openWork(workID)+            } label: {+                breakdownRowContent(name: name, count: row.count)+            }+            .buttonStyle(.plain)+            .accessibilityIdentifier("stats-breakdown-work-row")+            .accessibilityLabel("Open Work \(name)")+            .accessibilityValue(Pluralisation.count(row.count, "note", "notes"))+        } else {+            breakdownRowContent(name: name, count: row.count)+                .accessibilityElement(children: .ignore)+                .accessibilityIdentifier("stats-breakdown-row")+                .accessibilityLabel("\(name), \(Pluralisation.count(row.count, "note", "notes"))")+        }+    }++    private func breakdownRowContent(name: String, count: Int) -> some View {+        HStack(spacing: 8) {+            Text(name)+                .font(AsterismTypography.serifRowTitle)+                .lineLimit(1)+                .truncationMode(.tail)+                .foregroundStyle(AsterismColors.primaryText)+            Spacer(minLength: 4)+            Text("\(count)")+                .constellationPill(.count)+        }+        .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget, alignment: .leading)+        .padding(.vertical, 6)+        .padding(.horizontal, 12)+        .contentShape(Rectangle())+        .constellationCard()+    }++    /// Checked once more at the moment of the tap, which is what Req 6.10 asks+    /// for: a snapshot republished between the row rendering and the tap can+    /// have taken the work with it.+    private func openWork(_ workID: UUID) {+        guard works.contains(where: { $0.id == workID }) else { return }+        onOpenWork(workID)+    }++    // MARK: - The chart++    /// Req 7.3: the plot area is the selection surface, tiled band by band, so a+    /// touch resolves to a bar by position and the narrowest bar and the+    /// zero-value bar are equally reachable.+    ///+    /// **Neither `chartXSelection` nor `chartGesture` is used, and both were+    /// tried.** Measured on iOS 26:+    ///+    /// - `chartXSelection(value:)` never fires here at all. The page is a+    ///   `ScrollView`, because Req 6.11 requires the breakdown to scroll, and+    ///   the chart's built-in selection gesture loses to the scroll view's pan.+    ///   A tap at a band's centre and at both its edges selected nothing.+    /// - `chartGesture` with a `SpatialTapGesture` *does* fire, and+    ///   `proxy.value(atX:)` resolves the band correctly — but a gesture is not+    ///   a control, so it satisfies neither Req 7.5's exposed selected state nor+    ///   Req 7.7's "invoke its selection", which is what Q34 went looking for an+    ///   `.accessibilityRepresentation` to supply.+    ///+    /// So the touch targets are real `Button`s laid over the plot, one per band,+    /// positioned by the scale's own `positionRange(forX:)`. They are the+    /// interaction *and* the accessibility surface at once: a band-wide,+    /// plot-tall control carrying the bucket's date, its count, its identifier+    /// and `.isSelected`. That answers Q34's objection with controls rather than+    /// a parallel tree, and it is what makes a bar reachable by identifier+    /// rather than only readable.+    ///+    /// **No graph scrolls, All time included** (Q56). Decision 3 offered the+    /// scrolling All-time graph a stated fallback — "All time renders+    /// non-scrolling with compressed bands… the cost is tap comfort only" — and+    /// Q49 recorded the unmeasured risk that made it necessary. Measured over a+    /// 31-month fixture, the risk is real: `positionRange(forX:)` reports+    /// content-space positions while `.chartOverlay` lays out in viewport space,+    /// so a band past the first viewport is covered by another band's control and+    /// the band labelled *August 2026* opened *January 2025*.+    ///+    /// The pan is the half that cannot be repaired here. The overlay covers the+    /// plot outright, which is why the graph would not scroll on device at all,+    /// and no supported API puts a control inside a chart's scrolling content —+    /// so the mis-targeting could be compensated for and the scroll could not.+    ///+    /// Every band therefore fits its plot by construction, the controls cannot+    /// drift from the bars they name, and the graph needs no separate+    /// accessibility tree to escape a viewport it no longer has — so the band+    /// controls are the accessibility elements on every graph, which is what+    /// makes a bar both readable *and* invocable (Q46).+    private func chart(_ graph: StatsGraph) -> some View {+        let selected = selectedIndex(in: graph)+        // Every band the scale knows, and the label each *ticked* band carries.+        // Built once here so the axis reads its text off the bar it belongs to,+        // rather than parsing Q45's band name back into an index.+        let bands = graph.bars.map { Self.band($0.index) }+        let ticks = StatsDerivation.dateAxisTicks(graph)+        let tickBands = ticks.map { Self.band($0.index) }+        let tickLabels = Dictionary(+            uniqueKeysWithValues: ticks.map {+                (Self.band($0.index), Self.axisText($0.start, unit: graph.unit))+            })+        return Chart(graph.bars) { bar in+            BarMark(+                x: .value("Date", Self.band(bar.index)),+                y: .value("Notes", bar.count)+            )+            // Req 7.1: cyan, the accent the style guide assigns to affirmative+            // content. Flat, not a gradient — §11 restricts those to three named+            // cases and a chart is none of them.+            //+            // Deliberately not `ConstellationRecipes.knockdownOpacity`: that+            // token means "still shown but no longer offered", which is not what+            // an unselected bar is.+            .foregroundStyle(AsterismColors.cyan.opacity(bar.index == selected ? 1 : 0.5))+            .cornerRadius(3)+        }+        // Q32's categorical scale, over the band *name* rather than the bar's+        // `Int` index.+        //+        // **`.chartXScale(domain: bars.map(\.index), type: .category)` traps at+        // runtime** — `Charts/ChartInternal.swift:170`, "The specified scale+        // type is incompatible with the data values and visual property". Q32+        // reads `Int` correctly (a `Plottable` with a `Double` primitive, so a+        // *quantitative* scale) and then assumes `type:` can override that. It+        // cannot: a category scale requires a plottable whose primitive is a+        // `String`. Rendering the index as one is the smallest correction that+        // still delivers the mechanism Q32 is about, and it was measured to do+        // so — seven bands tile a 382 pt plot with zero gap between adjacent+        // `positionRange(forX:)` values, and a 24-band visible domain puts+        // exactly 24 bands in the viewport. The domain array fixes the order, so+        // string collation never reaches the axis (Req 4.5).+        //+        // The domain is hole-free only because Req 4.6 emits zero-count bars+        // (Q33).+        .chartXScale(domain: bands, type: .category)+        // Req 4.8: an explicit zero baseline, because Charts otherwise starts+        // the domain at the data's own minimum.+        .chartYScale(domain: 0...StatsDerivation.upperBound(graph))+        .chartYAxis {+            AxisMarks(values: StatsDerivation.valueAxisTicks(graph)) {+                AxisGridLine()+                AxisTick()+                AxisValueLabel()+            }+        }+        .chartYAxisLabel("Notes", position: .leading)+        .chartXAxis {+            // Req 4.9: the first and last bar of the span are always labelled.+            AxisMarks(values: tickBands) { value in+                AxisTick()+                AxisValueLabel {+                    if let band = value.as(String.self), let text = tickLabels[band] {+                        Text(text)+                    }+                }+            }+        }+        .chartOverlay { proxy in+            bandControls(graph, proxy: proxy, selected: selected)+        }+        .frame(height: 200)+    }++    /// One `Button` per band, covering the band's full width and the plot's full+    /// height, positioned by the scale's own `positionRange(forX:)` so a control+    /// cannot drift from the bar it names.+    ///+    /// Q25/Q54: the selected band reads from the bar's own fill strength, and+    /// takes an outline **only** where the system's Differentiate Without Color+    /// setting asks for one. A fill-strength difference is a difference in one+    /// colour's rendering rather than an independent channel, which is precisely+    /// why the outline is driven by the setting that exists to say so (Req 7.5).+    /// The `.isSelected` trait on the control is unconditional either way — it is+    /// a separate mechanism, and assistive technology never sees either mark.+    private func bandControls(_ graph: StatsGraph, proxy: ChartProxy, selected: Int?) -> some View {+        GeometryReader { geometry in+            if let plotFrame = proxy.plotFrame {+                let plot = geometry[plotFrame]+                ForEach(graph.bars) { bar in+                    if let range = proxy.positionRange(forX: Self.band(bar.index)) {+                        barElement(bar, in: graph, selected: selected) {+                            RoundedRectangle(cornerRadius: 4, style: .continuous)+                                .strokeBorder(+                                    bar.index == selected && differentiateWithoutColor+                                        ? AsterismColors.primaryText.opacity(0.8) : .clear,+                                    lineWidth: 2)+                                .contentShape(Rectangle())+                        }+                        .buttonStyle(.plain)+                        .frame(+                            width: max(range.upperBound - range.lowerBound, 1),+                            height: plot.height)+                        .position(+                            x: plot.minX + (range.lowerBound + range.upperBound) / 2,+                            y: plot.midY)+                    }+                }+            }+        }+    }++    /// One bar, as a control: the tap action and the four things the bar+    /// publishes about itself. One definition, one live tree — the band control+    /// *is* the accessibility element on every graph since Q56 retired the+    /// All-time representation along with the scroll it existed to escape.+    private func barElement<Label: View>(+        _ bar: StatsBar, in graph: StatsGraph, selected: Int?,+        @ViewBuilder label: () -> Label+    ) -> some View {+        Button {+            activate(bar, in: graph)+        } label: {+            label()+        }+        // Req 4.9: every bar's date is discoverable here whether or not an axis+        // label is drawn for it.+        .accessibilityIdentifier("stats-bar-\(bar.index)")+        .accessibilityLabel(Self.barText(bar.start, unit: graph.unit))+        .accessibilityValue(Pluralisation.count(bar.count, "note", "notes"))+        // Req 7.5: the selected state is exposed to assistive technology and+        // clears with the selection.+        .accessibilityAddTraits(bar.index == selected ? [.isSelected] : [])+    }++    // MARK: - Selection++    private func selectedIndex(in graph: StatsGraph) -> Int? {+        guard let day = navigation.selectedDay else { return nil }+        return graph.bars.first(where: { $0.start == day })?.index+    }++    /// The categorical x value for a bar. The scale's domain is this rendering+    /// of the index, so every place that names a band names it the same way.+    nonisolated private static func band(_ index: Int) -> String { String(index) }++    /// Decision 3's fork, in the one place both the band control and the+    /// All-time representation call — so a bar does exactly one thing however it+    /// was reached.+    private func activate(_ bar: StatsBar, in graph: StatsGraph) {+        if graph.scope.isAllTime {+            navigation.open(month: bar.start)+        } else {+            navigation.toggle(day: bar.start)+        }+    }++    private var openedMonth: Binding<Date?> {+        Binding(+            get: { navigation.openedMonth },+            // Req 5.4: the route out of an opened month returns to All time,+            // which is what `closeMonth()` leaves behind — the period is+            // untouched and the selection goes with the month.+            set: { if $0 == nil { navigation.closeMonth() } })+    }++    // MARK: - The derivation gate++    private var inputKey: StatsInputKey {+        StatsInputKey(+            generation: snapshotGeneration,+            scope: navigation.scope,+            selectedDay: navigation.selectedDay,+            temporal: temporal)+    }++    /// Req 8.1 (nothing derives off screen) and Req 8.2 (one snapshot, one+    /// period and one current day derive once, not once per view update).+    ///+    /// The two derivations carry two keys (Q35). Bars do not depend on the+    /// selection, so a day changing must not re-bucket every note in the+    /// library — which is what a single key containing `selectedDay` would do on+    /// every frame of a selection drag.+    private func deriveIfNeeded() {+        guard isPresented else { return }+        let key = inputKey+        // Re-read rather than captured: a captured `Calendar` retains the+        // settings it was read under, which is exactly what Req 3.7 is about.+        let calendar = Calendar.current++        if lastDerivedKey?.graphIdentity != key.graphIdentity {+            graph = StatsDerivation.graph(+                presentation: presentation,+                works: works,+                scope: navigation.scope,+                calendar: calendar,+                now: Date())+        }+        if lastDerivedKey?.breakdownIdentity != key.breakdownIdentity {+            // Req 1.8: a republished snapshot re-derives the selected day rather+            // than clearing the selection, and a day drained to zero renders+            // Req 6.9's message.+            breakdown = navigation.selectedDay.map {+                StatsDerivation.breakdown(+                    presentation: presentation, day: $0, calendar: calendar)+            }+        }+        lastDerivedKey = key+    }++    // MARK: - Text++    nonisolated private static func categoryName(_ category: StatsCategory) -> String {+        switch category {+        case .work(_, let title): title+        // Req 6.4's single named category, held immediately before the workless+        // one (Q20).+        case .unresolvedWork: "Unresolved Works"+        // Req 6.3: labelled as the Works tab labels its workless group.+        case .unattached: "Unattached Notes"+        }+    }++    /// The `exportDateText` precedent (`LibraryRepository+Export.swift:173`):+    /// one `Date.FormatStyle`, formatted where it is read.+    nonisolated private static func dayText(_ date: Date) -> String {+        date.formatted(.dateTime.weekday(.wide).day().month(.wide).year())+    }++    nonisolated private static func monthText(_ date: Date) -> String {+        date.formatted(.dateTime.month(.wide).year())+    }++    /// Req 2.10's figures name the span they describe, so "12 notes" cannot be+    /// read as a second lifetime total. A phrase rather than a heading: it has to+    /// agree with the noun beside it in one line of caption text, and in one+    /// spoken sentence.+    ///+    /// All time never reaches here — it carries no period figures at all.+    nonisolated private static func scopePhrase(_ scope: StatsScope) -> String {+        switch scope {+        case .period(let period): period.figuresPhrase+        case .month(let start): "in \(monthText(start))"+        }+    }++    /// Req 4.9: every bar's date is discoverable through its accessibility label+    /// whether or not an axis label is drawn for it.+    nonisolated private static func barText(_ date: Date, unit: StatsBarUnit) -> String {+        unit == .day ? dayText(date) : monthText(date)+    }++    nonisolated private static func axisText(_ date: Date, unit: StatsBarUnit) -> String {+        unit == .day+            ? date.formatted(.dateTime.day().month(.abbreviated))+            : date.formatted(.dateTime.month(.abbreviated).year(.twoDigits))+    }+}++// MARK: - Period titles++nonisolated extension StatsPeriod {+    /// Req 3.1's five labels, in the order the control offers them.+    var title: String {+        switch self {+        case .thisWeek: "This week"+        case .lastWeek: "Last week"+        case .thisMonth: "This month"+        case .lastMonth: "Last month"+        case .allTime: "All time"+        }+    }++    /// The same period as it reads *after* a noun — "12 notes this week". All+    /// time carries no period figures (Q55), so its phrase is never rendered and+    /// is stated only to keep the switch total.+    var figuresPhrase: String {+        switch self {+        case .thisWeek: "this week"+        case .lastWeek: "last week"+        case .thisMonth: "this month"+        case .lastMonth: "last month"+        case .allTime: "in all"+        }+    }+}
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +33 / -4
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex c7ae552..e2eb10a 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -17,6 +17,17 @@ public final class AppLibraryModel {     public private(set) var recentGroups: [DatedEntryGroup] = []     public private(set) var recentPresentation: RecentPresentation = RecentPresentation(groups: [], actionableCount: 0)     public private(set) var worksSnapshot: WorksSnapshot = WorksSnapshot(works: [], unattachedEntries: [])+    /// How many refresh cycles have completed, bumped once per cycle with the+    /// snapshots it publishes (`stats-page` Q27, Q29, Q37).+    ///+    /// Two things rest on it. A derived surface keys its work on this rather+    /// than on the snapshots themselves, which would mean a deep `Equatable`+    /// comparison of the whole library to decide whether to do a full pass. And+    /// zero means *no refresh has completed*: both snapshots initialise empty,+    /// so an unread library and an empty one are otherwise the same values.+    /// Production reaches `.ready` only after `refreshAll()`, so a zero+    /// generation in a ready app means that refresh threw.+    public private(set) var snapshotGeneration: Int = 0     /// One gate value drives repository validation, navigation, teaching, Backup, and capture UI.     public let capabilities: AsterismCapabilities @@ -328,18 +339,36 @@ public final class AppLibraryModel {         }     } -    /// Replaces all cached snapshots from the repository.+    /// Replaces all cached snapshots from the repository, publishing them+    /// together or not at all (`stats-page` Q37).+    ///+    /// The two reads remain two store instants — combining them would need one+    /// locked read in `AsterismCore`, which `stats-page` Q13 records as out of+    /// scope, and `specs/immutable-capture-safety-net/implementation.md:60`+    /// already discloses that tabs can briefly show different source moments.+    /// What the staging buys is narrower and is what Req 1.4 actually claims:+    /// one *completed cycle*. Assigning the presentation before `works()` runs+    /// left a throw there publishing a new presentation beside the previous+    /// works snapshot — a pair that never existed in the store — with no+    /// generation bump to say the cycle had moved.     public func refreshAll() async {         guard let repo = repository else { return }         do {             let calendar = Calendar.current-            recentPresentation = try await repo.recentPresentation(calendar: calendar)+            let presentation = try await repo.recentPresentation(calendar: calendar)+            let works = try await repo.works()             // Keep the legacy grouped snapshot for curation views while deriving it             // from the same coherent Recent read rather than a second repository read.-            recentGroups = recentPresentation.groups.map {+            let groups = presentation.groups.map {                 DatedEntryGroup(day: $0.day, entries: $0.rows.map(\.entry))             }-            worksSnapshot = try await repo.works()+            // No `await` from here to the end of the block: a reader observes+            // all four values of one cycle, never three of this one beside one+            // of the last.+            recentPresentation = presentation+            recentGroups = groups+            worksSnapshot = works+            snapshotGeneration += 1         } catch {             Self.logger.error("Snapshot refresh failed: \(String(describing: error), privacy: .public)")         }@@ -1126,6 +1155,23 @@ public final class AppLibraryModel {             #endif         } +        if fixture == .spanningMonths {+            #if DEBUG || ASTERISM_PERFORMANCE_TESTING+            // A wholly legal library, seeded through the same locked write the+            // other fixtures use. Guarded on the build rather than on a+            // capability gate, because it writes no gated feature at all — no+            // rules, no work types, no composition (docs/agent-notes/testing.md).+            try await repository.seedSpanningMonthsFixture()+            Self.logger.debug("Seeded the 31-month spanning UI test fixture")+            return+            #else+            throw LibraryRepositoryError.invalidInput(+                operation: "preparing UI test fixture",+                reason: "the spanning-months fixture requires a debug build"+            )+            #endif+        }+         if fixture == .composed {             // Production opens through the app-role opener, so the composed             // fixture seeds through the ordinary bootstrap, which creates and
Asterism/Asterism/ContentView.swift Modified +54 / -1
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 4b6eab6..0e6217f 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -3,7 +3,11 @@ import ConstellationKit import OSLog import SwiftUI -/// Two-tab root view driven by AppLibraryModel state.+/// Three-tab root view driven by AppLibraryModel state.+///+/// All three tabs live in `readyContent`, so Stats is present exactly when+/// Recent and Works are and absent in the loading and unavailable states they+/// are absent from (`specs/stats-page/` Req 1.2). struct ContentView: View {     @State private var model: AppLibraryModel     @State private var selectedTab: AppTab = .recent@@ -63,6 +67,9 @@ struct ContentView: View {     enum AppTab {         case recent         case works+        /// `specs/stats-page/` Decision 2. The third tab falsifies the two-tab+        /// shape three documents state; task 16 annotates each of them.+        case stats     }      /// Production initializer. Debug UI tests may request an isolated seeded@@ -299,6 +306,31 @@ struct ContentView: View {                 }             }             .accessibilityIdentifier("tab-works")++            // `specs/stats-page/` Req 1.1. The snapshots are passed whole and+            // never pre-flattened here: `recentPresentation.allRows` is a+            // computed `groups.flatMap(\.rows)`, so taking it in this body would+            // flatten the whole library on every republish, for every tab,+            // whether or not Stats is on screen (Req 8.1).+            SwiftUI.Tab("Stats", systemImage: "chart.bar", value: AppTab.stats) {+                StatsView(+                    presentation: model.recentPresentation,+                    works: model.worksSnapshot.works,+                    snapshotGeneration: model.snapshotGeneration,+                    // Req 8.1's gate. Q36: `.task(id:)` alone satisfies neither+                    // Req 8.1 nor Req 8.2, and tab lifecycle for the value-based+                    // `Tab` API is undocumented, so the screen is told outright.+                    isPresented: selectedTab == .stats,+                    onOpenWork: showWork+                )+            }+            // Matching the other two exactly (Req 1.1). Measured 2026-08-16: an+            // identifier on a `Tab` does **not** reach the tab-bar button — the+            // button carries its label and nothing else, and a custom `label:`+            // view carrying one does not change that. The identifier is+            // therefore inert here, as Decision 2 already suspected, and+            // `StatsUITests` reaches the tab by label like every other suite.+            .accessibilityIdentifier("tab-stats")         }         // Requirement 9.3: the native tab bar is already the floating glass         // capsule on iOS 26; the active item's cyan is the one token it takes.@@ -463,6 +495,27 @@ struct ContentView: View {         worksResetToken += 1     } +    /// `specs/stats-page/` Req 6.10: a breakdown row switches to the Works tab+    /// with that work open.+    ///+    /// The same clearing discipline `showWorksRoot()` above uses, minus the+    /// `worksResetToken` bump — this route names a destination rather than+    /// asking for the root, so rebuilding `WorksView` and discarding its query+    /// and scroll position would take Req 1.3's promise with it.+    ///+    /// The three clears are explicit for the reason recorded above: the+    /// `onChange(of: selectedWorkID)` fires on a later update and only when the+    /// id actually changed, so it is the safety net and not this route's clear.+    /// Without them, opening the work already open leaves a pushed chapter on+    /// top of it, and a pushed unattached note survives on the same stack as a+    /// second root destination.+    private func showWork(_ workID: UUID) {+        selectedTab = .works+        selectedWorksEntryID = nil+        selectedWorkChapterEntryID = nil+        selectedWorkID = workID+    }+     /// Q30's fork, in the one place that owns navigation: a divergent Work set     /// with no torn member is a *Merge*, which is the Works flow, and everything     /// else is the resolution sheet.
Asterism/Asterism/ViewModels/Pluralisation.swift Modified +9 / -1
diff --git a/Asterism/Asterism/ViewModels/Pluralisation.swift b/Asterism/Asterism/ViewModels/Pluralisation.swiftindex f202dcb..5927a80 100644--- a/Asterism/Asterism/ViewModels/Pluralisation.swift+++ b/Asterism/Asterism/ViewModels/Pluralisation.swift@@ -7,6 +7,14 @@ import Foundation /// this. It is one sentence-building rule, so it lives in one place. enum Pluralisation {     static func count(_ count: Int, _ singular: String, _ plural: String) -> String {-        "\(count) \(count == 1 ? singular : plural)"+        "\(count) \(noun(count, singular, plural))"+    }++    /// The noun on its own, for the places that show the number separately —+    /// the Stats totals set the figure in its own type and the noun beneath it.+    /// Agreement is the same rule either way, so it is stated once and `count`+    /// is built from it.+    static func noun(_ count: Int, _ singular: String, _ plural: String) -> String {+        count == 1 ? singular : plural     } }
Asterism/AsterismTests/StatsDerivationTests.swift Added +1564
diff --git a/Asterism/AsterismTests/StatsDerivationTests.swift b/Asterism/AsterismTests/StatsDerivationTests.swiftnew file mode 100644index 0000000..6262563--- /dev/null+++ b/Asterism/AsterismTests/StatsDerivationTests.swift@@ -0,0 +1,1716 @@+import AsterismCore+import Foundation+import SwiftData+import Testing+@testable import Asterism++// The Stats page's whole derivation (`specs/stats-page/`). `StatsDerivation` is+// pure and total over its parameters — no `Date()`, no `Calendar.current`, no+// repository — so every case here states its own clock, its own calendar and its+// own library, and nothing is a store test except the one that says so.+//+// The fixed `now` is Sunday 16 August 2026 at noon. That date is load-bearing+// twice: it is the last day of its week under a Monday first-weekday and the+// first day of its week under a Sunday one, which is what makes Req 3.3's+// first-weekday variation visible in one pair of assertions.+@Suite("Stats derivation")+struct StatsDerivationTests {++    // MARK: - Fixture helpers++    /// A calendar that states everything the derivation is allowed to read from+    /// it. `Calendar.current` never appears in these tests, because it never+    /// appears in the derivation.+    private func calendar(+        timeZone: String = "UTC", firstWeekday: Int = 2+    ) -> Calendar {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: timeZone)!+        calendar.firstWeekday = firstWeekday+        return calendar+    }++    private func date(+        _ year: Int, _ month: Int, _ day: Int,+        _ hour: Int = 12, _ minute: Int = 0, _ second: Int = 0,+        in calendar: Calendar+    ) -> Date {+        calendar.date(+            from: DateComponents(+                year: year, month: month, day: day,+                hour: hour, minute: minute, second: second))!+    }++    /// Sunday 16 August 2026, noon.+    private func now(in calendar: Calendar) -> Date { date(2026, 8, 16, in: calendar) }++    private func row(+        capturedAt: Date,+        workID: UUID? = nil,+        workTitle: String? = nil,+        lastSharedAt: Date = TestFixtures.fixedDate,+        attention: RecentRowAttention? = nil+    ) -> RecentPresentationRow {+        TestFixtures.makeRecentRow(+            workDisplayTitle: workTitle,+            lastSharedAt: lastSharedAt,+            workID: workID,+            firstCapturedAt: capturedAt,+            attention: attention)+    }++    /// One day group holding every row. The graph reads `allRows`, so the+    /// grouping is only meaningful in the case that is *about* input order.+    private func presentation(_ rows: [RecentPresentationRow]) -> RecentPresentation {+        RecentPresentation(+            groups: [RecentPresentationGroup(day: TestFixtures.fixedDate, rows: rows)],+            actionableCount: 0)+    }++    private func graph(+        _ rows: [RecentPresentationRow],+        scope: StatsScope,+        works: [WorkSnapshot] = [],+        calendar: Calendar,+        now: Date+    ) -> StatsGraph {+        StatsDerivation.graph(+            presentation: presentation(rows), works: works,+            scope: scope, calendar: calendar, now: now)+    }++    // MARK: - Period bounds (Reqs 3.1, 3.3, 4.1)++    @Test("This week spans the seven days of the calendar week holding now")+    func thisWeekBounds() {+        let calendar = calendar()+        let result = graph([], scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .day)+        #expect(result.bars.count == 7)+        #expect(result.bars.first?.start == date(2026, 8, 10, 0, in: calendar))+        #expect(result.bars.last?.start == date(2026, 8, 16, 0, in: calendar))+    }++    @Test("Last week is the seven days before this week")+    func lastWeekBounds() {+        let calendar = calendar()+        let result = graph([], scope: .period(.lastWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .day)+        #expect(result.bars.count == 7)+        #expect(result.bars.first?.start == date(2026, 8, 3, 0, in: calendar))+        #expect(result.bars.last?.start == date(2026, 8, 9, 0, in: calendar))+    }++    @Test("This month spans the calendar month holding now")+    func thisMonthBounds() {+        let calendar = calendar()+        let result = graph([], scope: .period(.thisMonth), calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .day)+        #expect(result.bars.count == 31)+        #expect(result.bars.first?.start == date(2026, 8, 1, 0, in: calendar))+        #expect(result.bars.last?.start == date(2026, 8, 31, 0, in: calendar))+    }++    @Test("Last month is the whole preceding calendar month")+    func lastMonthBounds() {+        let calendar = calendar()+        let result = graph([], scope: .period(.lastMonth), calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .day)+        #expect(result.bars.count == 31)+        #expect(result.bars.first?.start == date(2026, 7, 1, 0, in: calendar))+        #expect(result.bars.last?.start == date(2026, 7, 31, 0, in: calendar))+    }++    /// Req 3.3 in the form that actually breaks: a month-length subtraction from+    /// 31 March lands in March again, so "last month" has to be calendar+    /// arithmetic on the month component.+    @Test("Last month from the 31st is February, not March again")+    func lastMonthFromALongerMonth() {+        let calendar = calendar()+        let result = graph(+            [], scope: .period(.lastMonth), calendar: calendar,+            now: date(2026, 3, 31, in: calendar))++        #expect(result.bars.first?.start == date(2026, 2, 1, 0, in: calendar))+        #expect(result.bars.count == 28)+    }++    @Test("An opened month is a per-day span over that calendar month (Req 5.3)")+    func openedMonthBounds() {+        let calendar = calendar()+        let result = graph(+            [], scope: .month(start: date(2026, 2, 1, 0, in: calendar)),+            calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .day)+        #expect(result.bars.count == 28)+        #expect(result.bars.first?.start == date(2026, 2, 1, 0, in: calendar))+    }++    // MARK: - First weekday (Req 3.3)++    @Test("The week's bounds follow the calendar's first weekday")+    func firstWeekdayMovesTheWeek() {+        let monday = calendar(firstWeekday: 2)+        let sunday = calendar(firstWeekday: 1)++        let mondayWeek = graph(+            [], scope: .period(.thisWeek), calendar: monday, now: now(in: monday))+        let sundayWeek = graph(+            [], scope: .period(.thisWeek), calendar: sunday, now: now(in: sunday))++        // Sunday 16 August closes the Monday-based week and opens the+        // Sunday-based one.+        #expect(mondayWeek.bars.first?.start == date(2026, 8, 10, 0, in: monday))+        #expect(mondayWeek.bars.last?.start == date(2026, 8, 16, 0, in: monday))+        #expect(sundayWeek.bars.first?.start == date(2026, 8, 16, 0, in: sunday))+        #expect(sundayWeek.bars.last?.start == date(2026, 8, 22, 0, in: sunday))+    }++    // MARK: - Daylight saving (Req 3.3)++    /// The fixture verifies itself: if these two days ever stop being 23 and 25+    /// hours long, the case is no longer about what it claims to be about.+    @Test("A 23-hour and a 25-hour day are each one whole bar")+    func daylightSavingDaysAreWholeDays() {+        let calendar = calendar(timeZone: "America/New_York")+        let springForward = date(2026, 3, 8, 0, in: calendar)+        let fallBack = date(2026, 11, 1, 0, in: calendar)++        // Both sides typed: `#expect` decomposes the comparison, and an integer+        // literal left to default resolves as `Int` against a `TimeInterval`,+        // which reports 82800.0 != 82800 rather than failing to compile.+        #expect(calendar.dateInterval(of: .day, for: springForward)?.duration == TimeInterval(23 * 3600))+        #expect(calendar.dateInterval(of: .day, for: fallBack)?.duration == TimeInterval(25 * 3600))++        let march = graph(+            [+                row(capturedAt: date(2026, 3, 8, 23, 30, in: calendar)),+                row(capturedAt: date(2026, 3, 9, 0, 30, in: calendar)),+            ],+            scope: .period(.thisMonth), calendar: calendar,+            now: date(2026, 3, 20, in: calendar))+        #expect(march.bars.count == 31)+        #expect(march.bars.first(where: { $0.start == springForward })?.count == 1)+        #expect(march.bars.first(where: { $0.start == date(2026, 3, 9, 0, in: calendar) })?.count == 1)++        let november = graph(+            [+                row(capturedAt: date(2026, 11, 1, 0, 30, in: calendar)),+                row(capturedAt: date(2026, 11, 1, 23, 30, in: calendar)),+            ],+            scope: .period(.thisMonth), calendar: calendar,+            now: date(2026, 11, 20, in: calendar))+        #expect(november.bars.count == 30)+        #expect(november.bars.first(where: { $0.start == fallBack })?.count == 2)+    }++    // MARK: - Half-open boundaries (Req 3.4)++    @Test("A capture on a boundary instant belongs to the later interval")+    func boundaryInstantsBelongToTheLaterInterval() {+        let calendar = calendar()+        let weekStart = date(2026, 8, 10, 0, in: calendar)+        let weekEnd = date(2026, 8, 17, 0, in: calendar)++        let result = graph(+            [+                // Exactly the week's opening instant: the first bar's.+                row(capturedAt: weekStart),+                // One second before it: the previous week's, so outside the span.+                row(capturedAt: weekStart.addingTimeInterval(-1)),+                // Exactly the week's closing instant: the *next* week's.+                row(capturedAt: weekEnd),+            ],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.first?.count == 1)+        #expect(result.bars.reduce(0) { $0 + $1.count } == 1)+        #expect(result.totalNotes == 3)+    }++    @Test("A day boundary inside the span sends the capture to the later day")+    func dayBoundariesBelongToTheLaterDay() {+        let calendar = calendar()+        let thursday = date(2026, 8, 13, 0, in: calendar)++        let result = graph(+            [+                row(capturedAt: thursday),+                row(capturedAt: thursday.addingTimeInterval(-1)),+            ],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.first(where: { $0.start == thursday })?.count == 1)+        #expect(+            result.bars.first(where: { $0.start == date(2026, 8, 12, 0, in: calendar) })?.count == 1)+    }++    // MARK: - Undated notes (Req 3.8, Decision 6)++    @Test("Epoch-dated notes count in the total and in nothing else")+    func epochDatedNotesAreCountedButNotPlotted() {+        let calendar = calendar()+        let result = graph(+            [+                row(capturedAt: Date(timeIntervalSince1970: 0)),+                // The predicate is `< 1971`, so an imported pre-1970 date is the+                // same case rather than a note plotted in 1969.+                row(capturedAt: Date(timeIntervalSince1970: -100)),+                row(capturedAt: date(2026, 8, 12, in: calendar)),+            ],+            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++        #expect(result.totalNotes == 3)+        #expect(result.undatedNoteCount == 2)+        // One usable date, so the span is that one month rather than 680 of them.+        #expect(result.bars.count == 1)+        #expect(result.bars.first?.start == date(2026, 8, 1, 0, in: calendar))+        #expect(result.bars.reduce(0) { $0 + $1.count } == 1)+    }++    /// The bug the floor exists for. The schema default is the epoch instant,+    /// but a real library's placeholder rows sit *just after* it, and the+    /// original `<= epoch` predicate let every one of them through — which put a+    /// populated bar in January 1970 and ~680 empty months behind it, the exact+    /// outcome Decision 6 was written to prevent.+    @Test("A capture date anywhere in 1970 is a placeholder, not a capture")+    func nineteenSeventyDatesAreAllUndated() {+        let calendar = calendar()+        let placeholders = [+            Date(timeIntervalSince1970: 0),  // the schema default itself+            Date(timeIntervalSince1970: 5),  // what the real library actually holds+            Date(timeIntervalSince1970: 13_046_400),  // 1970-06-01T00:00:00Z+        ]+        for captured in placeholders {+            let result = graph(+                [row(capturedAt: captured), row(capturedAt: date(2026, 8, 12, in: calendar))],+                scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++            #expect(result.totalNotes == 2, "\(captured)")+            #expect(result.undatedNoteCount == 1, "\(captured)")+            // One usable date, so one month — not a span reaching back to 1970.+            #expect(result.bars.count == 1, "\(captured)")+            #expect(result.bars.first?.start == date(2026, 8, 1, 0, in: calendar), "\(captured)")+        }+    }++    @Test("The first instant of 1971 is a usable capture date")+    func theFloorItselfIsUsable() {+        let calendar = calendar()+        let floor = date(1971, 1, 1, 0, in: calendar)+        #expect(floor == StatsDerivation.usableCaptureFloor)++        let result = graph(+            [row(capturedAt: floor)],+            scope: .month(start: floor), calendar: calendar, now: now(in: calendar))++        #expect(result.undatedNoteCount == 0)+        #expect(result.bars.count == 31)+        #expect(result.bars.first?.count == 1)+    }++    @Test("An undated note does not widen a bounded period's span either")+    func epochDatedNotesDoNotEnterABoundedSpan() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: Date(timeIntervalSince1970: 0))],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.count == 7)+        #expect(result.bars.allSatisfy { $0.count == 0 })+        #expect(result.totalNotes == 1)+        #expect(result.undatedNoteCount == 1)+    }++    // MARK: - All time (Reqs 3.5, 3.6, 4.10)++    @Test("All time runs whole months from the earliest usable date to the latest")+    func allTimeRunsWholeMonths() {+        let calendar = calendar()+        let result = graph(+            [+                row(capturedAt: date(2026, 3, 20, in: calendar)),+                row(capturedAt: date(2026, 5, 4, in: calendar)),+                row(capturedAt: date(2026, 5, 30, in: calendar)),+            ],+            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++        #expect(result.unit == .month)+        #expect(+            result.bars.map(\.start) == [+                date(2026, 3, 1, 0, in: calendar),+                date(2026, 4, 1, 0, in: calendar),+                date(2026, 5, 1, 0, in: calendar),+            ])+        #expect(result.bars.map(\.count) == [1, 0, 2])+        // Req 4.10: every dated note falls inside a bar.+        #expect(result.bars.reduce(0) { $0 + $1.count } == result.totalNotes - result.undatedNoteCount)+    }++    @Test("A capture later than now still widens the span (Q12)")+    func allTimeAbsorbsAFutureCapture() {+        let calendar = calendar()+        let result = graph(+            [+                row(capturedAt: date(2026, 8, 12, in: calendar)),+                row(capturedAt: date(2026, 10, 3, in: calendar)),+            ],+            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.count == 3)+        #expect(result.bars.last?.start == date(2026, 10, 1, 0, in: calendar))+        #expect(result.bars.reduce(0) { $0 + $1.count } == 2)+    }++    // MARK: - Zero buckets and empty spans (Reqs 3.6, 4.6, 4.7)++    @Test("Days inside the span with no notes are emitted as zero-count bars")+    func zeroCountBarsAreEmitted() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: date(2026, 8, 12, in: calendar))],+            scope: .period(.thisMonth), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.count == 31)+        #expect(result.bars.filter { $0.count == 0 }.count == 30)+        #expect(result.bars.first(where: { $0.count > 0 })?.start == date(2026, 8, 12, 0, in: calendar))+    }++    @Test("An empty span and a span of zeros are different results")+    func anEmptySpanIsNotASpanOfZeros() {+        let calendar = calendar()+        // Req 3.6: All time resolves no span at all when nothing carries a+        // usable date, and stays selectable.+        let noSpan = graph(+            [row(capturedAt: Date(timeIntervalSince1970: 0))],+            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))+        // Req 4.6: an empty week is still seven bars.+        let spanOfZeros = graph(+            [], scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(noSpan.bars.isEmpty)+        #expect(spanOfZeros.bars.count == 7)+        #expect(spanOfZeros.bars.allSatisfy { $0.count == 0 })+        #expect(noSpan.bars != spanOfZeros.bars)+    }++    @Test("An entirely empty library resolves no All-time span and no totals")+    func anEmptyLibraryIsTotal() {+        let calendar = calendar()+        let result = graph([], scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.isEmpty)+        #expect(result.totalNotes == 0)+        #expect(result.totalWorks == 0)+        #expect(result.undatedNoteCount == 0)+    }++    // MARK: - Input order (Req 4.5)++    @Test("Rows arriving in last-share order produce chronological bars")+    func lastShareOrderedInputProducesChronologicalBars() {+        let calendar = calendar()+        // Recent's groups are ordered by `lastSharedAt` descending, and the+        // first capture dates deliberately disagree with that order.+        let groups = [+            RecentPresentationGroup(+                day: date(2026, 8, 16, 0, in: calendar),+                rows: [row(capturedAt: date(2026, 8, 11, in: calendar))]),+            RecentPresentationGroup(+                day: date(2026, 8, 15, 0, in: calendar),+                rows: [+                    row(capturedAt: date(2026, 8, 16, in: calendar)),+                    row(capturedAt: date(2026, 8, 10, in: calendar)),+                ]),+            RecentPresentationGroup(+                day: date(2026, 8, 14, 0, in: calendar),+                rows: [row(capturedAt: date(2026, 8, 14, in: calendar))]),+        ]+        let result = StatsDerivation.graph(+            presentation: RecentPresentation(groups: groups, actionableCount: 0),+            works: [], scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.bars.map(\.start) == result.bars.map(\.start).sorted())+        #expect(result.bars.map(\.index) == Array(0..<7))+        #expect(result.bars.map(\.count) == [1, 1, 0, 0, 1, 0, 1])+    }++    // MARK: - Totals (Reqs 2.1, 2.2, 2.4, 2.5)++    @Test("The works total comes from the works array, empty and blank titles included")+    func totalWorksComesFromTheWorksArray() {+        let calendar = calendar()+        let attached = UUID()+        let works = [+            TestFixtures.makeWork(id: attached, displayTitle: "Serial"),+            // Req 2.4: a work holding no notes at all.+            TestFixtures.makeWork(displayTitle: "Never Read"),+            // Q24: a blank display title is still a work the library holds.+            TestFixtures.makeWork(displayTitle: ""),+        ]+        let result = graph(+            [+                row(capturedAt: date(2026, 8, 12, in: calendar), workID: attached, workTitle: "Serial"),+                row(capturedAt: date(2026, 8, 12, in: calendar)),+            ],+            scope: .period(.thisWeek), works: works, calendar: calendar, now: now(in: calendar))++        #expect(result.totalWorks == 3)+        #expect(result.totalNotes == 2)+    }++    @Test("Both totals ignore the selected period (Req 2.5)")+    func totalsAreUnaffectedByThePeriod() {+        let calendar = calendar()+        let works = [TestFixtures.makeWork()]+        let rows = [+            row(capturedAt: date(2026, 8, 12, in: calendar)),+            row(capturedAt: date(2024, 1, 2, in: calendar)),+        ]+        let periods: [StatsPeriod] = [.thisWeek, .lastWeek, .thisMonth, .lastMonth, .allTime]++        for period in periods {+            let result = graph(+                rows, scope: .period(period), works: works,+                calendar: calendar, now: now(in: calendar))+            #expect(result.totalNotes == 2)+            #expect(result.totalWorks == 1)+        }+    }++    // MARK: - The period's own figures (Req 2.10, Q55)++    @Test("Each bounded period reports its own notes and the works they came from")+    func periodFiguresCountTheSpansOwnNotes() {+        let calendar = calendar()+        let serial = UUID()+        let other = UUID()+        let rows = [+            // This week (10–16 August), two notes from two works.+            row(capturedAt: date(2026, 8, 12, in: calendar), workID: serial, workTitle: "Serial"),+            row(capturedAt: date(2026, 8, 14, in: calendar), workID: other, workTitle: "Other"),+            // Last week (3–9 August), two notes from the *same* work.+            row(capturedAt: date(2026, 8, 4, in: calendar), workID: serial, workTitle: "Serial"),+            row(capturedAt: date(2026, 8, 6, in: calendar), workID: serial, workTitle: "Serial"),+            // Last month, one note.+            row(capturedAt: date(2026, 7, 20, in: calendar), workID: other, workTitle: "Other"),+        ]++        let expected: [StatsPeriod: StatsPeriodFigures] = [+            .thisWeek: StatsPeriodFigures(notes: 2, works: 2),+            .lastWeek: StatsPeriodFigures(notes: 2, works: 1),+            // August holds this week's two and last week's two.+            .thisMonth: StatsPeriodFigures(notes: 4, works: 2),+            .lastMonth: StatsPeriodFigures(notes: 1, works: 1),+        ]+        for (period, figures) in expected {+            let result = graph(+                rows, scope: .period(period), calendar: calendar, now: now(in: calendar))+            #expect(result.periodFigures == figures, "\(period)")+        }+    }++    @Test("An opened month is a bounded scope and reports its own figures")+    func anOpenedMonthReportsItsFigures() {+        let calendar = calendar()+        let serial = UUID()+        let result = graph(+            [+                row(capturedAt: date(2026, 6, 2, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2026, 6, 29, in: calendar), workID: serial, workTitle: "Serial"),+                // Outside the month, so outside its figures.+                row(capturedAt: date(2026, 7, 1, in: calendar), workID: UUID(), workTitle: "Other"),+            ],+            scope: .month(start: date(2026, 6, 1, 0, in: calendar)),+            calendar: calendar, now: now(in: calendar))++        #expect(result.periodFigures == StatsPeriodFigures(notes: 2, works: 1))+    }++    @Test("A period holding no notes reports zero of each rather than nothing")+    func anEmptyPeriodReportsZeroes() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: date(2024, 1, 2, in: calendar), workID: UUID(), workTitle: "Serial")],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.periodFigures == StatsPeriodFigures(notes: 0, works: 0))+    }++    @Test("A note attached to no work counts in the notes figure and in no work")+    func anUnattachedNoteCountsInNotesOnly() {+        let calendar = calendar()+        let serial = UUID()+        let result = graph(+            [+                row(capturedAt: date(2026, 8, 12, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2026, 8, 13, in: calendar)),+                // A reference that resolves to no title is still a work here:+                // the figure counts references, not titles (Req 2.10).+                row(capturedAt: date(2026, 8, 14, in: calendar), workID: UUID()),+            ],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.periodFigures == StatsPeriodFigures(notes: 3, works: 2))+    }++    @Test("An undated note is in no period's figures")+    func undatedNotesAreInNoPeriodFigures() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: Date(timeIntervalSince1970: 0), workID: UUID(), workTitle: "Serial")],+            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.totalNotes == 1)+        #expect(result.periodFigures == StatsPeriodFigures(notes: 0, works: 0))+    }++    @Test("All time carries no period figures at all (Q55)")+    func allTimeHasNoPeriodFigures() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: date(2026, 8, 12, in: calendar), workID: UUID(), workTitle: "Serial")],+            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))++        #expect(result.periodFigures == nil)+    }++    @Test("The period's notes figure equals the sum of the period's own bars")+    func periodNotesEqualTheBarSum() {+        let calendar = calendar()+        let rows = (0..<6).map { index in+            row(capturedAt: date(2026, 8, 10 + index, in: calendar), workID: UUID())+        }+        let result = graph(+            rows, scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))++        #expect(result.periodFigures?.notes == result.bars.reduce(0) { $0 + $1.count })+        #expect(result.periodFigures?.works == 6)+    }++    @Test("The graph reports the scope it was asked for")+    func theGraphReportsItsScope() {+        let calendar = calendar()+        let result = graph(+            [], scope: .period(.lastMonth), calendar: calendar, now: now(in: calendar))++        #expect(result.scope == .period(.lastMonth))+    }++    // MARK: - Breakdown categories (Reqs 6.3, 6.4, 6.5, Q22)++    private func breakdown(+        _ rows: [RecentPresentationRow], day: Date, calendar: Calendar+    ) -> StatsBreakdown {+        StatsDerivation.breakdown(+            presentation: presentation(rows), day: day, calendar: calendar)+    }++    @Test("A note falls in one of three categories by what it references, not by its title")+    func theThreeCategories() {+        let calendar = calendar()+        let day = date(2026, 8, 12, in: calendar)+        let serial = UUID()+        let blankTitled = UUID()++        let result = breakdown(+            [+                row(capturedAt: day, workID: serial, workTitle: "The Perfect Run"),+                // Req 6.4: a work reference the page cannot resolve to a title —+                // the blank-titled work the presentation omits (Q24).+                row(capturedAt: day, workID: blankTitled),+                // Req 6.3: no work reference at all.+                row(capturedAt: day),+            ],+            day: day, calendar: calendar)++        #expect(result.total == 3)+        #expect(+            result.rows.map(\.category) == [+                .work(id: serial, title: "The Perfect Run"),+                .unresolvedWork,+                .unattached,+            ])+        #expect(result.rows.allSatisfy { $0.count == 1 })+    }++    /// Q22: `.workMissing` ranks below three site-level causes, so a note whose+    /// site is also missing reports the site cause instead. Reading membership+    /// off `attention` would drop it out of Req 6.4's category and silently+    /// break Req 6.5's sum.+    @Test("An unresolvable work reference stays in its category when a site cause outranks it")+    func aSiteCauseDoesNotMoveTheCategory() {+        let calendar = calendar()+        let day = date(2026, 8, 12, in: calendar)++        let result = breakdown(+            [+                row(capturedAt: day, workID: UUID(), attention: .siteMissing),+                row(capturedAt: day, workID: UUID(), attention: .siteDuplicated),+                row(capturedAt: day, workID: UUID(), attention: .workMissing),+            ],+            day: day, calendar: calendar)++        #expect(result.total == 3)+        #expect(result.rows == [StatsBreakdownRow(category: .unresolvedWork, count: 3)])+    }++    @Test("A settled note whose site is missing is still attributed to its work")+    func aSiteCauseDoesNotUnattachAResolvedWork() {+        let calendar = calendar()+        let day = date(2026, 8, 12, in: calendar)+        let serial = UUID()++        let result = breakdown(+            [row(capturedAt: day, workID: serial, workTitle: "Serial", attention: .siteMissing)],+            day: day, calendar: calendar)++        #expect(result.rows.map(\.category) == [.work(id: serial, title: "Serial")])+    }++    // MARK: - Breakdown order (Reqs 6.1, 6.6, Q9, Q20)++    @Test("Works order by count descending, then title, then identity, with the unresolved pair last")+    func breakdownOrderIsTotal() {+        let calendar = calendar()+        let day = date(2026, 8, 12, in: calendar)+        let zeta = UUID()+        let alpha = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!+        // Same title as `alpha`, and the same count: the unresolved-duplicate+        // case Q9 exists for, where display title alone is not a total order.+        let alphaTwin = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!++        var rows: [RecentPresentationRow] = []+        rows += (0..<3).map { _ in row(capturedAt: day, workID: zeta, workTitle: "Zeta") }+        rows += (0..<2).map { _ in row(capturedAt: day, workID: alpha, workTitle: "Alpha") }+        rows += (0..<2).map { _ in row(capturedAt: day, workID: alphaTwin, workTitle: "Alpha") }+        // Both unresolved categories outnumber every work and still come last.+        rows += (0..<5).map { _ in row(capturedAt: day, workID: UUID()) }+        rows += (0..<4).map { _ in row(capturedAt: day) }++        // Shuffled input, asserted against the stated order. Deriving twice and+        // comparing would prove nothing: `Dictionary` iteration order is+        // randomised per process, not per call, so both derivations in one test+        // see the same order.+        var random = SeededRandom(seed: 20_260_816)+        let result = breakdown(rows.shuffled(using: &random), day: day, calendar: calendar)++        #expect(+            result.rows == [+                StatsBreakdownRow(category: .work(id: zeta, title: "Zeta"), count: 3),+                StatsBreakdownRow(category: .work(id: alpha, title: "Alpha"), count: 2),+                StatsBreakdownRow(category: .work(id: alphaTwin, title: "Alpha"), count: 2),+                StatsBreakdownRow(category: .unresolvedWork, count: 5),+                StatsBreakdownRow(category: .unattached, count: 4),+            ])+    }++    // MARK: - Breakdown totals and the empty day (Reqs 6.5, 6.9)++    @Test("The rows sum to the day's total and hold every counted note once")+    func breakdownRowsSumToTheTotal() {+        let calendar = calendar()+        let day = date(2026, 8, 12, in: calendar)+        let serial = UUID()++        let result = breakdown(+            [+                row(capturedAt: date(2026, 8, 12, 0, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2026, 8, 12, 23, 59, 59, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2026, 8, 12, 9, in: calendar)),+                // Neighbouring days, and a note carrying no usable date at all:+                // none of them belongs to this day (Reqs 3.4, 3.8).+                row(capturedAt: date(2026, 8, 11, 23, 59, 59, in: calendar)),+                row(capturedAt: date(2026, 8, 13, 0, in: calendar)),+                row(capturedAt: Date(timeIntervalSince1970: 0)),+            ],+            day: day, calendar: calendar)++        #expect(result.total == 3)+        #expect(result.rows.reduce(0) { $0 + $1.count } == result.total)+        #expect(Set(result.rows.map(\.category)).count == result.rows.count)+    }++    @Test("A day holding no notes reports a zero total and no rows")+    func anEmptyDayHasNoRows() {+        let calendar = calendar()+        let result = breakdown(+            [row(capturedAt: date(2026, 8, 12, in: calendar))],+            day: date(2026, 8, 13, in: calendar), calendar: calendar)++        #expect(result.total == 0)+        #expect(result.rows.isEmpty)+    }++    @Test("The breakdown names the day it describes, normalised to its start")+    func theBreakdownNamesItsDay() {+        let calendar = calendar()+        let result = breakdown(+            [], day: date(2026, 8, 12, 17, 42, in: calendar), calendar: calendar)++        #expect(result.day == date(2026, 8, 12, 0, in: calendar))+    }+}++// Seeded fuzzing over a small fixture space, not exhaustive proof. Each seed+// builds a whole library — dated and undated notes, attached, unattached and+// unresolvable ones, arriving in last-share order — and every case asserts a+// property that has to hold whatever came out of the generator.+@Suite("Stats derivation invariants")+struct StatsDerivationInvariantTests {++    /// Arbitrary, fixed, and re-runnable: a failing seed is a number that can be+    /// pasted straight back into this list.+    static let seeds: [UInt64] = [1, 7, 42, 1_000_003, 20_260_816, 0xDEAD_BEEF]++    // MARK: - The fixture++    private struct SeededLibrary {+        let presentation: RecentPresentation+        let works: [WorkSnapshot]+        let calendar: Calendar+        let now: Date+        /// Every usable capture date in the library, in no particular order.+        let captureDates: [Date]+        let totalNotes: Int+    }++    private func library(seed: UInt64) -> SeededLibrary {+        var random = SeededRandom(seed: seed)++        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        calendar.firstWeekday = Int.random(in: 1...2, using: &random)+        let now = calendar.date(+            from: DateComponents(year: 2026, month: 8, day: 16, hour: 12))!++        let works = (0..<4).map { index in+            TestFixtures.makeWork(displayTitle: "Serial \(index)")+        }++        var rows: [RecentPresentationRow] = []+        var captureDates: [Date] = []+        for _ in 0..<Int.random(in: 12...60, using: &random) {+            let captured: Date+            if Int.random(in: 0..<8, using: &random) == 0 {+                // Decision 6's unhydrated row: counted, never plotted.+                captured = Date(timeIntervalSince1970: 0)+            } else {+                let daysBack = Int.random(in: 0..<420, using: &random)+                let secondsIntoDay = Int.random(in: 0..<86_400, using: &random)+                captured = calendar.date(byAdding: .day, value: -daysBack, to: now)!+                    .addingTimeInterval(TimeInterval(secondsIntoDay) - 43_200)+                captureDates.append(captured)+            }++            let workID: UUID?+            let workTitle: String?+            switch Int.random(in: 0..<4, using: &random) {+            case 0:+                workID = nil+                workTitle = nil+            case 1:+                // A reference that resolves to no title (Req 6.4).+                workID = UUID()+                workTitle = nil+            default:+                let work = works[Int.random(in: 0..<works.count, using: &random)]+                workID = work.id+                workTitle = work.displayTitle+            }++            rows.append(+                TestFixtures.makeRecentRow(+                    workDisplayTitle: workTitle,+                    lastSharedAt: now.addingTimeInterval(+                        -TimeInterval(Int.random(in: 0..<36_000_000, using: &random))),+                    workID: workID,+                    firstCapturedAt: captured,+                    // The attention state is deliberately arbitrary: nothing in+                    // the derivation may read it (Q22).+                    attention: Int.random(in: 0..<3, using: &random) == 0 ? .siteMissing : nil))+        }++        // The shape Recent publishes: day groups in `lastSharedAt` descending+        // order, so the input never arrives in capture order.+        let sorted = rows.sorted { $0.lastSharedAt > $1.lastSharedAt }+        var groups: [RecentPresentationGroup] = []+        for row in sorted {+            let day = calendar.startOfDay(for: row.lastSharedAt)+            if groups.last?.day == day {+                let last = groups.removeLast()+                groups.append(RecentPresentationGroup(day: day, rows: last.rows + [row]))+            } else {+                groups.append(RecentPresentationGroup(day: day, rows: [row]))+            }+        }++        return SeededLibrary(+            presentation: RecentPresentation(groups: groups, actionableCount: 0),+            works: works,+            calendar: calendar,+            now: now,+            captureDates: captureDates,+            totalNotes: rows.count)+    }++    private func graph(_ library: SeededLibrary, scope: StatsScope, now: Date? = nil) -> StatsGraph {+        StatsDerivation.graph(+            presentation: library.presentation, works: library.works, scope: scope,+            calendar: library.calendar, now: now ?? library.now)+    }++    /// The bucket a bar covers: `[start, next start)`, and the final bar runs to+    /// the end of its own calendar unit.+    private func upperBound(+        of bar: StatsBar, in graph: StatsGraph, calendar: Calendar+    ) -> Date {+        if let next = graph.bars.first(where: { $0.index == bar.index + 1 }) { return next.start }+        let component: Calendar.Component = graph.unit == .day ? .day : .month+        return calendar.dateInterval(of: component, for: bar.start)!.end+    }++    // MARK: - Bucketing (Reqs 3.4, 4.5, 4.6, 4.10)++    @Test("Every dated note lands in exactly one All-time bucket", arguments: seeds)+    func everyDatedNoteLandsInOneBucket(seed: UInt64) {+        let library = library(seed: seed)+        let result = graph(library, scope: .period(.allTime))++        for captured in library.captureDates {+            let holders = result.bars.filter {+                captured >= $0.start && captured < upperBound(of: $0, in: result, calendar: library.calendar)+            }+            #expect(holders.count == 1)+        }+    }++    @Test("The All-time bars sum to the notes total less the undated ones", arguments: seeds)+    func allTimeBarsSumToTheDatedTotal(seed: UInt64) {+        let library = library(seed: seed)+        let result = graph(library, scope: .period(.allTime))++        #expect(result.totalNotes == library.totalNotes)+        #expect(result.undatedNoteCount == library.totalNotes - library.captureDates.count)+        #expect(+            result.bars.reduce(0) { $0 + $1.count }+                == result.totalNotes - result.undatedNoteCount)+    }++    @Test("Bars are strictly chronological and contiguous in every scope", arguments: seeds)+    func barsAreChronologicalAndContiguous(seed: UInt64) {+        let library = library(seed: seed)+        let scopes: [StatsScope] = StatsPeriod.allCases.map { .period($0) } + [+            .month(start: library.calendar.dateInterval(of: .month, for: library.now)!.start)+        ]++        for scope in scopes {+            let result = graph(library, scope: scope)+            let component: Calendar.Component = result.unit == .day ? .day : .month++            #expect(result.bars.map(\.index) == Array(0..<result.bars.count))+            for (bar, next) in zip(result.bars, result.bars.dropFirst()) {+                #expect(bar.start < next.start)+                // Contiguous, so the span has no hole a note could fall through+                // and no gap the categorical x domain would inherit (Q33).+                #expect(+                    library.calendar.dateInterval(of: component, for: bar.start)?.end == next.start)+            }+        }+    }++    @Test("A bounded period counts exactly the notes inside its span", arguments: seeds)+    func aBoundedPeriodCountsItsOwnSpan(seed: UInt64) {+        let library = library(seed: seed)++        for period in [StatsPeriod.thisWeek, .lastWeek, .thisMonth, .lastMonth] {+            let result = graph(library, scope: .period(period))+            guard let first = result.bars.first, let last = result.bars.last else {+                Issue.record("a bounded period always resolves a span")+                continue+            }+            let spanEnd = upperBound(of: last, in: result, calendar: library.calendar)+            let inside = library.captureDates.filter { $0 >= first.start && $0 < spanEnd }++            #expect(result.bars.reduce(0) { $0 + $1.count } == inside.count)+        }+    }++    // MARK: - Purity++    @Test("Two instants in one day derive the same graph", arguments: seeds)+    func theHourOfDayDoesNotMoveTheGraph(seed: UInt64) {+        let library = library(seed: seed)+        let startOfDay = library.calendar.startOfDay(for: library.now)+        let endOfDay = library.calendar.dateInterval(of: .day, for: library.now)!+            .end.addingTimeInterval(-1)++        for period in StatsPeriod.allCases {+            #expect(+                graph(library, scope: .period(period), now: startOfDay)+                    == graph(library, scope: .period(period), now: endOfDay))+        }+    }++    // MARK: - Breakdown (Reqs 6.5, 6.6)++    /// The stated total order: rank first (Q20), then count descending, then+    /// display title, then work identity (Q9).+    private func isOrdered(_ left: StatsBreakdownRow, _ right: StatsBreakdownRow) -> Bool {+        func rank(_ category: StatsCategory) -> Int {+            switch category {+            case .work: 0+            case .unresolvedWork: 1+            case .unattached: 2+            }+        }+        if rank(left.category) != rank(right.category) {+            return rank(left.category) < rank(right.category)+        }+        if left.count != right.count { return left.count > right.count }+        guard case .work(let leftID, let leftTitle) = left.category,+            case .work(let rightID, let rightTitle) = right.category+        else { return true }+        let titles = leftTitle.localizedStandardCompare(rightTitle)+        if titles != .orderedSame { return titles == .orderedAscending }+        return leftID.uuidString <= rightID.uuidString+    }++    @Test("A day's breakdown sums to its total with each note in one category", arguments: seeds)+    func breakdownRowsSumToTheDayTotal(seed: UInt64) {+        let library = library(seed: seed)++        for day in Set(library.captureDates.map { library.calendar.startOfDay(for: $0) }) {+            let result = StatsDerivation.breakdown(+                presentation: library.presentation, day: day, calendar: library.calendar)+            let expected = library.captureDates.filter {+                library.calendar.startOfDay(for: $0) == day+            }++            #expect(result.day == day)+            #expect(result.total == expected.count)+            #expect(result.rows.reduce(0) { $0 + $1.count } == result.total)+            #expect(Set(result.rows.map(\.category)).count == result.rows.count)+        }+    }++    /// Asserted against **shuffled input** rather than by deriving twice.+    /// `Dictionary` iteration order is randomised per process, not per call, so+    /// two derivations in one test see the same order and a dictionary-order+    /// dependency would pass unnoticed.+    @Test("The breakdown's order holds whatever order the rows arrive in", arguments: seeds)+    func breakdownOrderSurvivesShuffledInput(seed: UInt64) {+        let library = library(seed: seed)+        var random = SeededRandom(seed: seed &* 31)+        let day = library.calendar.startOfDay(for: library.now)+        // The seeded library spreads its captures over more than a year, so a+        // real day of it holds one or two notes and would order trivially. This+        // keeps the generator's category and work distribution and moves every+        // note onto one day, which is what gives the ordering something to say.+        let dense = library.presentation.allRows.map { row in+            TestFixtures.makeRecentRow(+                workDisplayTitle: row.workDisplayTitle,+                workID: row.entry.workID,+                firstCapturedAt: day.addingTimeInterval(+                    TimeInterval(Int.random(in: 0..<86_400, using: &random))),+                attention: row.attention)+        }++        var previous: [StatsBreakdownRow]?+        for _ in 0..<3 {+            let shuffled = RecentPresentation(+                groups: [+                    RecentPresentationGroup(day: day, rows: dense.shuffled(using: &random))+                ],+                actionableCount: 0)+            let result = StatsDerivation.breakdown(+                presentation: shuffled, day: day, calendar: library.calendar)++            #expect(result.total == dense.count)+            #expect(result.rows.count > 1)+            for (left, right) in zip(result.rows, result.rows.dropFirst()) {+                #expect(isOrdered(left, right))+            }+            if let previous { #expect(result.rows == previous) }+            previous = result.rows+        }+    }+}++// The page's whole state machine (Reqs 5.4, 5.5, 6.7, 6.8). It lives in a value+// type rather than in `StatsView.body` because no app-layer test instantiates a+// SwiftUI view, so these rules would otherwise have no test at all (Q41).+@Suite("Stats navigation")+struct StatsNavigationTests {++    private let march = Date(timeIntervalSince1970: 1_772_323_200)  // 2026-03-01+    private let april = Date(timeIntervalSince1970: 1_775_001_600)  // 2026-04-01+    private let day = Date(timeIntervalSince1970: 1_772_496_000)  // 2026-03-03++    @Test("The page opens on This week with no month and no selection (Reqs 3.2, 6.7)")+    func initialState() {+        let navigation = StatsNavigation()++        #expect(navigation.period == .thisWeek)+        #expect(navigation.openedMonth == nil)+        #expect(navigation.selectedDay == nil)+        #expect(navigation.scope == .period(.thisWeek))+    }++    @Test("Changing the period leaves the opened month and clears the selection (Req 5.5)")+    func selectingAPeriodClearsEverythingElse() {+        var navigation = StatsNavigation()+        navigation.select(period: .allTime)+        navigation.open(month: march)+        navigation.toggle(day: day)++        navigation.select(period: .lastMonth)++        #expect(navigation.period == .lastMonth)+        #expect(navigation.openedMonth == nil)+        #expect(navigation.selectedDay == nil)+        #expect(navigation.scope == .period(.lastMonth))+    }++    @Test("Opening a month makes it the scope (Reqs 5.2, 5.3)")+    func openingAMonthSetsTheScope() {+        var navigation = StatsNavigation()+        navigation.select(period: .allTime)++        navigation.open(month: march)++        #expect(navigation.openedMonth == march)+        #expect(navigation.scope == .month(start: march))+    }++    @Test("A second month replaces the first")+    func openingAnotherMonthReplacesIt() {+        var navigation = StatsNavigation()+        navigation.open(month: march)++        navigation.open(month: april)++        #expect(navigation.scope == .month(start: april))+    }++    @Test("The selected period survives the month being open (Req 5.4)")+    func thePeriodSurvivesAnOpenedMonth() {+        var navigation = StatsNavigation()+        navigation.select(period: .allTime)+        navigation.open(month: march)++        // The period control still reads All time while a month is open, and+        // closing the month is the route back to it.+        #expect(navigation.period == .allTime)+        #expect(navigation.scope == .month(start: march))++        navigation.closeMonth()++        #expect(navigation.period == .allTime)+        #expect(navigation.scope == .period(.allTime))+    }++    @Test("Closing a month clears the month and the selection (Req 5.4)")+    func closingAMonthClearsTheSelection() {+        var navigation = StatsNavigation()+        navigation.select(period: .allTime)+        navigation.open(month: march)+        navigation.toggle(day: day)++        navigation.closeMonth()++        #expect(navigation.openedMonth == nil)+        #expect(navigation.selectedDay == nil)+    }++    @Test("Selecting a day sets it, and selecting it again clears it (Req 6.8)")+    func togglingADay() {+        var navigation = StatsNavigation()++        navigation.toggle(day: day)+        #expect(navigation.selectedDay == day)++        navigation.toggle(day: day)+        #expect(navigation.selectedDay == nil)+    }++    @Test("Selecting a different day replaces the selection (Req 6.8)")+    func selectingAnotherDayReplacesIt() {+        var navigation = StatsNavigation()+        navigation.toggle(day: day)++        navigation.toggle(day: march)++        #expect(navigation.selectedDay == march)+    }++    @Test("Clearing the selection leaves the period and the month alone (Req 6.8)")+    func clearingTheSelection() {+        var navigation = StatsNavigation()+        navigation.select(period: .allTime)+        navigation.open(month: march)+        navigation.toggle(day: day)++        navigation.clearSelection()++        #expect(navigation.selectedDay == nil)+        #expect(navigation.period == .allTime)+        #expect(navigation.scope == .month(start: march))+    }++    @Test("Clearing an empty selection changes nothing")+    func clearingNothingIsIdempotent() {+        var navigation = StatsNavigation()+        let before = navigation++        navigation.clearSelection()++        #expect(navigation == before)+    }+}++// The two axes, as arithmetic over the derived bars. They sit here rather than+// in `StatsView` for Q41's reason: no app-layer test instantiates a SwiftUI+// view, so a rule left in one is a rule nothing can check — and Reqs 4.8 and+// 4.9 had no test at all while they lived there.+@Suite("Stats axis arithmetic")+struct StatsAxisArithmeticTests {++    /// A graph is only its bars as far as the axes are concerned, so the counts+    /// are the whole fixture.+    private func graph(_ counts: [Int]) -> StatsGraph {+        let bars = counts.enumerated().map { index, count in+            StatsBar(+                index: index,+                start: Date(timeIntervalSince1970: 1_770_000_000 + Double(index) * 86_400),+                count: count)+        }+        return StatsGraph(+            scope: .period(.thisMonth), unit: .day, bars: bars,+            totalNotes: counts.reduce(0, +), totalWorks: 0, undatedNoteCount: 0,+            periodFigures: nil)+    }++    /// The bar shapes the page actually draws: an unresolved span, a single+    /// month, a flat week, a spiky one, and one tall bar beside a short one.+    private static let shapes: [[Int]] = [+        [], [0], [1], [0, 0, 0, 0, 0, 0, 0], [1, 0, 2], [3, 1, 4, 1, 5],+        [0, 17, 4], [9, 9, 9, 9], [100, 1], Array(repeating: 2, count: 31),+    ]++    // MARK: - The value axis (Req 4.8)++    @Test("The value axis starts at zero whatever the bars hold", arguments: shapes)+    func valueAxisStartsAtZero(counts: [Int]) {+        // Charts otherwise opens the domain at the data's own minimum, so a+        // week of 8, 9 and 10 notes would draw as three bars of wildly+        // different heights.+        #expect(StatsDerivation.valueAxisTicks(graph(counts)).first == 0)+    }++    @Test(+        "The value axis ticks ascend in whole numbers and stay inside the domain",+        arguments: shapes)+    func valueAxisTicksAreWholeAndOrdered(counts: [Int]) {+        let result = graph(counts)+        let upper = StatsDerivation.upperBound(result)+        let ticks = StatsDerivation.valueAxisTicks(result)++        // Whole by type: `[Int]` is what keeps Charts from labelling a count of+        // notes "1.5", which is the defect Req 4.8 names.+        #expect(ticks.count >= 2)+        #expect(ticks.allSatisfy { $0 >= 0 && $0 <= upper })+        for (low, high) in zip(ticks, ticks.dropFirst()) { #expect(low < high) }+        // Four intervals at most, so the axis stays readable at the small+        // maxima this page actually draws.+        #expect(ticks.count <= 5)+    }++    @Test("An all-empty span still has an axis to draw over")+    func emptySpanKeepsAnExtent() {+        // A `0...0` domain has no extent, so Req 4.7's zero-count span — what a+        // fresh library shows — would draw nothing at all.+        #expect(StatsDerivation.upperBound(graph([0, 0, 0])) == 1)+        #expect(StatsDerivation.upperBound(graph([])) == 1)+        #expect(StatsDerivation.valueAxisTicks(graph([0, 0, 0])) == [0, 1])+    }++    @Test("The tallest bar is inside the domain", arguments: shapes)+    func theTallestBarFits(counts: [Int]) {+        #expect(StatsDerivation.upperBound(graph(counts)) >= counts.max() ?? 0)+    }++    // MARK: - The date axis (Req 4.9)++    @Test(+        "The first and last bar of the span are always labelled",+        arguments: [1, 2, 3, 4, 7, 13, 24, 28, 31, 100])+    func dateAxisAlwaysLabelsBothEnds(barCount: Int) {+        let result = graph(Array(repeating: 0, count: barCount))+        let ticks = StatsDerivation.dateAxisTicks(result)++        #expect(ticks.first == result.bars.first)+        #expect(ticks.last == result.bars.last)+        // Each tick is a real bar, named once, in the bars' own order — the+        // axis reads its label off the bar rather than parsing a band name back+        // into an index (Q45).+        #expect(ticks.allSatisfy { result.bars.contains($0) })+        #expect(Set(ticks.map(\.index)).count == ticks.count)+        #expect(ticks.map(\.index) == ticks.map(\.index).sorted())+        #expect(ticks.count <= result.bars.count)+    }++    @Test("A span with no bars has no ticks")+    func anEmptySpanHasNoDateTicks() {+        #expect(StatsDerivation.dateAxisTicks(graph([])).isEmpty)+    }+}++// Q35's split, which is what keeps a day selection from re-bucketing the whole+// library. The rule lives in `StatsInputKey` and was untested while the type+// sat inside `StatsView`.+@Suite("Stats derivation keys")+struct StatsInputKeyTests {++    private let temporal = StatsTemporalKey(+        timeZoneIdentifier: "Europe/Amsterdam", firstWeekday: 2,+        startOfDay: Date(timeIntervalSince1970: 1_770_000_000))+    private let day = Date(timeIntervalSince1970: 1_770_086_400)+    private let otherDay = Date(timeIntervalSince1970: 1_770_172_800)++    private func key(+        generation: Int = 1,+        scope: StatsScope = .period(.thisWeek),+        selectedDay: Date? = nil,+        temporal: StatsTemporalKey? = nil+    ) -> StatsInputKey {+        StatsInputKey(+            generation: generation, scope: scope, selectedDay: selectedDay,+            temporal: temporal ?? self.temporal)+    }++    @Test("Selecting a day moves the breakdown's identity and leaves the graph's alone (Q35)")+    func aDaySelectionDoesNotReBucket() {+        let before = key()+        let after = key(selectedDay: day)++        #expect(before != after)+        #expect(before.graphIdentity == after.graphIdentity)+        #expect(before.breakdownIdentity != after.breakdownIdentity)+    }++    @Test("Moving the selection to another day, and clearing it, also leave the bars alone")+    func movingAndClearingTheSelectionDoNotReBucket() {+        let selected = key(selectedDay: day)++        for next in [key(selectedDay: otherDay), key(selectedDay: nil)] {+            #expect(selected.graphIdentity == next.graphIdentity)+            #expect(selected.breakdownIdentity != next.breakdownIdentity)+        }+    }++    @Test("A scope change moves the graph's identity and leaves the breakdown's alone")+    func aScopeChangeDoesNotReDeriveTheBreakdown() {+        let before = key(scope: .period(.thisWeek), selectedDay: day)+        let after = key(scope: .month(start: day), selectedDay: day)++        #expect(before.graphIdentity != after.graphIdentity)+        #expect(before.breakdownIdentity == after.breakdownIdentity)+    }++    @Test("A republished snapshot re-derives both (Reqs 1.8, 8.2)")+    func aNewGenerationReDerivesBoth() {+        let before = key(generation: 1, selectedDay: day)+        let after = key(generation: 2, selectedDay: day)++        #expect(before.graphIdentity != after.graphIdentity)+        #expect(before.breakdownIdentity != after.breakdownIdentity)+    }++    @Test("A time-zone move re-derives both, shared UTC offset or not (Req 3.7, Q39)")+    func aTemporalChangeReDerivesBoth() {+        let before = key(selectedDay: day)+        // Same day stamp, different week rules — the case Q39 says the stamp+        // alone cannot see.+        let after = key(+            selectedDay: day,+            temporal: StatsTemporalKey(+                timeZoneIdentifier: "Europe/Lisbon", firstWeekday: 1,+                startOfDay: temporal.startOfDay))++        #expect(before.graphIdentity != after.graphIdentity)+        #expect(before.breakdownIdentity != after.breakdownIdentity)+    }++    @Test("An unchanged key derives nothing a second time (Req 8.2)")+    func anUnchangedKeyIsEqual() {+        #expect(key(selectedDay: day) == key(selectedDay: day))+        #expect(key(selectedDay: day).graphIdentity == key(selectedDay: day).graphIdentity)+        #expect(key(selectedDay: day).breakdownIdentity == key(selectedDay: day).breakdownIdentity)+    }+}++// Req 1.5: the page issues no library read of its own. The derivation takes the+// two published snapshots and nothing else, so the way to state that is to run+// everything the screen can ask for against a model built on a recording+// provider, and show the provider never hears from it again.+@Suite("Stats read discipline")+@MainActor+struct StatsReadDisciplineTests {++    @Test("Selecting the tab, every period, a month and every day issue no repository call (Req 1.5)")+    func derivingReadsNothing() async {+        let now = Date(timeIntervalSince1970: 1_786_000_000)+        let calendar = Calendar(identifier: .gregorian)+        let work = TestFixtures.makeWork(displayTitle: "Counted Work")+        let mock = MockLibraryProvider()+        mock.recentPresentationResult = .success(+            RecentPresentation(+                groups: [+                    RecentPresentationGroup(+                        day: now,+                        rows: [+                            TestFixtures.makeRecentRow(+                                workDisplayTitle: work.displayTitle, workID: work.id,+                                firstCapturedAt: now),+                            TestFixtures.makeRecentRow(+                                firstCapturedAt: now.addingTimeInterval(-40 * 86_400)),+                        ])+                ],+                actionableCount: 0))+        mock.worksResult = .success(WorksSnapshot(works: [work], unattachedEntries: []))+        let model = AppLibraryModel(readyRepository: mock)++        await model.refreshAll()++        // The refresh is the app's read, and it has to have happened — without+        // it the assertion below would be true of a provider nobody ever called.+        let reads = mock.callLog+        #expect(reads.contains("recentPresentation"))+        #expect(reads.contains("works"))+        let presentationReads = mock.recentPresentationCallCount+        let worksReads = mock.worksCallCount++        // Everything the screen can ask of the derivation: the tab becoming+        // selected, each of the five periods, a month opened from All time, and+        // every bar of each selected and cleared in turn.+        var navigation = StatsNavigation()+        var scopes: [StatsScope] = StatsPeriod.allCases.map { period in+            navigation.select(period: period)+            return navigation.scope+        }+        navigation.select(period: .allTime)+        navigation.open(month: calendar.dateInterval(of: .month, for: now)!.start)+        scopes.append(navigation.scope)++        for scope in scopes {+            let graph = StatsDerivation.graph(+                presentation: model.recentPresentation, works: model.worksSnapshot.works,+                scope: scope, calendar: calendar, now: now)+            for bar in graph.bars {+                navigation.toggle(day: bar.start)+                guard let day = navigation.selectedDay else { continue }+                _ = StatsDerivation.breakdown(+                    presentation: model.recentPresentation, day: day, calendar: calendar)+                navigation.clearSelection()+            }+        }++        #expect(mock.callLog == reads, "Req 1.5: the page reads the snapshots, never the repository")+        #expect(mock.recentPresentationCallCount == presentationReads)+        #expect(mock.worksCallCount == worksReads)+        #expect(mock.entryCallCount == 0)+        #expect(mock.workCallCount == 0)+        #expect(mock.workDetailCallCount == 0)+        #expect(mock.refreshDiagnosticsCallCount == 0)+    }+}++// Req 2.6's equivalence, against a real store at a temporary root: the two+// totals the page shows are the two numbers the backup-import screen shows.+//+// Every one of these builds the graph through the same `recentPresentation` /+// `worksSnapshot` pair the app publishes. Flattening `WorksSnapshot` into a note+// count looks like the obvious simplification and is wrong (Decision 5) — a note+// whose duplicate rows point at two works appears under both — and this is the+// test that catches it, which it cannot do if the fixture hands the derivation a+// pre-computed pair.+@Suite("Stats totals against a real store")+struct StatsStoreTotalsTests {++    @Test("Both totals equal the store's distinct-identity record counts")+    @MainActor+    func totalsEqualTheRecordCounts() async throws {+        let fixture = try StatsStoreFixture()+        defer { fixture.cleanup() }+        try fixture.seed()++        let counts = try await fixture.repository.recordCounts()+        let result = try await fixture.graph()++        // Six identities across seven rows: the split group is folded, and the+        // duplicate set's two members are two identities, not one.+        #expect(counts.entries == 6)+        #expect(counts.works == 4)+        #expect(result.totalNotes == counts.entries)+        #expect(result.totalWorks == counts.works)++        // The fixture is only a test of Decision 5's sourcing rule if the wrong+        // source gives a different answer, so state that it does: the split+        // group's rows point at two Works, so `WorksSnapshot` lists that one+        // note twice and flattening it overcounts.+        let snapshot = try await fixture.repository.works()+        let flattened = snapshot.works.reduce(0) { $0 + $1.entries.count }+            + snapshot.unattachedEntries.count+        #expect(flattened == 7)+        #expect(flattened != counts.entries)+        // And a works total derived from the rows' assignments undercounts,+        // because a work holding no notes has no row to be derived from.+        let derivedFromRows = Set(+            try await fixture.repository.recentPresentation(calendar: .current)+                .allRows.compactMap { $0.entry.workID }+        ).count+        #expect(derivedFromRows != counts.works)+    }++    /// Req 2.8: the totals track the store rather than holding still. Both+    /// reductions happen without the reader touching the Stats page.+    @Test("Resolving a duplicate set and merging two works each reduce a total")+    @MainActor+    func theTotalsFollowTheStoreDown() async throws {+        let fixture = try StatsStoreFixture()+        defer { fixture.cleanup() }+        try fixture.seed(toleratedShapes: false)++        #expect(try await fixture.graph().totalNotes == 5)+        #expect(try await fixture.graph().totalWorks == 3)++        // Resolving the divergent Entry set deletes the losing identity.+        let workload = try await fixture.repository+            .recentPresentation(calendar: .current).duplicateWorkload+        let setKey = try #require(+            workload.reviewItems.first { $0.recordType == .entry }?.key)+        let contract = try await fixture.repository.projectDuplicateResolution(setKey: setKey)+        let resolution = try await fixture.repository.commitDuplicateResolution(+            contract, choosing: try #require(contract.variantIDs.first),+            appendingOtherNotes: false)+        guard case .committed = resolution else {+            Issue.record("the resolution did not commit: \(resolution)")+            return+        }++        let afterResolution = try await fixture.graph()+        let countsAfterResolution = try await fixture.repository.recordCounts()+        #expect(afterResolution.totalNotes == 4)+        #expect(afterResolution.totalNotes == countsAfterResolution.entries)+        #expect(afterResolution.totalWorks == 3)++        // Merging two works removes exactly one work identity.+        let merge = try await fixture.repository.projectMerge(+            sourceWorkID: fixture.mergeSourceID, targetWorkID: fixture.mergeTargetID)+        let mergeOutcome = try await fixture.repository.commitMerge(merge)+        guard case .committed = mergeOutcome else {+            Issue.record("the merge did not commit: \(mergeOutcome)")+            return+        }++        let afterMerge = try await fixture.graph()+        let countsAfterMerge = try await fixture.repository.recordCounts()+        #expect(afterMerge.totalWorks == 2)+        #expect(afterMerge.totalWorks == countsAfterMerge.works)+        // The notes went with the work rather than with the count.+        #expect(afterMerge.totalNotes == 4)+        #expect(afterMerge.totalNotes == countsAfterMerge.entries)+    }++    @Test("Every note the store holds falls in an All-time bar or in the undated count")+    @MainActor+    func everyStoredNoteIsAccountedFor() async throws {+        let fixture = try StatsStoreFixture()+        defer { fixture.cleanup() }+        try fixture.seed()++        let result = try await fixture.graph()++        #expect(result.undatedNoteCount == 1)+        #expect(+            result.bars.reduce(0) { $0 + $1.count }+                == result.totalNotes - result.undatedNoteCount)+    }+}++/// A real library over a temporary directory, seeded with the shapes the+/// repository's own write paths refuse to produce — the same reason+/// `ToleratedStateFixture` writes underneath the validating commit path.+///+/// Modelled on `OptionalSequenceThroughEditorTests`' `PresenceFixture` for the+/// temp-root half; the rows go in through the container's own context, before+/// the repository exists, because a duplicate set and a split identity group+/// have no ordinary route.+@MainActor+private struct StatsStoreFixture {+    let hostname = "stats.test"+    let directory: URL+    let container: ModelContainer+    let repository: LibraryRepository++    /// The two works Merge collapses into one.+    let mergeSourceID = UUID()+    let mergeTargetID = UUID()++    init() throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismStats-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        let configuration = LibraryConfiguration(rootDirectory: directory)+        try FileManager.default.createDirectory(+            at: configuration.storeURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)+        container = try LibraryRepository.openContainer(at: configuration.storeURL)+        repository = LibraryRepository.makeRepository(+            configuration, container, .m4, FixedStatsClock(), ModelContextSaveStrategy())+    }++    func cleanup() { try? FileManager.default.removeItem(at: directory) }++    /// Seven Entry rows over six identities, and four Work identities.+    ///+    /// `toleratedShapes` covers the two states the store can hold and the+    /// validating write path refuses to leave in place: a blank-titled Work,+    /// which makes every later commit report "Invalid Work tuple … display title+    /// is blank", and a split identity group whose rows disagree about their+    /// Work. The totals cases want both (Req 2.4, Q24, and the flattening trap);+    /// the case that goes on to resolve and merge can have neither.+    func seed(toleratedShapes: Bool = true) throws {+        let context = container.mainContext+        let site = Site(hostname: hostname)+        site.mode = .untaught+        context.insert(site)++        // A duplicate set: two *distinct* identities the library judges to be+        // the same thing, divergent because their notes disagree.+        context.insert(entry(title: "Twinned", path: "shared", day: 3, note: "this device"))+        context.insert(entry(title: "Twinned", path: "shared", day: 4, note: "the other one"))++        // A split entry group: two rows, one application UUID, one logical+        // record, which both the store's count and the presentation fold to one.+        let split = UUID()+        let splitRows = (0..<2).map { _ in+            entry(id: split, title: "Split", path: "split", day: 5, note: "same")+        }+        splitRows.forEach { context.insert($0) }++        let target = work(id: mergeTargetID, title: "Kept Serial")+        let source = work(id: mergeSourceID, title: "Folded Serial")+        context.insert(target)+        context.insert(source)++        // Req 2.4: a work holding no notes at all.+        context.insert(work(id: UUID(), title: "Never Read"))+        // Q24: a blank display title is still a work the library holds, and its+        // notes land in Req 6.4's category because the presentation omits the+        // title it has none of.+        if toleratedShapes {+            // Decision 5's trap, made reachable: the split group's two rows+            // point at two different Works, so `WorksSnapshot` lists the one+            // note under both. Flattening it into a note count is the obvious+            // simplification and is wrong; the presentation has already+            // collapsed the group to its carrier's assignment.+            splitRows[0].work = target+            splitRows[0].workAssignmentProvenance = .manual+            splitRows[1].work = source+            splitRows[1].workAssignmentProvenance = .manual++            let blank = work(id: UUID(), title: "")+            context.insert(blank)+            let blankEntry = entry(title: "Untitled Work Chapter", path: "blank-1", day: 6)+            context.insert(blankEntry)+            blankEntry.work = blank+            blankEntry.workAssignmentProvenance = .manual+        }++        let keptEntry = entry(title: "Kept Chapter", path: "kept-1", day: 7)+        context.insert(keptEntry)+        keptEntry.work = target+        keptEntry.workAssignmentProvenance = .manual+        // Decision 6's unhydrated row: it counts in the notes total and appears+        // in no bar.+        let foldedEntry = entry(+            title: "Folded Chapter", path: "folded-1", day: 0,+            capturedAt: Date(timeIntervalSince1970: 0))+        context.insert(foldedEntry)+        foldedEntry.work = source+        foldedEntry.workAssignmentProvenance = .manual++        try context.save()+    }++    /// Built through the two published snapshots, exactly as `StatsView` does.+    func graph(scope: StatsScope = .period(.allTime)) async throws -> StatsGraph {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        return StatsDerivation.graph(+            presentation: try await repository.recentPresentation(calendar: calendar),+            works: try await repository.works().works,+            scope: scope,+            calendar: calendar,+            now: Date(timeIntervalSince1970: 1_800_000_000))+    }++    private func entry(+        id: UUID = UUID(), title: String, path: String, day: Int, note: String = "",+        capturedAt: Date? = nil+    ) -> Entry {+        let rawURL = "https://\(hostname)/\(path)"+        let entry = Entry(+            id: id,+            captureTitle: title,+            captureTitleSource: .host,+            rawURLString: rawURL,+            hostname: hostname,+            entryIdentityKey: rawURL,+            timestamp: Date(timeIntervalSince1970: 1_780_000_000 + TimeInterval(day) * 86_400),+            note: note)+        // The conservative alias is what the scan buckets a duplicate set on.+        entry.conservativeIdentityKey = rawURL+        if let capturedAt { entry.firstCapturedAt = capturedAt }+        return entry+    }++    private func work(id: UUID, title: String) -> Work {+        Work(+            id: id, displayTitle: title, siteHostname: hostname,+            timestamp: Date(timeIntervalSince1970: 1_780_000_000))+    }+}++private final class FixedStatsClock: RepositoryClock, @unchecked Sendable {+    private let value = Date(timeIntervalSince1970: 1_800_000_000)+    func now() -> Date { MillisecondInstant.quantize(value) }+}++/// A seeded PRNG, so a failing case is a number anybody can re-run.+/// `SystemRandomNumberGenerator` cannot be seeded and would make these tests+/// report a different library on every run.+///+/// Copied from `AsterismCoreTests/DuplicatePropertyTests.swift:508` rather than+/// invented, and declared `nonisolated` so the `RandomNumberGenerator`+/// conformance is legal under the app target's default main-actor isolation.+nonisolated struct SeededRandom: RandomNumberGenerator {+    private var state: UInt64++    init(seed: UInt64) { state = seed &+ 0x9E37_79B9_7F4A_7C15 }++    mutating func next() -> UInt64 {+        state = state &+ 0x9E37_79B9_7F4A_7C15+        var z = state+        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9+        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB+        return z ^ (z >> 31)+    }+}
Asterism/AsterismTests/AppLibraryModelTests.swift Modified +135
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex f6e3b00..9492fe7 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -693,6 +693,141 @@ struct AppLibraryModelSyncArrivalTests {     } } +// MARK: - Atomic snapshot publication (stats-page Reqs 1.4, 1.7; Q37, Q29)++/// `refreshAll()` publishes both snapshots and the generation counter together.+///+/// The counter is what tells "no refresh has completed" apart from "the library+/// is empty" (Req 1.7, Q29): both snapshots initialise empty, so an unread+/// library and an empty one carry the same values and nothing else distinguishes+/// them. And Req 1.4 promises the *most recent completed cycle*, which only+/// holds if a failed read leaves the previous cycle standing whole — a new+/// presentation published beside the previous works snapshot is a pair that+/// never existed in the store, and it would carry no bump to say so.+@Suite("AppLibraryModel snapshot publication")+@MainActor+struct AppLibraryModelSnapshotPublicationTests {++    /// A works snapshot distinguishable from the empty default.+    private static func seededWorks(title: String) -> WorksSnapshot {+        WorksSnapshot(+            works: [TestFixtures.makeWork(displayTitle: title)], unattachedEntries: [])+    }++    @Test("No refresh has completed before the first one runs (Req 1.7, Q29)")+    func generationStartsAtZero() {+        let model = AppLibraryModel(readyRepository: MockLibraryProvider())++        // The snapshots are empty here and empty for an empty library; the+        // counter is the only thing that separates the two states.+        #expect(model.snapshotGeneration == 0)+        #expect(model.recentPresentation.groups.isEmpty)+        #expect(model.worksSnapshot.works.isEmpty)+    }++    @Test("A completed refresh bumps the generation exactly once")+    func completedRefreshBumpsOnce() async {+        let mock = MockLibraryProvider()+        let model = AppLibraryModel(readyRepository: mock)++        await model.refreshAll()++        #expect(model.snapshotGeneration == 1)++        // One cycle, one bump — not one per published property.+        await model.refreshAll()++        #expect(model.snapshotGeneration == 2)+    }++    @Test("A model with no repository publishes nothing and bumps nothing")+    func refreshWithoutARepositoryDoesNotBump() async {+        let root = FileManager.default.temporaryDirectory+            .appending(path: "asterism-generation-\(UUID())")+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))++        await model.refreshAll()++        #expect(model.snapshotGeneration == 0)+    }++    @Test("A refresh whose works() read throws leaves both snapshots and the generation unchanged (Req 1.4, Q37)")+    func aThrowingWorksReadPublishesNothing() async {+        let mock = MockLibraryProvider()+        let firstPresentation = RecentPresentation(groups: [], actionableCount: 3)+        let firstWorks = Self.seededWorks(title: "Published Work")+        mock.recentPresentationResult = .success(firstPresentation)+        mock.worksResult = .success(firstWorks)+        let model = AppLibraryModel(readyRepository: mock)+        await model.refreshAll()+        #expect(model.snapshotGeneration == 1)++        // The next cycle reads a *different* presentation and then fails on+        // works(). Staging is what keeps the two halves together: without it the+        // presentation is already assigned when the throw lands, so the page+        // would read the new presentation beside the old works snapshot under a+        // generation that never changed.+        mock.recentPresentationResult = .success(+            RecentPresentation(groups: [], actionableCount: 9))+        mock.worksResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("works read failed"))++        await model.refreshAll()++        #expect(mock.recentPresentationCallCount == 2, "the read is attempted; only the publish is withheld")+        #expect(model.recentPresentation == firstPresentation)+        #expect(model.worksSnapshot == firstWorks)+        #expect(model.snapshotGeneration == 1)+    }++    @Test("A refresh whose recentPresentation() read throws leaves the previous cycle standing (Req 1.4)")+    func aThrowingPresentationReadPublishesNothing() async {+        let mock = MockLibraryProvider()+        let firstWorks = Self.seededWorks(title: "Published Work")+        mock.worksResult = .success(firstWorks)+        let model = AppLibraryModel(readyRepository: mock)+        await model.refreshAll()++        mock.recentPresentationResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("presentation read failed"))+        mock.worksResult = .success(Self.seededWorks(title: "Later Work"))++        await model.refreshAll()++        #expect(model.worksSnapshot == firstWorks)+        #expect(model.snapshotGeneration == 1)+    }++    @Test("The legacy grouped snapshot is published with the same cycle")+    func recentGroupsPublishWithTheCycle() async {+        let mock = MockLibraryProvider()+        let day = Date(timeIntervalSince1970: 1_750_000_000)+        mock.recentPresentationResult = .success(+            RecentPresentation(+                groups: [+                    RecentPresentationGroup(day: day, rows: [TestFixtures.makeRecentRow()])+                ],+                actionableCount: 0))+        let model = AppLibraryModel(readyRepository: mock)++        await model.refreshAll()+        #expect(model.recentGroups.count == 1)++        // A failed cycle leaves the grouped snapshot on the last completed one+        // too — it is derived from the same presentation, so it must not be+        // published without it.+        mock.recentPresentationResult = .success(+            RecentPresentation(groups: [], actionableCount: 0))+        mock.worksResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("works read failed"))++        await model.refreshAll()++        #expect(model.recentGroups.count == 1)+        #expect(model.snapshotGeneration == 1)+    }+}+ // MARK: - Test helpers  private struct FailingLocator: SharedContainerLocating {
Asterism/AsterismTests/Helpers/TestFixtures.swift Modified +19 / -3
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftindex 5c245d9..6a471f9 100644--- a/Asterism/AsterismTests/Helpers/TestFixtures.swift+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -15,6 +15,11 @@ enum TestFixtures {         rating: Rating? = nil,         lastSharedAt: Date = fixedDate,         modifiedAt: Date = fixedDate,+        /// When the library first saw this note. Separate from `lastSharedAt`+        /// because the Stats graph plots one and Recent orders by the other+        /// (`stats-page` Decision 1), so a fixture that conflated them could not+        /// state a re-share at all.+        firstCapturedAt: Date = fixedDate,         workID: UUID? = nil,         chapterTitle: String? = nil,         chapterSequence: String? = nil,@@ -36,7 +41,7 @@ enum TestFixtures {             chapterTitleProvenance: try! FieldProvenance(kind: .none),             note: note,             rating: rating,-            firstCapturedAt: fixedDate,+            firstCapturedAt: firstCapturedAt,             lastSharedAt: lastSharedAt,             modifiedAt: modifiedAt,             workID: workID,@@ -61,13 +66,23 @@ enum TestFixtures {         lastSharedAt: Date = fixedDate,         /// What the duplicate banner's filter keys on (Req 9.2). Nil is the         /// ordinary row: in no published set, so the filter drops it.-        duplicateRoute: DuplicateResolutionRoute? = nil+        duplicateRoute: DuplicateResolutionRoute? = nil,+        /// The Work this note is assigned to. Nil with a nil `workDisplayTitle`+        /// is an unattached note; set with a nil title is the reference that+        /// resolves to nothing (`stats-page` Req 6.4).+        workID: UUID? = nil,+        firstCapturedAt: Date = fixedDate,+        /// Why the row needs attention. Left nil for the settled row every+        /// existing suite wants; stated where a case is *about* the attention+        /// ranking (`stats-page` Q22).+        attention: RecentRowAttention? = nil     ) -> RecentPresentationRow {         RecentPresentationRow(             id: id,             entry: makeEntry(                 id: id, captureTitle: captureTitle, hostname: hostname, note: note,-                rating: rating, lastSharedAt: lastSharedAt),+                rating: rating, lastSharedAt: lastSharedAt,+                firstCapturedAt: firstCapturedAt, workID: workID),             captureTitle: captureTitle,             hostname: hostname,             workDisplayTitle: workDisplayTitle,@@ -79,6 +94,7 @@ enum TestFixtures {             note: note,             rating: rating,             lastSharedAt: lastSharedAt,+            attention: attention,             duplicateRoute: duplicateRoute         )     }
Asterism/AsterismUITests/StatsUITests.swift Added +191
diff --git a/Asterism/AsterismUITests/StatsUITests.swift b/Asterism/AsterismUITests/StatsUITests.swiftnew file mode 100644index 0000000..bca934b--- /dev/null+++ b/Asterism/AsterismUITests/StatsUITests.swift@@ -0,0 +1,254 @@+import XCTest++/// The Stats tab (`specs/stats-page/`), driven from launch through real+/// navigation.+///+/// Two things here are new coverage rather than an amendment. No existing test+/// asserts the tab bar's *structure* — every one of them reaches a single tab by+/// label and none counts them. And the chart is reachable only because it is+/// given real controls: `ChartContent` exposes no per-mark action, so the bars+/// this suite taps are `Button`s laid over the plot, one per band (Q46). Since+/// Q56 that holds for every graph including All time — no graph scrolls, so+/// nothing needs the parallel accessibility tree Q47 kept, and the control a+/// test taps is the control the reader taps.+///+/// **The tab identifiers cannot be asserted, and this suite is what established+/// that.** Decision 2 suspected `tab-recent` / `tab-works` were inert; they are.+/// Measured on iOS 26: an `.accessibilityIdentifier` on a `SwiftUI.Tab` never+/// reaches the tab-bar button — the button exposes its label and its symbol+/// image and no identifier at all, and moving the identifier onto a custom+/// `label:` view does not change it. The three tabs are therefore asserted by+/// label, which is what the accessibility tree actually publishes.+final class StatsUITests: XCTestCase {+    private var app: XCUIApplication!++    override func setUp() {+        continueAfterFailure = false+        XCUIDevice.shared.orientation = .portrait+        app = XCUIApplication()+        terminateAndWaitForExit(app)+    }++    override func tearDown() {+        if let app {+            terminateAndWaitForExit(app)+        }+        app = nil+    }++    /// Reqs 1.1, 1.2: three tabs, Stats beside Recent and Works.+    @MainActor+    func testTabBarCarriesRecentWorksAndStats() {+        launchSeeded()+        XCTAssertTrue(+            app.collectionViews["recent-list"].waitForExistence(timeout: 30),+            "Seeded Recent list should load")++        let tabBar = app.tabBars.firstMatch+        XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "The tab bar should exist")+        for name in ["Recent", "Works", "Stats"] {+            let tab = tabBar.buttons[name]+            XCTAssertTrue(tab.waitForExistence(timeout: 10), "\(name) should be in the tab bar")+            XCTAssertTrue(tab.isHittable, "\(name) should be reachable")+        }+        XCTAssertEqual(tabBar.buttons.count, 3, "Req 1.1: Stats joins Recent and Works, not a fourth")+    }++    /// Reqs 5.1, 6.1, 6.2 and 6.10 end to end: a day is selected through the+    /// graph, the breakdown names the work its notes came from, and the row+    /// hands off to the Works tab with that work open.+    @MainActor+    func testSelectingADayBreaksItDownAndRoutesToWorks() {+        launchSeeded()+        assignSeededEntryToItsWork()++        openStats()++        // The seeded library holds exactly one note, captured now, so exactly+        // one bar of This week carries a count. Found by its spoken value+        // rather than by index, because which weekday today is is not the+        // suite's business.+        let bar = app.buttons+            .matching(NSPredicate(format: "identifier BEGINSWITH 'stats-bar-' AND value == '1 note'"))+            .firstMatch+        XCTAssertTrue(bar.waitForExistence(timeout: 15), "Today's bar should be reachable and speak its value")+        XCTAssertFalse(bar.label.isEmpty, "Req 4.9: a bar states its date in its label")+        bar.tap()++        XCTAssertTrue(+            app.staticTexts["stats-breakdown-day"].waitForExistence(timeout: 10),+            "Req 6.2: the breakdown names the selected day")+        XCTAssertEqual(+            app.staticTexts["stats-breakdown-total"].label, "1 note",+            "Req 6.2: the breakdown states the day's total, with the noun agreeing")++        let workRow = app.buttons["stats-breakdown-work-row"]+        XCTAssertTrue(workRow.waitForExistence(timeout: 10), "Req 6.1: the work the note belongs to is listed")+        XCTAssertEqual(workRow.value as? String, "1 note")+        workRow.tap()++        // Req 6.10: the Works tab, with that work open — the work detail+        // screen's own toolbar is what says the push landed.+        XCTAssertTrue(+            app.buttons["work-detail-edit-button"].waitForExistence(timeout: 15),+            "Req 6.10: the breakdown row opens the work in the Works tab")+    }++    /// Reqs 6.7, 6.8: the page opens with nothing selected, and selecting the+    /// already-selected bar clears the breakdown again.+    @MainActor+    func testTheBreakdownOpensClosedAndTogglesOnTheSameBar() {+        launchSeeded()+        openStats()++        let bar = app.buttons+            .matching(NSPredicate(format: "identifier BEGINSWITH 'stats-bar-' AND value == '1 note'"))+            .firstMatch+        XCTAssertTrue(bar.waitForExistence(timeout: 15))+        XCTAssertFalse(+            app.anyElement("stats-breakdown-day").exists,+            "Req 6.7: the page opens with no day selected")++        bar.tap()+        XCTAssertTrue(app.staticTexts["stats-breakdown-day"].waitForExistence(timeout: 10))++        bar.tap()+        waitUntilGone(+            app.staticTexts["stats-breakdown-day"],+            "Req 6.8: selecting the already-selected bar clears the breakdown",+            timeout: 10)+    }++    /// Reqs 5.2, 5.3, 5.4 and Q30: an All-time bar opens its month as a pushed+    /// screen, and the way out of that screen returns to All time.+    @MainActor+    func testAnAllTimeBarOpensItsMonthAndBackReturnsToAllTime() {+        launchSeeded()+        openStats()++        let period = app.buttons["stats-period-menu"]+        period.tap()+        let allTime = app.buttons["All time"]+        XCTAssertTrue(allTime.waitForExistence(timeout: 10), "Req 3.1 offers All time")+        allTime.tap()++        // The seeded library's captures are all from today, so All time is one+        // month — one band covering the plot.+        let band = app.buttons["stats-bar-0"]+        XCTAssertTrue(band.waitForExistence(timeout: 15), "All time should draw its month")+        band.tap()++        XCTAssertTrue(+            app.anyElement("stats-month-screen").waitForExistence(timeout: 10),+            "Req 5.2: an All-time bar opens its month rather than selecting")+        XCTAssertFalse(+            app.anyElement("stats-breakdown-day").exists,+            "Req 5.2: All time itself shows no breakdown")++        // Req 5.4's route out is the pushed screen's own back affordance.+        app.navigationBars.buttons.element(boundBy: 0).tap()+        waitUntilGone(+            app.anyElement("stats-month-screen"), "Back should leave the month", timeout: 10)+        XCTAssertEqual(+            period.label, "Period, All time",+            "Req 5.4: the route out of a month returns to All time")+    }++    /// Every seeded scenario carries one reachability check that is **not**+    /// device-gated, so a seeder that throws at launch fails `make test-ui`+    /// rather than showing up as a `waitForExistence` timeout in a suite nobody+    /// runs (`docs/agent-notes/testing.md`, T-1947).+    @MainActor+    func testSpanningMonthsScenarioReachesRecent() {+        launchSpanningMonths()+        XCTAssertTrue(+            app.collectionViews["recent-list"].waitForExistence(timeout: 30),+            "The spanning-months fixture should seed and reach Recent")+    }++    /// Q49, confirmed on device and now here: an All-time graph whose span runs+    /// past the visible band limit must still open the month its band *names*.+    ///+    /// The seeded library spans 31 months, so the last band sits well outside+    /// the first 24-band viewport. That is the whole point of the fixture —+    /// before it, no library in this repository spanned more than one month, so+    /// this path had never been exercised at all.+    @MainActor+    func testAnAllTimeBandBeyondTheFirstViewportOpensTheMonthItNames() {+        launchSpanningMonths()+        openStats()+        selectAllTime()++        // Band 0 is the earliest month of the span; band 30 is the current one.+        let firstBand = app.buttons["stats-bar-0"]+        XCTAssertTrue(firstBand.waitForExistence(timeout: 20), "All time should draw its bands")+        let lastBand = app.buttons["stats-bar-30"]+        XCTAssertTrue(+            lastBand.waitForExistence(timeout: 20),+            "Req 7.7: every band of a 31-month span is reachable, viewport or not")+        XCTAssertNotEqual(+            firstBand.label, lastBand.label,+            "The first and last band of a 31-month span name different months")++        let named = lastBand.label+        XCTAssertTrue(lastBand.isHittable, "A band the reader can see is a band they can activate")+        lastBand.tap()++        XCTAssertTrue(+            app.anyElement("stats-month-screen").waitForExistence(timeout: 10),+            "Req 5.2: an All-time band opens its month")+        XCTAssertTrue(+            app.navigationBars[named].waitForExistence(timeout: 10),+            "The opened month must be the one the band names (\(named)), not another band's "+                + "— opened \(app.navigationBars.element(boundBy: 0).identifier)")+    }++    // MARK: - Helpers++    private func selectAllTime() {+        app.buttons["stats-period-menu"].tap()+        let allTime = app.buttons["All time"]+        XCTAssertTrue(allTime.waitForExistence(timeout: 10), "Req 3.1 offers All time")+        allTime.tap()+    }++    /// The m1 fixture captures through `capture(_:)`, which applies no rules and+    /// assigns no Work — so the seeded note is unattached until the Move-to+    /// route puts it in the seeded Work. Req 6.10 is about a row that *has* a+    /// work, so the journey has to make one first.+    private func assignSeededEntryToItsWork() {+        let chapter = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+        XCTAssertTrue(chapter.waitForExistence(timeout: 30), "Seeded Recent row should load")+        chapter.tap()++        scrollUntilTappableAndTap(+            app.buttons["entry-detail-move-to-button"], in: app, "Move to should be reachable")+        let destination = app.buttons.matching(identifier: "move-to-work-destination").firstMatch+        XCTAssertTrue(destination.waitForExistence(timeout: 10), "A same-host Work destination should be offered")+        destination.tap()+        waitUntilGone(+            app.navigationBars["Move to"], "Move-to sheet should dismiss after assignment", timeout: 15)+    }++    private func openStats() {+        let stats = app.tabBars.buttons["Stats"]+        XCTAssertTrue(stats.waitForExistence(timeout: 30), "Stats tab should exist")+        stats.tap()+        XCTAssertTrue(+            app.anyElement("stats-total-notes").waitForExistence(timeout: 15),+            "Stats should render its lifetime totals")+    }++    /// The 31-month library. Its captures are dated by calendar arithmetic from+    /// launch, so the span is 31 bands whatever day the suite runs on.+    private func launchSpanningMonths() {+        launchSeeded(scenario: "seeded-spanning-months")+    }++    private func launchSeeded(scenario: String = "seeded-m1", extraArguments: [String] = []) {+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launchArguments += extraArguments+        app.launch()+    }+}
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Modified +48
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 7ae6637..0bd4dc7 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -164,6 +164,9 @@ final class AccessibilityJourneyUITests: XCTestCase {                 "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge",             ]         )+        walkStatsAtLargestDynamicType()++        app.tabBars.buttons["Recent"].tap()         openSeededEntry()          let moveButton = app.buttons["entry-detail-move-to-button"]@@ -250,6 +253,55 @@ final class AccessibilityJourneyUITests: XCTestCase {         assertSystemControl(update, named: "Update under Reduce Transparency")     } +    /// `specs/stats-page/` Req 7.8, at the largest accessibility Dynamic Type+    /// size: neither total truncated, and the period control neither clipping+    /// its labels nor overlapping the graph.+    ///+    /// What XCUI can and cannot say here. A label is reported in full whether or+    /// not it is visually truncated, so "not truncated" is asserted as the two+    /// things that would make it truncate: the tile has to hold the whole+    /// sentence, and it has to sit inside the window rather than running off its+    /// edge. "Does not overlap the graph" is asserted as the control finishing+    /// above the topmost band control — the bands are the graph's own geometry,+    /// laid out by the chart's scale, so nothing between the two can have been+    /// pushed over them either.+    private func walkStatsAtLargestDynamicType() {+        let stats = app.tabBars.buttons["Stats"]+        XCTAssertTrue(stats.waitForExistence(timeout: 30), "Stats tab should exist")+        stats.tap()++        let window = app.windows.firstMatch+        // Req 2.10 put two more figures in this header, so all four are walked:+        // four numbers sharing one header is exactly the case Req 7.8 is about.+        for identifier in [+            "stats-total-notes", "stats-total-works", "stats-period-notes", "stats-period-works",+        ] {+            let total = app.anyElement(identifier)+            XCTAssertTrue(total.waitForExistence(timeout: 15), "\(identifier) should render")+            XCTAssertFalse(+                total.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,+                "\(identifier) states its value in words")+            XCTAssertTrue(+                window.frame.contains(total.frame),+                "\(identifier) must stay inside the window at the largest Dynamic Type size")+        }++        let period = app.buttons["stats-period-menu"]+        assertContentControl(period, named: "Period control at largest Dynamic Type")+        XCTAssertTrue(+            period.label.contains("This week"),+            "The period control names the current period rather than clipping it")+        XCTAssertTrue(+            window.frame.contains(period.frame),+            "The period control must stay inside the window")++        let firstBand = app.buttons["stats-bar-0"]+        XCTAssertTrue(firstBand.waitForExistence(timeout: 15), "The graph's bands should render")+        XCTAssertLessThanOrEqual(+            period.frame.maxY, firstBand.frame.minY,+            "The period control must sit above the graph, not over it")+    }+     private func walkWorksAppearanceJourney(named appearance: String) {         let works = app.tabBars.buttons["Works"]         XCTAssertTrue(works.waitForExistence(timeout: 30), "Works tab missing in \(appearance)")
docs/agent-notes/testing.md Modified +40
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex d7407c7..635c5f2 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -211,6 +211,46 @@ and by title for the rest, so one story lands in two Works. A library that grew capture → teach → capture does not look like that. See Q20 in `specs/url-locator-generalisation/decision_log.md`. +## Two fixture shapes the validator will not let you write past++Both surfaced building the real-store totals test in+`Asterism/AsterismTests/StatsDerivationTests.swift`, and both fail on a *later*+write rather than on the one that seeded them, which is what makes them+confusing.++- **A blank-titled `Work` poisons every subsequent write.**+  `LibraryValidator.swift:648` throws `Invalid Work …: display title is blank`,+  so a fixture cannot both hold a blank-titled work and then resolve a duplicate+  set or merge two works. Split it: one library seeded with the tolerated shapes+  for read-only assertions, another writable one for the mutation. (The store+  can *hold* the shape — `recentWorkTitles` omits such a Work rather than+  throwing, which is how an Entry reaches the "references a work that resolves+  to no title" state — it just cannot be written past.)+- **An `Entry` assigned a `Work` needs `workAssignmentProvenance = .manual`.**+  Leaving it `.none` while the relationship exists trips+  `LibraryValidator.swift:964` with `none assignment has incompatible+  relationship or provenance`, rejecting the whole library on the next write.++## Two UI-test facts about tabs and Swift Charts++Both measured on iOS 26 while building the Stats tab (`specs/stats-page/`), and+both cost a debugging session because the symptom is "the element is there and+the tap does nothing".++- **An `.accessibilityIdentifier` on a `SwiftUI.Tab` never reaches the tab-bar+  button.** The button publishes its label and its symbol image and no+  identifier, and moving the identifier onto a custom `label:` view does not+  change it. `tab-recent`, `tab-works` and `tab-stats` are all inert. Reach a tab+  by label (`app.tabBars.buttons["Works"]`), which is what every suite already+  does.+- **XCUI taps an accessibility element by coordinate, not by invoking its+  action.** So a `.accessibilityRepresentation` supplying `Button`s is readable+  by a test but not drivable by one: the tap lands on whatever real view sits at+  that point. If a UI test has to *activate* something, it needs a real control+  whose frame is where the test expects — which is why the Stats chart's bands+  are `Button`s laid over the plot rather than a representation (Q46 of that+  spec).+ ## Misc  - `make test-only TEST=AsterismTests/SomeSuite` runs one suite; `TEST` also
docs/asterism-design.md Modified +7 / -1
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 5f99b42..819f46c 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -264,7 +264,7 @@ Without option 2, correcting an early teaching mistake would mean re-parsing eve  ## 5. Main app structure -Two tabs plus a settings gear.+Three tabs plus a settings gear. Stats — two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day — is the third, added after this shape was first stated as two tabs (`specs/stats-page/`, Decision 2).  ### 5.1 Recent (home) @@ -294,6 +294,12 @@ Search: work titles.  Sites list: every stored site — untaught, taught, and articles — with mode and display name (Q10 of `specs/polish-and-export/`); tap to re-teach or flip mode. Full-library backup export (§10). Deliberately buried. +### 5.4 Stats++Numbered after Settings because §5.3 is cited from elsewhere, not because it sits last: it is the third tab, between Works and the gear.++Two lifetime totals (notes, works) over a bar graph of reading activity across one of five periods — This week, Last week, This month, Last month, All time — a day per bar, or a month per bar for All time. Selecting a bar breaks that day down by work; a row routes to its work in the Works tab. All time selects nothing and instead opens a bar's month. Derived from the snapshots the app already publishes: no new entity, no stored counts. Full behaviour in `specs/stats-page/`.+ ---  ## 6. Work detail
docs/asterism-style-guide.md Modified +2 / -2
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex a43a994..1bff40a 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -90,7 +90,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - **Inbox banner**: amber-tinted glass capsule (fill .10 dark / .30 light, border .35/.45), ✦ icon, chevron. Appears only when count > 0. *[Recent dark]*. - **Unparsed row**: normal card but with the amber border, amber "?" circle glyph, raw title still set in serif, and an amber **Teach pill** (10.5 pt bold, dark text on amber fill). - **Teach chips**: radius 15, 1.5 px borders. Chapter = amber (fill .13, border .8), Work = cyan (same recipe + glow), Ignored = card fill, dim, opacity ~.5, strikethrough. URL chips: same but SF Mono, radius 12. Delimiters/slashes between chips: bare text at ~.25 white (dark) / .25 ink (light). *[Teach chips dark]*, *[URL identity dark]*.-- **Tab bar**: floating capsule, detached (16 px bottom, ~52 px side insets), two items. Active = cyan label+icon (+icon glow in dark); inactive = dim. Icons: ◷ Recent, ✦ Works.+- **Tab bar**: floating capsule, detached (16 px bottom, ~52 px side insets), three items. Active = cyan label+icon (+icon glow in dark); inactive = dim. Icons: ◷ Recent, ✦ Works, `chart.bar` Stats. Three labels inside the ~52 px side insets is a live layout constraint at the accessibility Dynamic Type sizes (`specs/stats-page/`, Decision 2). - **Tags**: type tag = violet tint/border; genre tags = neutral card recipe. 10.5 pt, weight 650. - **Count pill** (works list): cyan text on cyan .12 fill, radius 12. - **Unattached notes group**: dashed border (`rgba(170,200,255,.18)` dark), lower fill, dim ✎ glyph, neutral count pill. *[Works dark]*.@@ -99,7 +99,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field  ## 8. Iconography -✦ (four-pointed star) is the app's mark — used for: Works tab, section-header bullets, banner icon, new-site banner icon. Keep it sparse: never more than one ✦ per component. Other glyphs (◷, ⚙, ✎, ?, ▲, ▼, ‹, …, ↗) come from SF Symbols equivalents at implementation time. No emoji anywhere.+✦ (four-pointed star) is the app's mark — used for: Works tab, section-header bullets, banner icon, new-site banner icon. Keep it sparse: never more than one ✦ per component. Other glyphs (◷, ⚙, ✎, ?, ▲, ▼, ‹, …, ↗) come from SF Symbols equivalents at implementation time. The Stats tab is named directly by its symbol, `chart.bar` — it says what the page is and matches the graph it opens onto (`specs/stats-page/`, Q17). No emoji anywhere.  ## 9. Motion (v1 minimal) 
specs/polish-and-export/requirements.md Modified +2 / -2
diff --git a/specs/polish-and-export/requirements.md b/specs/polish-and-export/requirements.mdindex 28c4004..0ce6532 100644--- a/specs/polish-and-export/requirements.md+++ b/specs/polish-and-export/requirements.md@@ -129,7 +129,7 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e  1. <a name="9.1"></a>Each screen with a primary action SHALL style exactly one control as the gradient primary button (e.g. Save/Update, "Looks right — apply", "Open last noted chapter"); all other buttons SHALL use the secondary or tertiary treatment. 2. <a name="9.2"></a>Site glyphs SHALL render as circles with the radial star-gradient treatment, colored deterministically from the hostname (no stored color field); unknown-site `?` and article ✎ glyphs SHALL be flat, dim, and unglowed. Rows that show a hostname as plain text SHALL show the glyph instead.-3. <a name="9.3"></a>The tab bar SHALL be the floating detached capsule with two items (◷ Recent, ✦ Works), active item cyan (with icon glow in dark).+3. <a name="9.3"></a>The tab bar SHALL be the floating detached capsule with two items (◷ Recent, ✦ Works), active item cyan (with icon glow in dark). **Superseded in part** (2026-08-16) — the item count and icon set are extended by [`specs/stats-page/`](../stats-page/requirements.md) Req 1.1, which adds a third tab (Stats, carrying `chart.bar`); the floating detached capsule treatment and the cyan active item apply unchanged to all three. 4. <a name="9.4"></a>Rating toggles, inbox banner, unparsed rows with Teach pill, the Resolve pill (matching the Teach pill recipe per the design doc §5.1), the duplicate-resolution sheet, teach chips, count pills, tags, the unattached-notes dashed group, the rating pulse cards, and the provenance disclosure SHALL match their style-guide §7 recipes. 5. <a name="9.5"></a>Glow SHALL appear only on colored site glyphs, primary buttons, the active tab icon (dark), selected teach chips, and active rating toggle controls, at the §5/§7 values; rating glyphs in rows and lists never glow. 6. <a name="9.6"></a>Corner radii SHALL follow the concentric system of style guide §6.@@ -151,6 +151,6 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e **Acceptance Criteria:**  1. <a name="11.1"></a>WHEN Reduce Transparency is on, glass surfaces SHALL fall back to the opaque fills named in style guide §10.-2. <a name="11.2"></a>All interactive elements SHALL keep hit targets of at least 44 pt even where visuals are smaller (Teach pill, tags, chips).+2. <a name="11.2"></a>All interactive elements SHALL keep hit targets of at least 44 pt even where visuals are smaller (Teach pill, tags, chips). **Superseded in part** (2026-08-16) — narrowed by [`specs/stats-page/`](../stats-page/requirements.md) Req 7.3 for the activity graph's bars alone, where a bar can be 12 pt wide and selection is resolved across the whole plot area by position instead of by touching the drawn mark. Every other control on that page, including its period control, keeps the 44 pt rule (its Req 7.4), as does every element this criterion already named. 3. <a name="11.3"></a>Serif titles SHALL scale with Dynamic Type; single-line work and chapter names in rows SHALL truncate with an ellipsis rather than wrap. 4. <a name="11.4"></a>Amber text SHALL use the lifted variant on dark and the darkened variant on light, per style guide §10.

Things to double-check

The three visual requirements are still open, by design.

The cyan accent with no amber, the graph not reading as a glass surface, and the selected bar being distinguishable at a glance are settled by eye on a device — this repo has no snapshot testing, and prerequisites.md reserved them for the author from the start. The author has the build installed; these are the three things to look at.

The month screen now shows the whole header.

The pushed month screen renders the lifetime pair and the month's pair, so one code path serves both screens. If the pushed screen should stay spare, that is a small narrowing.

The M4 scale fixture no longer works for Stats.

It dates all 5,000 entries at epoch + n seconds, so under the new 1971 floor that library has no usable capture dates at all and Stats reports that no note carries one. Nothing measures Stats over it today, so nothing broke — but it cannot be used for future Stats work.

snapshotGeneration creates a new obligation.

Anything that publishes a snapshot outside refreshAll() must bump the generation, or a derived surface will hold stale values indefinitely. Nothing does today, and refreshAll() is the sole writer of all four values — but that is now an invariant to preserve rather than an accident.

Overlapping refreshes are not serialised.

Two concurrent refreshAll() calls can still interleave such that the older pair publishes with the higher generation. This is pre-existing and no worse than what other consumers already see, but the counter now asserts 'newer' where nothing enforces it.