asterism Branch T-2216/stats-period-navigation Commits 12 Files touched 17 Lines +2,818 / -556

Pre-push review: T-2216/stats-period-navigation

The Stats page trades five fixed periods for a Week | Month | All time unit toggle, chevron stepping and a bounded date picker over the library's whole history, and gains two top-five ranked lists (works and sites) for the shown period. Twelve commits across derivation, view, shared control and documentation.

At a glance

  • The five-period Menu is gone. A three-segment capsule over a back-chevron / label / forward-chevron row reaches any week or month the library holds; the label presents a .graphical DatePicker bounded to the earliest usable capture through now.
  • The pushed month screen is deleted (Q5). An All-time bar switches the unit to Month at that month, and the chevrons carry on from there — navigationDestination, monthScreen, openedMonth and closeMonth() all go.
  • Two ranked lists below the graph: most-read works and most-read sites, top five each, derived in the pass that already produced the period figures — no second walk over the rows.
  • The anchor is a midpoint, not a period start (Q18). A boundary instant re-resolved under a lower UTC offset lands in the previous period; the midpoint has half a period of slack in both directions.
  • The segmented capsule became shared code (Q28): ConstellationSegmentedControl in ConstellationKit, replacing two line-for-line copies. This knowingly overrules the smolspec's “AsterismCore is not modified” scope line, argued in the decision log.
  • The supersession is documented in place: stats-page Reqs 3.1/5.3/5.4 outright, 2.10/3.2/5.2/5.5 in part, Decision 3's drill-down half, Q30, Q31, Q42, Q50, Q55, plus design §5.4, style guide §7/§10 and OVERVIEW.md.

Verdict

Ready to push

Four parallel review agents ran over the range — code reuse, code quality, efficiency and spec/docs adherence. Every major and minor they raised is fixed in 95cf817; the five items declined are recorded below with their reasons rather than left silent.

Verification after the fixes: make test-quick 937 tests green, StatsUITests 10/10, AccessibilityJourneyUITests 9/9, WorkDetailActionsUITests 13/13 (re-run because the shared segmented control changed work detail's sort), and no new compiler warnings over the touched files.

Two things want a human eye before or shortly after the push — see Worth a second look. Neither blocks.

Review findings

21 raised · 15 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The Stats page used to offer five fixed periods behind a drop-down menu: This week, Last week, This month, Last month, All time. Anything older than last month could only be reached by opening the All-time chart and tapping a bar, which pushed a separate month screen with a back button.

Now the page has a three-way switch — Week | Month | All time — with a row underneath it holding a back arrow, the period's name, and a forward arrow. Tapping the name opens a calendar so any week or month in the library's history can be jumped to directly. The arrows step one week or one month at a time and grey out when there is nowhere further to go: forward stops at today, backward stops at the oldest note in the library.

Two new lists appear below the chart: Most read works and Most read sites, each showing the top five for whatever period is on screen, with the note count beside each row. Tapping a work opens it.

The separate month screen is gone. Tapping a bar on the All-time chart now flips the switch to Month at that month, and the arrows carry on from there.

Why it matters

The old page could only show you the last two weeks and the last two months. If you wanted to know what you were reading in March, you had no route to it short of the All-time chart and a tap. Now every week and every month the library holds is one or two gestures away.

The ranked lists answer the question the bar chart could not: the chart says how much you read; the lists say what you read, and where you read it.

Key concepts

  • Unit and anchor. Think of the unit as the zoom level (a week wide, a month wide, or everything) and the anchor as where the window sits on the timeline. The two together decide what the chart shows. Keeping them separate is what lets the switch change zoom without losing your place.
  • “Nothing stored” means “the current one.” When no anchor is stored, the page means this week or this month — whatever those are right now. So a page left open past midnight on a Sunday shows the new week the next morning, with no timer and nothing to refresh. Navigate back and then forward again to today, and the page goes back to that self-updating state.
  • Midpoint, not edge. The anchor is stored as the instant halfway through the shown period rather than its first moment. A first moment sits exactly on a boundary, and moving time zones can push a boundary instant into the neighbouring week. The midpoint has half a period of slack in both directions.
  • A shared control. The three-way switch and the sort switch already on the work page were two identical copies of the same code. They are now one shared component, so they cannot drift apart.

Changes overview

FileWhat it does now
ViewModels/StatsDerivation.swift
+525/-67
StatsPeriodStatsUnit {week, month, allTime}; StatsScope carries its own start; StatsNavigation becomes a unit/anchor/selectedDay state machine with select(unit:), step(by:), pick(date:), open(month:) and bounds(...); StatsGraph gains earliestUsableCapture, non-optional periodFigures, topWorks, topSites; new StatsPeriodNaming; StatsInputKey splits periodIdentity out of graphIdentity
Views/StatsView.swift
+380/-133
The Menu, the navigationDestination, monthScreen and openedMonth are deleted; a centred capsule + chevron/label/chevron row + .graphical DatePicker sheet replace them; two ranked sections render below the graph; one countRow builder serves the breakdown and both lists
ConstellationKit/ConstellationRecipes.swift
+140
New public ConstellationSegmentedControl<Value: Hashable> — the segmented-capsule recipe extracted from work detail (Q28)
Views/WorkDetailView.swift
+33/-91
The hand-rolled sort capsule is deleted; the sort control is the shared one, with title / controlIdentifier key paths in an extension
ViewModels/WorkDetailModel.swift
+7/-1
ChapterSortOrder gains Hashable, CaseIterable so the shared control can key and enumerate its segments
AsterismTests/StatsDerivationTests.swift
+1,190/-187
Navigation suite rewritten around the state machine; new Stats period naming suite; derivation, keys, invariants and read-discipline suites extended
UI tests
+281/-53
Chevron clamps, the picker sheet, unit switches, both ranked lists appearing and disappearing, the work-row route; the Q16/Q26 no-clipping journey; work detail's sort segments under the new container label
Docs and specs
7 files
stats-page supersessions, design §5.4, style guide §7/§10, OVERVIEW.md, this spec's three documents, CHANGELOG.md

Implementation approach

The navigation is a value type, not view state. StatsNavigation holds unit, anchor: Date? and selectedDay: Date?, all private(set). Every transition takes (calendar:now:earliest:) and funnels through one private show(_:) that normalises to the period start, clamps at both ends, stores nil where the result is the current period, and clears the selected day. The scope is derived (scope(calendar:now:)), never stored, so the unit and the shown period cannot disagree. This follows the repo's existing rule (stats-page Q41, and the RecentDisplayPlan precedent): no app-layer test instantiates a SwiftUI view, so a rule left in body is a rule nothing can check.

The view reads bounds, never the anchor. bounds(calendar:now:earliest:) returns StatsPeriodBounds { canStepBackward, canStepForward, selectableRange, currentSelection }. Chevron enablement, the picker's range and the picker's opening selection all come from that one struct — which is what makes a bound that moved after a republish (notes reconciled away) disable a chevron rather than allow a step past it.

Ranking rides the existing pass. tally(in:rows:) already walked the span's rows once for Req 2.10's notes/works pair. It now also accumulates [UUID: (title, count)] for works and [String: Int] for sites in that same loop, classified from the row alone (row.entry.workID, row.workDisplayTitle, row.hostname) — never by consulting the works array, which stays the source of the works total only (Q17, stats-page Decision 5). Works are selected by a bounded insertion capped at five; sites, whose cardinality per span is small, take the simpler sorted(by:).prefix(5).

The ordering is shared. precedes(_ left: StatsRankingRow, _ right:) — count descending, then localizedStandardCompare on the title, then uuidString — is the same comparator the breakdown's own precedes(StatsBreakdownRow) delegates to for its .work case, so the two lists cannot disagree about a tie.

Naming left the view. StatsPeriodNaming provides label (bare, for the chevron row), tilePhrase (with its preposition, Req 2.10's tiles) and rankingPhrase (the two headings), all taking the calendar as a parameter (Q23).

One row builder. countRow(name:count:workID:routableIdentifier:inertIdentifier:) produces the breakdown row, the ranked-work row and the ranked-site row. The routable branch is a Button announcing “Open Work …”; the inert branch is a plain row. Two identifiers per list, not one (Q34), so a test that means “this row opens a work” cannot pass on a row that opens nothing.

Trade-offs

  • AsterismCore was modified after all. The smolspec's scope line said it would not be. Q28 overrules it explicitly: ConstellationKit is a SwiftUI package the app links, not a stored shape — no schema, archive, backup format or published snapshot is touched.
  • The period control is absent, not disabled, before the first derivation (Q30). The bounds come from the graph, so before there is a graph there is nothing to disable. Absence satisfies the requirement's intent; it is a looser reading of its letter.
  • All time keeps the period tiles (Q12), so its notes figure near-duplicates the lifetime total less undated notes. Accepted so the header does not change shape on one toggle position — this reverses stats-page Q55's exclusion.
  • works.contains(where:) is a linear scan, run per rendered row and again at tap time. Kept: row counts are bounded at five, and it matches the file's recorded precedent.
  • The .graphical DatePicker is a day grid even for Month (Q6). A .compact picker was tried first but draws its own date text and cannot read “This week” or a week range, so the label presents the picker rather than being it.

Technical deep dive

The midpoint anchor (Q18). The first cut stored the shown period's start. A period start is a boundary instant, and dateInterval(of:for:) applied to the same absolute instant under a calendar at a lower UTC offset resolves into the period before: 2026-08-03 00:00 in Amsterdam is 2026-08-02 12:00 in Honolulu, which under a Monday-start week is the previous week. A user flying west would have watched the shown week silently shift back one. periodMidpoint(of:_:_:) stores interval.start + interval.duration / 2 — half a period from either boundary, further than any real zone change (max ~26 h of spread) can move it, for a 7-day week and a 28-day February alike. The clamp inside show(_:) still compares period starts, which are a total order over periods; only what is persisted is the midpoint. The navigation suite pins it: an anchor picked under Amsterdam/Sunday-start resolves under Honolulu/Monday-start to a week holding six of the seven days the reader was looking at.

periodIdentity vs graphIdentity. graphIdentity is the derivation trigger and holds generation — the counter that says the library beneath the page moved. StatsView.isGraphCurrent originally compared that, and the result was a one-frame blank of the graph, breakdown and period tiles on every republish: body re-evaluates before .task(id:) runs, so the moment the generation bumped, the cached graph was declared stale before the re-derivation had produced a replacement. periodIdentity is the same three fields (unit, anchor, temporal) with the generation removed — “is the graph in hand the one this screen is naming?”, which a republish does not change. GraphIdentity nests PeriodIdentity rather than restating its fields, so a field added to one cannot be forgotten in the other. The keys suite asserts a generation bump leaves periodIdentity alone.

Q31's degenerate bounds. The spec's clause “WHEN the library holds no usable capture date, both chevrons MUST be disabled and the picker's range MUST be the current period's start through now” describes the fresh state — a nil anchor with nothing to clamp to. It collides with the other requirement, that a shown period stays after a republish moves a bound. Q31 resolves it in favour of the shown period: with a dated anchor and earliest == nil, backward is refused, forward is allowed and lands on the current period, and selectableRange widens to min(limits.lower, shown)...now so the .graphical DatePicker(in:) is never handed a selection outside its range — behaviour that is otherwise undefined on that control. currentSelection is min(max(anchor ?? now, lower), upper), resolved on StatsPeriodBounds where the clock and the range are both in hand rather than in the view's binding, where the clamp had been a second copy of the same rule.

Bounded top-5 selection. rankedWorks maintains an ordered array capped at rankingLimit. Per candidate: an early continue if the array is full and the candidate does not precede its last element, then firstIndex(where: precedes) and an insert, then a removeLast() on overflow. Worst case O(n · k) with k = 5 rather than O(n log n) over every distinct work in the span — which for All time is every work in the library. Correctness rests on precedes being a total order: count, then title, then uuidString. Title alone is not (two works can carry the same display title — exactly the unresolved-duplicate case), so the UUID tie-break is load-bearing rather than defensive. Because the order is total, the five selected and their order are the same five a full sort would have put in front. rankedSites keeps the plain sort: hostnames per span are few.

Read discipline. The Stats read discipline suite drives every transition the screen can produce — the tab becoming selected, all three units, a step back and forward, a pick, a month opened from an All-time bar, and every bar of each resolved scope selected and cleared — and asserts the repository call counts are unchanged throughout (Req 1.5). deriveIfNeeded() resolves the scope from the temporal key's calendar and now, so no body evaluation reads the clock for derivation. Three exemptions are recorded rather than left implicit: Q22 (weekText formatting through Calendar.current), Q29 (periodControl reading Date() for chevron enablement) and Q14's statement of the rule itself. All three are safe for the same reason — the temporal key already forces a re-render on a significant time or zone change — and each derives nothing and issues no library read.

Q27 over Q10. Q10 says a unit switch carries the shown period's start into the new unit. Applied to a nil anchor, that resolves the current week's start — which lies in the previous month whenever the current week spans a month boundary — so from the default state, tapping Month showed last month. Q27 exempts a nil anchor: select(unit:) guards on anchor != nil, let component = unit.calendarComponent, and the else branch lands on the current period. The guard states an invariant rather than defending against a case: All time is the only unit with no calendarComponent, and also the only unit that always holds a nil anchor (show(_:) clears the anchor whenever the unit has no component), so “a dated anchor” and “a unit with a component” are the same condition.

Architecture impact

  • ConstellationKit gains its first segmented control. Two app-layer screens depend on it. The container label is required rather than optional (Q28's amendment), and accessibilityElement(children: .contain) keeps every segment individually reachable — the accessibility shape that “an existing caller unchanged” was actually protecting. Work detail's journey now pins both segments and the selected one under the “Sort order” label.
  • Stats' NavigationStack keeps one screen. navigationDestination, monthScreen, openedMonth and closeMonth() are deleted. stats-page Decision 3's drill-down half, Q30, Q42 and Q50 are superseded in place; Q50's single-cache trade-off is answered rather than retired, since there is no push and no pop to blank behind.
  • StatsPeriodFigures is non-optional. Callers that handled nil now handle zeros. All time gets the pair too (Q12), reversing stats-page Q55.
  • WorkDetailModel.ChapterSortOrder is public API that gained conformances (Hashable, CaseIterable). Both synthesised — the cases carry nothing — so this is additive.

Potential issues

  • Q16's criterion is frame containment, not glyph containment. The accessibility journey asserts the capsule, both chevrons, the picker and the ranked sections lie inside the window at AccessibilityExtraExtraExtraLarge. A segment whose text overflowed its own fixed-width frame while the frame stayed inside the window would pass. The segment label carries .lineLimit(1) and .fixedSize(horizontal: true), which makes the label push the frame rather than be clipped by it — so the likelier failure mode is a stacked layout, not a clipped one — but the assertion does not close the gap, and Q26 was decided on it.
  • The naming test pins an ICU literal: "10\u{2009}\u{2013}\u{2009}16 Aug 2026" — thin space, en dash, thin space, which is what ICU produces for en_AU and not the bare en dash the requirement's prose writes. Pinned as produced, deliberately, but it will break on an ICU data change rather than on a code regression.
  • The date picker is a day grid for a month selection (Q6). Selecting “March 2025” means tapping a day in March 2025. pick(date:) normalises, so the result is right; the interaction is a compromise the .compact picker's own date text forced.
  • Style guide §7 still says “optional container label.” The sentence predates Q28's amendment making the label required and was not updated with it. Code and Q28 agree; §7 lags by one word.
  • Q30's absent-not-disabled control is a reading, not a match. The requirement says the chevrons and picker “MUST be disabled” until the first derivation. Absence is a defensible satisfaction of the intent and is recorded as a decision, but the smolspec text was not amended to say so — unlike Req 2.10's tile bullet, which was amended for Q19.
  • works.contains(where:) is linear in the works snapshot, run per rendered ranked row and once more at tap time. Bounded at five rows, so cheap in practice, but the scan grows with the library rather than with the list.

Important changes — detailed

StatsNavigation becomes a clamped unit/anchor state machine

Asterism/Asterism/ViewModels/StatsDerivation.swift:215-414

Why it matters. The old shape was five enum cases and a separate openedMonth binding, so 'which period' was spread across two pieces of state that could disagree, and nothing older than last month was addressable at all.

What to look at. StatsNavigation now holds unit / anchor: Date? / selectedDay, all private(set), with select(unit:), step(by:), pick(date:) and open(month:) all funnelling through one private show(_:) that normalises to the period start, clamps at both ends, stores nil on the current period and clears the selection. scope(calendar:now:) is derived, never stored.

Takeaway. One transition function means one place where clamping, normalisation and selection-clearing can be wrong. A nil anchor is the clock-tracking state, so Req 3.7 needs no timer.
Rationale. Q9 (nil = the current period, so a stored week start cannot go stale at midnight), Q15 (a transition landing on the current period re-nils), and stats-page Q41's standing rule that no app-layer test instantiates a view, so a rule left in body is a rule nothing can check.

The anchor stores the period's midpoint, not its start

Asterism/Asterism/ViewModels/StatsDerivation.swift:358, 388-414

Why it matters. A period start is a boundary instant. The same absolute instant re-resolved under a calendar at a lower UTC offset falls in the period before it, so a time-zone move silently shifted the shown week.

What to look at. periodMidpoint(of:_:_:) returns interval.start + interval.duration / 2, and show(_:) stores that. The clamp still compares period starts (a total order over periods); only what is persisted moved. Reads normalise through dateInterval(of:for:) as before.

Takeaway. Half a period of slack in both directions is wider than any real zone change, for a 7-day week and a 28-day February alike. The test resolves an Amsterdam/Sunday-start anchor under Honolulu/Monday-start and lands on a week holding six of the seven original days.
Rationale. Q18 states the failing case outright: 2026-08-03 00:00 UTC is the Sunday of the previous Monday-start week in Honolulu.

periodIdentity split out of graphIdentity for the stale-graph guard

Asterism/Asterism/ViewModels/StatsDerivation.swift:554-598; Views/StatsView.swift:965-974

Why it matters. isGraphCurrent compared graphIdentity, which carries the generation. body re-evaluates before .task(id:) runs, so on every republish the cached graph was declared stale a frame before its replacement existed - blanking the graph, breakdown and period tiles.

What to look at. periodIdentity is (unit, anchor, temporal) with no generation: 'is the graph in hand the one this screen is naming?'. graphIdentity nests PeriodIdentity rather than restating its fields, so the two cannot drift. The keys suite asserts a generation bump leaves periodIdentity alone.

Takeaway. The derivation trigger and the display-currency question are different questions about the same state. Asking the trigger for both cost a visible frame on every library republish.
Rationale. Raised as one of the eight findings in the derivation-phase design review and fixed in 2cab4b5; the nesting is a pre-push review fix in 95cf817.

The Menu becomes a capsule, a chevron row and a bounded date picker

Asterism/Asterism/Views/StatsView.swift:216-397

Why it matters. Five fixed periods behind a Menu made anything older than last month unreachable except through the All-time chart and a pushed month screen.

What to look at. periodControl renders the three-segment capsule over a ViewThatFits chevron / label / chevron row (absent for All time), with one .sheet declared on the control rather than inside a layout candidate - ViewThatFits builds every candidate to measure it, so a presentation inside one is declared more than once. The label is a Button presenting a .graphical DatePicker bounded to bounds.selectableRange. Chevron enablement, the range and the opening selection all come from StatsPeriodBounds; the view never reads the stored anchor.

Takeaway. Enablement from bounds rather than from the anchor is what makes a bound that moved after a republish disable the chevron instead of allowing a step past it. The whole control renders only where a graph exists, which is what disables it before the first derivation (Q30).
Rationale. Q6 (the label presents the picker because a .compact picker draws its own date text and cannot read 'This week'), Q7 (clamped at both ends), Q29 (reading Date() for enablement derives nothing, so it is not the read Q14 forbids), Q30 (absent, not disabled, before the first derivation).

ConstellationSegmentedControl extracted to ConstellationKit

Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift:196-335; Views/WorkDetailView.swift:650-662, 1826-1847

Why it matters. Stats' three-segment unit toggle and work detail's two-segment sort were line-for-line copies: the ViewThatFits pair, the @ScaledMetric visual, the cardFill/cardBorder surface, the 44 pt target and the .isSelected trait, twice. A copy is a chance for the two to disagree, and style guide section 7 describes one recipe, not two.

What to look at. A public generic view over Value: Hashable taking values, a Binding, a required containerLabel and title / identifier closures. Work detail's 91 lines of hand-rolled capsule are deleted; ChapterSortOrder gains Hashable + CaseIterable and an extension supplying title and controlIdentifier, so the segments come from allCases rather than a hand-written list.

Takeaway. The container label went from optional to required in the pre-push review: with both callers passing one, the optional arm had no user left, only a second accessibility shape to keep working. children: .contain keeps every segment individually reachable.
Rationale. Q28, which also states explicitly that it overrules the smolspec's 'AsterismCore is not modified' scope line - ConstellationKit is a linked SwiftUI package, not a stored shape, so no schema, archive, backup format or published snapshot is touched.

Ranked lists derive in the existing pass, with a bounded top-5 selection

Asterism/Asterism/ViewModels/StatsDerivation.swift:679-765, 833-855

Why it matters. The span was already walked once for Req 2.10's notes/works pair. Ranking by a second pass, or by a full sort over every distinct work in an All-time span, would pay for five rows at library scale.

What to look at. tally(in:rows:) accumulates [UUID: (title, count)] and [String: Int] in the same loop, keyed on the work's UUID rather than on a .work(id:title:) case whose hash covers the whole display title. rankedWorks keeps an ordered array capped at five by insertion, with an early continue when the array is full and the candidate loses to its last element. The comparator is shared with the breakdown's own precedes.

Takeaway. The bounded selection is only correct because precedes is a total order - count, then localizedStandardCompare on the title, then uuidString. Title alone is not: two works can carry the same display title, which is exactly the unresolved-duplicate case.
Rationale. Q3 (top five, works and sites, count then title/hostname then identity), Q17 (classify from the row alone, never from the works array), and the efficiency agent's finding on the UUID key and the bounded selection, fixed in 95cf817.

One row builder for the breakdown and both ranked lists

Asterism/Asterism/Views/StatsView.swift:509-655

Why it matters. The breakdown row, the ranked work row and the ranked site row were three copies of one shape, free to disagree about their routing, their spoken strings or their identifiers.

What to look at. countRow(name:count:workID:routableIdentifier:inertIdentifier:) carries both halves: a Button announcing 'Open Work ...' where the work still resolves in the current snapshot, and a plain row otherwise. rankedSection is generic over its rows, so emptiness derives from the collection rather than from a flag passed beside it, and the ranking phrase is resolved once per body evaluation instead of once per section.

Takeaway. Two identifiers per list rather than one: stats-top-work-row for the routable row, stats-top-row for the inert one. A test that means 'this row opens a work' would otherwise pass on a row that opens nothing.
Rationale. Q34 records the identifier split and its reasoning; Q25 covers the section that is present but ranks nothing (stats-top-works-empty / stats-top-sites-empty), on the Req 6.9 precedent that a zero-value bar says so rather than rendering an empty list. The unification itself is the reuse agent's finding, fixed in 95cf817.

The supersession is written into the specs it replaces

specs/stats-page/requirements.md, specs/stats-page/decision_log.md, docs/asterism-design.md:302, docs/asterism-style-guide.md:100,121, specs/OVERVIEW.md

Why it matters. The stats-page spec described five fixed periods and a pushed month screen, neither of which exists any more. Left unmarked, it would read as current.

What to look at. Reqs 3.1, 5.3 and 5.4 superseded outright; 2.10, 3.2, 5.2 and 5.5 in part, each struck where wrong and annotated with what replaced it and what stands. Two non-goals annotated. Decision 3's status carries 'the drill-down half superseded in part' with a note naming what survives (month bucketing, navigate-not-select, the scroll) and the one consequence that does not. Q30, Q31, Q42, Q50 and Q55 carry dated supersession clauses pointing at this spec's Qs. Design section 5.4 describes the shipped page; style guide sections 7 and 10 say which capsule placement applies to which caller.

Takeaway. Q50's single-cache trade-off is answered rather than retired - there is no push and no pop, so there is no blank to show at the tail of one.
Rationale. Task 7 of the smolspec, which named each requirement and decision to annotate. The Q42/Q50 clauses and the section 7 / section 10 placement split were added in the pre-push review pass.

Key decisions

Q5 - The pushed month screen is deleted; an All-time bar switches the unit

With Month a first-class unit, a pushed month screen duplicates it, and the chevrons supply the “next month” the push never could. Tapping an All-time bar sets unit = .month at that bar's month instead. navigationDestination, monthScreen, openedMonth and closeMonth() all go; the NavigationStack stays with one screen in it. Supersedes stats-page Decision 3's drill-down half and Q30, and retires the Q42 and Q50 questions entirely.

Q10 + Q27 - A unit switch keeps the shown date, except from a nil anchor

Q10: switching Week → Month to widen the view should not lose the reader's place, so the shown period's start carries into the new unit, clamped.

Q27 is the exemption found in review: a nil anchor is “the current period” (Q15), and carrying the current week's start lands Month on the previous month whenever the current week began in it — the default state on a month boundary. A nil anchor therefore stays nil across a unit switch; a dated anchor keeps Q10 exactly.

Q15 - A transition landing on the current period stores nil

Otherwise a period reached by navigation stops tracking the clock at midnight — the exact failure Q9's nil anchor exists to prevent. So stepping back and forward again, or picking a date inside this week, returns the page to the self-updating state as if the current period had never been left.

Q18 - The anchor is the period's midpoint, not its start

A period start is a boundary instant, and re-resolving it under a calendar at a lower UTC offset lands in the period before: 2026-08-03 00:00 UTC is the Sunday of the previous Monday-start week in Honolulu, so a zone move would silently shift the shown week. The midpoint is half a period from either boundary, further than any zone change moves it. Nil still means the current period, and every read still normalises through dateInterval(of:for:).

Q19 - The Req 2.10 tiles keep the preposition on a dated period

“12 notes in August 2026” beside “80 notes in all”. The tile reads as a sentence; “12 notes August 2026” next to it reads as a defect. The bare label stays available for the chevron row, which is not a sentence. This is why StatsPeriodNaming has three arms rather than one.

Q20 - A forward step from an out-of-range period clamps to the new lower bound

The shown period stays where it is after a republish moves a bound (the requirement), so the next step has to land somewhere the library still holds — the lower bound is the nearest such period. selectableRange widens to min(lower, shown)...now for the same state: a .graphical DatePicker(in:) handed a selection outside its range is undefined, and widening costs nothing because show(_:) clamps a pick either way.

Q24 - The picker's binding resolves a nil anchor to now, not to the period start

Q18 made an anchor an instant inside its period rather than its boundary, and now is that instant for the current period. It is inside the picker's range by construction, where a period start re-introduces the boundary Q18 exists to avoid. The pre-push review moved this resolution onto StatsPeriodBounds.currentSelection, where the clock and the range were already in hand, so the view's binding stopped repeating the clamp.

Q25 - A period with notes but no rankable work says so

Both sections must appear wherever the period holds a note, and Q13 means a period of unattached notes ranks no work at all. The precedent is Req 6.9: a zero-value bar says so rather than rendering an empty list. The two explanations carry their own identifiers — stats-top-works-empty and stats-top-sites-empty — so a test can tell “the section is present and says why it is empty” from “the section is missing”.

Q26 - The capsule stays; Q16's Menu fallback is not applied

Q16 named the accessibility journey's no-clipping assertion as the sole arbiter, deliberately in place of a judgement by eye. That assertion passes at AccessibilityExtraExtraExtraLarge: all three segments, both chevrons and the picker render inside the window, at 44 pt, above the graph. Worth knowing what the assertion measures — frame containment (Q32) — which is narrower than “does not clip”; see Worth a second look.

Q28 - The segmented capsule is one shared recipe, and it overrules the scope line

ConstellationSegmentedControl in ConstellationKit replaces two line-for-line copies. The decision states plainly that this overrules the smolspec'sAsterismCore is not modified” scope line, and why: the shared recipe is a new public SwiftUI view in a package the app links, not a stored shape — no schema change, no archive or backup format change, no snapshot published or altered, so nothing the scope line exists to protect is touched.

Amended in the pre-push review: the container label is now required rather than optional. A row of segments that never says what it is for is a gap rather than a caller's choice, and with both callers passing one the optional arm had no user left.

Q30 - The period control is absent, not disabled, before the first derivation

The requirement asks for disabled chevrons and picker until the first graph. The bounds come from the graph's own earliestUsableCapture, so before there is a graph there is no control to disable and nothing for a reader to reach. The ProgressView is what the page shows instead. A reinterpretation of the requirement's letter in favour of its intent, recorded rather than silently taken.

Q31 - Losing every usable capture with a period shown: Q20 governs

The spec's “both chevrons disabled, picker range current…now” clause describes the fresh state — a nil anchor with no capture to clamp to. With a period shown, the requirement that the shown period stays wins: the shown period stays, backward is refused, a forward step lands on the current period, and the picker's range is shown...now so the .graphical DatePicker(in:) is never handed a selection outside its range. Pinned by a test added in the pre-push review.

Q33 - The period control is centred on the Stats page

Style guide §7 stated the capsule's recipe and its stacking but no alignment, because its only caller until now sat beside a section header that fixed its place. The Stats control has no header — it is the unit toggle with the chevron row beneath it, governing the whole page below — and left-aligning a two-part control under a leading-aligned column left the chevron row hanging off one edge of what it names. §7 and §10 now say which of the two placements applies to which caller rather than leaving it unstated.

Review findings

SeverityAreaFindingResolution
majorCode reuse - StatsViewThe breakdown row, the ranked work row and the ranked site row were three copies of one row shape, free to disagree about routing, spoken strings or identifiers.Unified into a single countRow(name:count:workID:routableIdentifier:inertIdentifier:) helper carrying the routable and inert halves. Every identifier and spoken string unchanged.
minorCode quality - StatsViewrankedSection took an isEmpty flag beside its rows, letting a heading claim rows it was not given.rankedSection is generic over its row collection; emptiness derives from the collection itself.
minorEfficiency - StatsViewEach ranked section asked for its own ranking phrase, so a week's interval range text was formatted twice per body evaluation.The phrase is resolved once in rankedLists and passed to both sections.
minorCode reuse - StatsView / StatsDerivationThe date picker's binding re-implemented the anchor-or-now clamp that bounds(...) had already computed, and read the stored anchor to do it.StatsPeriodBounds gains currentSelection, resolved where the clock and the range were already in hand; the binding's get arm is that value. The view no longer reads the anchor.
minorCode quality - StatsUnitThe unit's lower-case noun was spelled two ways - title.lowercased() in one place and a == .week ternary in another - free to drift, and a third unit would have had to be remembered in both.StatsUnit gains an exhaustive noun property, used by both the chevrons' spoken labels and the naming helper.
majorEfficiency - StatsDerivation.tallyThe span tally keyed work counts on a .work(id:title:) case, so every counted row hashed a whole display title to reach a bucket the id already names; and the top five came from a full sort over every distinct work in the span - for All time, every work in the library.Keyed on the work's UUID with the title carried alongside, and the top five selected by a bounded insertion capped at rankingLimit. The work tie-break is now one comparator shared with the breakdown's ordering.
minorEfficiency - StatsDerivation.graphcaptureDates.min() was computed twice - once for the All-time span and once for navigation's backward clamp.Found once in graph(...) and passed down to span(for:).
minorCode quality - StatsNavigation.select(unit:)select(unit:) fell through to an unreachable ?? now, defending against a case that cannot arise instead of stating the invariant.Folded into one guard that states it: All time is the unit with no calendar component and also the unit that always holds a nil anchor, so 'a dated anchor' and 'a unit with a component' are the same condition.
minorCode quality - StatsInputKeyGraphIdentity restated PeriodIdentity's three fields, so a field added to one had to be remembered in the other.GraphIdentity nests PeriodIdentity.
minorCode reuse / accessibility - ConstellationSegmentedControlThe container label was optional, leaving a second accessibility shape to keep working and letting a row of segments ship without saying what it is for.The label is required; work detail's sort passes 'Sort order' and takes its segments from ChapterSortOrder.allCases with title and identifier key paths, as the Stats call site does. ChapterSortOrder gains Hashable and CaseIterable. Q28 amended to record it.
minorTest coverage - navigation suiteQ31's case - a dated anchor with no usable capture left in the library - was decided but not pinned by a test.The navigation suite asserts the shown period stays, backward is refused, forward lands on the current period, and selectableRange widens to hold the shown period.
minorTest coverage - work detailThe shared control changed work detail's sort capsule, but no journey asserted its segments or the selected one under the new container label.AccessibilityJourneyUITests pins both sort segments and the selected one under 'Sort order'. WorkDetailActionsUITests re-run: 13/13.
minorSpec/docs - decision logQ28 extracted a shared control into ConstellationKit while the smolspec's scope line said AsterismCore is not modified, with no record of the conflict; the label's change to required was undocumented; the centring (Q33) and the ranked-row identifier split (Q34) were unrecorded; Q25's identifiers were not named.Q28 records the overrule and the amendment; Q33 records the centred period control; Q34 records the two ranked-row identifiers and why they differ; Q25 names stats-top-works-empty and stats-top-sites-empty.
minorSpec/docs - style guideSections 7 and 10 stated the capsule's recipe and its stacking but no alignment, so nothing said which placement applied to which caller once Stats had one with no section header.Section 7 now names both placements - beside a section header where there is one, centred above what it governs where there is not - and section 10 carries the corresponding note.
minorSpec/docs - stats-pageThe stats-page spec's Reqs 1.9, 4.1 and 5.1 and its Q42 and Q50 read as current after the supersession pass, though the period navigation changed or retired what they describe.Reqs 1.9, 4.1 and 5.1 annotated; Q42 and Q50 carry dated supersession clauses pointing at Q5, with Q50's cache trade-off marked answered rather than retired.
minorCode quality - StatsDerivationStatsResolution-style parameter object suggested for the (calendar:now:earliest:) triple threaded through every navigation transition.Declined. The refactor would rewrite roughly 25 existing test call sites, and a refactor that forces test changes is not one this repo takes for readability alone. The triple is a stable, self-describing signature.
nitCode reuse - date formattingDate.FormatStyle and Date.IntervalFormatStyle configuration (calendar, timeZone, locale) is repeated across monthText and weekText.Declined. No shared protocol carries calendar/timeZone/locale across the two style types, so the 'dedup' would be a hand-rolled shim over two unrelated types.
nitTest structureHelper functions (calendar(), now(in:), anchor helpers) are repeated across the Stats test suites.Declined. This is the file's existing convention, and Swift Testing suites do not inherit - shared helpers would need a free function or a protocol extension, which reads worse than a four-line private helper per suite.
nitEfficiency - StatsViewworks.contains(where:) is a linear scan, run once per rendered row and again at tap time.Declined. Row counts are bounded (five per ranked list, one day's categories for the breakdown), and the file already records this precedent explicitly: the linear lookup costs less than the set it would otherwise build on every update.
nitCode quality - StatsGraphA StatsLibraryFacts split suggested, gathering earliestUsableCapture and the lifetime totals away from the scoped figures.Declined. One fact does not warrant a type; the split would add a layer without removing a question.
nitCode reuse - empty statesThe empty-state message shape (secondary text, fixedSize, vertical padding, an identifier) now appears three times and could become a ConstellationKit recipe.Declined. There is no existing recipe to extend, and three copies of four modifiers is below the bar Q28 cleared for a whole control with its own layout, surface and accessibility shape.

Per-file diffs

Click to expand.

Asterism/Asterism/ViewModels/StatsDerivation.swift Modified +525 / -67
diff --git a/Asterism/Asterism/ViewModels/StatsDerivation.swift b/Asterism/Asterism/ViewModels/StatsDerivation.swiftindex 2cfd3d5..211d325 100644--- a/Asterism/Asterism/ViewModels/StatsDerivation.swift+++ b/Asterism/Asterism/ViewModels/StatsDerivation.swift@@ -17,23 +17,52 @@ import Foundation  // 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+/// The three units the period control offers (`stats-period-navigation`). The+/// unit says *how wide* a period is; which one is shown is the navigation's+/// `anchor`, and the two together resolve to a `StatsScope`.+nonisolated enum StatsUnit: String, CaseIterable, Identifiable, Sendable {+    case week, month, allTime      var id: String { rawValue }++    /// The calendar component one step of this unit moves by, and the one its+    /// period start is normalised to. All time has none — it is a single+    /// unbounded period, so it neither steps nor anchors.+    var calendarComponent: Calendar.Component? {+        switch self {+        case .week: .weekOfYear+        case .month: .month+        case .allTime: nil+        }+    }++    /// The unit as a lower-case noun: what the chevrons say they step by+    /// ("Previous week") and what the current period is called in a phrase+    /// ("this week"). An exhaustive switch rather than `title.lowercased()` in+    /// one place and a `== .week` ternary in another — the two spellings of the+    /// same three words were free to drift, and a third unit would have had to+    /// be remembered in both.+    var noun: String {+        switch self {+        case .week: "week"+        case .month: "month"+        case .allTime: "all time"+        }+    } } -/// 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).+/// What a graph covers: one whole calendar week, one whole calendar month, or+/// the library's whole history. A bounded case carries the period's own start,+/// so a scope is a complete answer without a clock beside it. nonisolated enum StatsScope: Equatable, Sendable {-    case period(StatsPeriod)+    case week(start: Date)     case month(start: Date)+    case allTime      /// 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) }+    var isAllTime: Bool { self == .allTime } }  nonisolated enum StatsBarUnit: Equatable, Sendable { case day, month }@@ -101,6 +130,27 @@ nonisolated struct StatsPeriodFigures: Equatable, Sendable {     let works: Int } +/// One row of the "Most read works" list: a work the span's notes came from,+/// and how many of them it holds. Only a *resolved* work reference reaches this+/// list (Q13) — an unattached note and one whose work resolves to no title are+/// counted in the sites list and nowhere here.+nonisolated struct StatsRankingRow: Identifiable, Equatable, Sendable {+    let workID: UUID+    let title: String+    let count: Int++    var id: UUID { workID }+}++/// One row of the "Most read sites" list. A site is the note's capture hostname+/// (Q4): work-level attribution would be wrong for a work read on two sites.+nonisolated struct StatsSiteRankingRow: Identifiable, Equatable, Sendable {+    let hostname: String+    let count: Int++    var id: String { hostname }+}+ /// 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.@@ -113,51 +163,165 @@ nonisolated struct StatsGraph: Equatable, Sendable {     /// 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?+    /// The oldest usable capture date the library holds, or nil where it holds+    /// none. Already the lower end of the All-time span; published here because+    /// it is also navigation's backward clamp, and re-deriving it in the view+    /// would mean a second pass over every row.+    let earliestUsableCapture: Date?+    /// Req 2.10, for every scope including All time (Q12): a header that changed+    /// shape on one toggle position costs more than the near-duplicate does.+    /// Zeros where the scope resolves no span at all.+    let periodFigures: StatsPeriodFigures+    /// The span's five most-read works and sites, longest first.+    let topWorks: [StatsRankingRow]+    let topSites: [StatsSiteRankingRow] }  // 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).+/// What the chevrons and the date picker are allowed to do from where the page+/// currently stands. The view reads only this, never the stored anchor: a bound+/// that moves after a republish — notes reconciled away, so the library's+/// earliest capture is later than it was — has to disable the chevron rather+/// than let a step walk past it.+nonisolated struct StatsPeriodBounds: Equatable, Sendable {+    let canStepBackward: Bool+    let canStepForward: Bool+    /// What the date picker offers: the lower period's start through `now`.+    let selectableRange: ClosedRange<Date>+    /// The instant the date picker opens on, inside `selectableRange` by+    /// construction. An anchor names its period by an instant *inside* it+    /// (Q18), so a nil anchor — the current period — resolves to `now`, which+    /// is inside the current period. Clamped all the same: a `.graphical+    /// DatePicker` handed a selection outside its range is undefined, and Q20+    /// lets the shown period sit below the range's lower end after a republish+    /// moved the bound. Derived here, where the clock and the range are both in+    /// hand, rather than in the view's binding, where the clamp was a second+    /// copy of this rule.+    let currentSelection: Date+}++/// The whole state machine: which unit is selected, which period of it is shown,+/// and which day is selected (Reqs 5.5, 6.7, 6.8, and `stats-period-navigation`). /// /// 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.+/// `scope` is **derived** from the stored fields and never stored, so the unit+/// and the shown period cannot disagree. Every transition takes the calendar,+/// `now` and the library's earliest usable capture, because normalising to a+/// period start and clamping to both ends is what a transition *is* here. 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?+    /// The page opens on Week, and `@State` re-initialisation is what keeps the+    /// selection from persisting across launches.+    private(set) var unit: StatsUnit = .week+    /// An instant inside the shown period, or **nil for the current one** (Q9).+    ///+    /// Nil rather than a stored week start, so a page left open past midnight+    /// re-resolves against the temporal key and still shows the current period+    /// (Req 3.7) without a timer.+    ///+    /// A stored `Date` is the shown period's **midpoint**, not its start (Q18):+    /// a boundary instant is one time-zone move away from resolving into the+    /// neighbouring period, and the midpoint is the instant furthest from both+    /// boundaries. It is normalised through `dateInterval(of:for:)` on every+    /// read, so what is stored only has to name the period, not begin it.+    private(set) var anchor: 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) }+    /// The scope the graph is derived for. Resolved on demand rather than+    /// stored, so nothing has to be re-normalised when the clock or the calendar+    /// moves under it.+    func scope(calendar: Calendar, now: Date) -> StatsScope {+        switch unit {+        case .week: .week(start: shownStart(.weekOfYear, calendar: calendar, now: now))+        case .month: .month(start: shownStart(.month, calendar: calendar, now: now))+        case .allTime: .allTime+        }+    } -    /// 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+    /// Q7's clamp, as the view sees it.+    func bounds(calendar: Calendar, now: Date, earliest: Date?) -> StatsPeriodBounds {+        guard let component = unit.calendarComponent else {+            // All time shows no chevron row and presents no picker; the range is+            // stated only to keep the type total.+            let range = min(earliest ?? now, now)...now+            return StatsPeriodBounds(+                canStepBackward: false, canStepForward: false,+                selectableRange: range, currentSelection: selection(in: range, now: now))+        }+        let limits = limits(component, calendar: calendar, now: now, earliest: earliest)+        let shown = shownStart(component, calendar: calendar, now: now)+        // Q20: the shown period can sit *below* the lower bound after a+        // republish moved it, and a `.graphical DatePicker(in:)` whose selection+        // lies outside its range is undefined. Widening to hold the shown period+        // costs nothing — a step or a pick out of that widened range is clamped+        // by `show(_:)` either way.+        let range = min(limits.lower, shown)...now+        return StatsPeriodBounds(+            canStepBackward: shown > limits.lower,+            canStepForward: shown < limits.current,+            selectableRange: range, currentSelection: selection(in: range, now: now))     } -    /// Reqs 5.2, 5.3: an All-time bar navigates rather than selecting.-    mutating func open(month: Date) {-        openedMonth = month+    /// The stored anchor, or `now` where there is none (Q24), clamped into the+    /// range the picker is bounded to.+    private func selection(in range: ClosedRange<Date>, now: Date) -> Date {+        min(max(anchor ?? now, range.lowerBound), range.upperBound)     } -    /// 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+    /// Q10: switching unit keeps the shown date rather than jumping to today, so+    /// widening the view does not lose the reader's place. All time has no shown+    /// date, so its two neighbours open on the current period.+    ///+    /// Q27 is the one exemption: a nil anchor *is* "the current period" (Q15),+    /// so a switch from it has to land on the current period. Carrying the+    /// current week's **start** into the month resolution does not — a week that+    /// begins in the previous month makes the default state open Month on the+    /// month before this one. A dated anchor keeps Q10 exactly.+    ///+    /// The one guard states the invariant rather than defending against it:+    /// All time is the unit with no component, and it is also the unit that+    /// always holds a nil anchor — `show(_:)` clears the anchor whenever the+    /// unit has no component — so "a dated anchor" and "a unit with a component"+    /// are the same condition, and the else branch is the current period either+    /// way.+    mutating func select(unit newUnit: StatsUnit, calendar: Calendar, now: Date, earliest: Date?) {+        guard newUnit != unit else { return }+        guard anchor != nil, let component = unit.calendarComponent else {+            unit = newUnit+            // Req 5.5 still applies: the unit changed, so the period on screen+            // did, and a selected day would point at a different bar.+            selectedDay = nil+            return+        }+        let shown = shownStart(component, calendar: calendar, now: now)+        unit = newUnit+        show(shown, calendar: calendar, now: now, earliest: earliest)+    }++    /// One period back (`-1`) or forward (`+1`). A step at its limit is clamped+    /// back onto the period it started from — the chevron is disabled there, and+    /// this is what keeps that true if it is ever tapped anyway.+    mutating func step(by units: Int, calendar: Calendar, now: Date, earliest: Date?) {+        guard let component = unit.calendarComponent else { return }+        let shown = shownStart(component, calendar: calendar, now: now)+        guard let moved = calendar.date(byAdding: component, value: units, to: shown) else { return }+        show(moved, calendar: calendar, now: now, earliest: earliest)+    }++    /// The date picker's result: the week or month containing the picked date.+    mutating func pick(date: Date, calendar: Calendar, now: Date, earliest: Date?) {+        show(date, calendar: calendar, now: now, earliest: earliest)+    }++    /// Q5: an All-time bar switches the unit to Month at that bar's month rather+    /// than pushing a screen. The chevrons then step months.+    mutating func open(month: Date, calendar: Calendar, now: Date, earliest: Date?) {+        unit = .month+        show(month, calendar: calendar, now: now, earliest: earliest)     }      /// Req 6.8: selecting the already-selected bar clears the breakdown.@@ -171,6 +335,181 @@ nonisolated struct StatsNavigation: Equatable, Sendable {     mutating func clearSelection() {         selectedDay = nil     }++    // MARK: - The one transition++    /// Every transition ends here: normalise to the period, clamp to both ends,+    /// store nil where that lands on the current period, and clear the selection+    /// (Req 5.5 — a selection is a question about one bar, and carrying it+    /// across a period change would point it at a different one).+    ///+    /// The clamp compares period *starts*, which are a total order over periods;+    /// what is stored is the chosen period's midpoint (Q18).+    private mutating func show(_ date: Date, calendar: Calendar, now: Date, earliest: Date?) {+        selectedDay = nil+        guard let component = unit.calendarComponent else {+            anchor = nil+            return+        }+        let limits = limits(component, calendar: calendar, now: now, earliest: earliest)+        let start = min(max(periodStart(of: date, component, calendar), limits.lower), limits.current)+        // Q15: a step or pick landing on the current period stores nil, so the+        // page tracks the clock again exactly as if it had never been left.+        anchor = start == limits.current ? nil : periodMidpoint(of: start, component, calendar)+    }++    /// The start of the period on screen. `anchor` names a period rather than+    /// beginning it (Q18), so it is resolved through the calendar in hand on+    /// every read — which is also what absorbs a time-zone change since it was+    /// stored.+    private func shownStart(_ component: Calendar.Component, calendar: Calendar, now: Date) -> Date {+        periodStart(of: anchor ?? now, component, calendar)+    }++    /// The two periods navigation is clamped between: the one holding the+    /// library's earliest usable capture, and the one holding `now`. A library+    /// holding no usable capture date is pinned to the current period alone.+    private func limits(+        _ component: Calendar.Component, calendar: Calendar, now: Date, earliest: Date?+    ) -> (lower: Date, current: Date) {+        let current = periodStart(of: now, component, calendar)+        let lower = earliest.map { periodStart(of: $0, component, calendar) } ?? current+        // A capture dated later than `now` is possible (Q12), and would+        // otherwise invert the clamp.+        return (min(lower, current), current)+    }++    private func periodStart(+        of date: Date, _ component: Calendar.Component, _ calendar: Calendar+    ) -> Date {+        calendar.dateInterval(of: component, for: date)?.start ?? calendar.startOfDay(for: date)+    }++    /// The instant halfway through the period holding `date` — what an anchor+    /// stores (Q18). A period start is a boundary, and a boundary instant+    /// re-resolved under a calendar at a lower UTC offset lands in the period+    /// before it; the midpoint is half a period away from either boundary, which+    /// is further than any real zone change moves it.+    private func periodMidpoint(+        of date: Date, _ component: Calendar.Component, _ calendar: Calendar+    ) -> Date {+        guard let interval = calendar.dateInterval(of: component, for: date) else { return date }+        return interval.start + interval.duration / 2+    }+}++// MARK: - Naming++/// What the shown period is called: on the chevron row, in the Req 2.10 tiles,+/// and in the two ranked-list headings.+///+/// A value-type helper rather than three computed properties on `StatsView`,+/// for the reason Q41 gives about the navigation itself: no app-layer test+/// instantiates a SwiftUI view, so a rule left in `body` is a rule nothing can+/// check — and this rule has three arms that differ only in a preposition.+///+/// The calendar is a parameter rather than `Calendar.current` read inside: a+/// week's dates depend on the first weekday, so a test that cannot state the+/// calendar can assert nothing about them. The view passes `.current`, which is+/// Q22's formatting exemption — naming a stored anchor derives nothing, and the+/// temporal key already re-renders it on a zone or first-weekday change.+nonisolated enum StatsPeriodNaming {++    /// The shown period named on its own, with no preposition — the form the+    /// chevron row shows between its two chevrons. Nil for All time, whose+    /// chevron row is absent rather than disabled.+    static func label(unit: StatsUnit, anchor: Date?, calendar: Calendar) -> String? {+        switch unit {+        case .allTime: nil+        case .week: dated(unit: unit, anchor: anchor, calendar: calendar) ?? "This week"+        case .month: dated(unit: unit, anchor: anchor, calendar: calendar) ?? "This month"+        }+    }++    /// Req 2.10's tiles: "12 notes **in** August 2026" beside "80 notes in all"+    /// (Q19). The tile reads as a sentence, so a dated period keeps its+    /// preposition and the two named ones carry their own.+    static func tilePhrase(unit: StatsUnit, anchor: Date?, calendar: Calendar) -> String {+        switch unit {+        case .allTime: "in all"+        case .week, .month: phrase(unit: unit, anchor: anchor, calendar: calendar)+        }+    }++    /// The ranked-list headings: "Most read works this week", "Most read works+    /// in August 2026", "Most read works all time". The same phrase as the+    /// tiles everywhere except All time, which is a heading over a list rather+    /// than a sentence about a number.+    static func rankingPhrase(unit: StatsUnit, anchor: Date?, calendar: Calendar) -> String {+        switch unit {+        case .allTime: "all time"+        case .week, .month: phrase(unit: unit, anchor: anchor, calendar: calendar)+        }+    }++    /// "August 2026" — also every All-time bar's spoken date.+    ///+    /// Rendered in the terms of the calendar it is handed rather than the+    /// process-wide ones (see `weekText`).+    static func monthText(_ inside: Date, calendar: Calendar) -> String {+        var style: Date.FormatStyle = .dateTime+        style = style.month(.wide).year()+        style.calendar = calendar+        style.timeZone = calendar.timeZone+        if let locale = calendar.locale { style.locale = locale }+        return inside.formatted(style)+    }++    /// A week as its first and last day — "10 – 16 Aug 2026" under en_AU, whose+    /// interval separator ICU draws as thin space, en dash, thin space.+    ///+    /// Takes an instant *inside* the week rather than its start: an anchor is+    /// the period's midpoint (Q18), and resolving it here is what keeps the one+    /// normalisation rule in the navigation's hands.+    ///+    /// Both texts format through the calendar in hand — its zone, and its locale+    /// where it carries one. In the app that calendar is `Calendar.current`, so+    /// nothing changes; in a test it is what makes the output a fact about this+    /// rule rather than about the host's region settings, so the strings can be+    /// asserted rather than merely told apart. A calendar with a nil locale+    /// states nothing about language and keeps the formatter's own.+    static func weekText(_ inside: Date, calendar: Calendar) -> String {+        let start = calendar.dateInterval(of: .weekOfYear, for: inside)?.start ?? inside+        // Six days on, by the calendar rather than by six times 86,400: a week+        // holding a daylight-saving change is not 144 hours long.+        let last = calendar.date(byAdding: .day, value: 6, to: start) ?? start+        var style: Date.IntervalFormatStyle = .interval+        style = style.day().month(.abbreviated).year()+        style.calendar = calendar+        style.timeZone = calendar.timeZone+        if let locale = calendar.locale { style.locale = locale }+        return (start..<last).formatted(style)+    }++    /// The lower-case phrase both callers share for a bounded unit.+    private static func phrase(unit: StatsUnit, anchor: Date?, calendar: Calendar) -> String {+        guard let dated = dated(unit: unit, anchor: anchor, calendar: calendar) else {+            // The unit's own noun, so a third bounded unit needs no second list+            // of these words (`StatsUnit.noun`).+            return "this \(unit.noun)"+        }+        return "in \(dated)"+    }++    /// The shown period's own dates, or nil where it is the current period —+    /// which is named rather than dated — or All time, which has no dates.+    ///+    /// Read off the stored anchor rather than off a resolved scope: Q9's nil+    /// anchor *is* "the current period", so the current week and a dated one are+    /// told apart without reading the clock.+    private static func dated(unit: StatsUnit, anchor: Date?, calendar: Calendar) -> String? {+        guard let anchor else { return nil }+        switch unit {+        case .week: return weekText(anchor, calendar: calendar)+        case .month: return monthText(anchor, calendar: calendar)+        case .allTime: return nil+        }+    } }  // MARK: - Derivation keys@@ -198,15 +537,22 @@ nonisolated struct StatsTemporalKey: Equatable { /// 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.+///+/// Keyed on the **unit and anchor** rather than on a resolved scope (Q14):+/// resolving one needs `Calendar.current` and `Date()`, and the page's whole+/// stance is that nothing derives on a `body` evaluation. The scope is resolved+/// inside `deriveIfNeeded()`, and the temporal key is what makes a nil anchor+/// re-resolve when midnight or a zone change moves the current period. nonisolated struct StatsInputKey: Equatable {     let generation: Int-    let scope: StatsScope+    let unit: StatsUnit+    let anchor: Date?     let selectedDay: Date?     let temporal: StatsTemporalKey      /// The bars do not depend on the selection.     var graphIdentity: GraphIdentity {-        GraphIdentity(generation: generation, scope: scope, temporal: temporal)+        GraphIdentity(generation: generation, period: periodIdentity)     }      /// The breakdown does not depend on the scope.@@ -214,9 +560,32 @@ nonisolated struct StatsInputKey: Equatable {         BreakdownIdentity(generation: generation, day: selectedDay, temporal: temporal)     } +    /// Which *period* the page is asking for, with no generation in it.+    ///+    /// `graphIdentity` is the derivation trigger and holds the generation, so a+    /// republish moves it — but the view also has to ask "is the graph in hand+    /// the one this screen is naming?", and by that question a republished+    /// snapshot changes nothing: the period is the same period. Asking the+    /// derivation trigger would blank the graph, the breakdown and the period+    /// tiles for the frame between the republish and the re-derivation, on every+    /// republish. This is that question.+    var periodIdentity: PeriodIdentity {+        PeriodIdentity(unit: unit, anchor: anchor, temporal: temporal)+    }++    /// The derivation trigger: a period, plus the generation that says the+    /// library beneath it moved. Nesting the period rather than restating its+    /// three fields is what keeps the two from drifting — the pair below is the+    /// same question asked with and without the generation, and a field added to+    /// one had to be remembered in the other.     struct GraphIdentity: Equatable {         let generation: Int-        let scope: StatsScope+        let period: PeriodIdentity+    }++    struct PeriodIdentity: Equatable {+        let unit: StatsUnit+        let anchor: Date?         let temporal: StatsTemporalKey     } @@ -273,21 +642,26 @@ nonisolated enum StatsDerivation {         presentation: RecentPresentation,         works: [WorkSnapshot],         scope: StatsScope,-        calendar: Calendar,-        now: Date+        calendar: Calendar     ) -> 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) }+        // Both the All-time span and navigation's backward clamp are this one+        // instant, so it is found once rather than by two passes over every+        // dated note in the library.+        let earliestCapture = captureDates.min()         let unit = barUnit(for: scope)         let resolvedSpan = span(-            for: scope, calendar: calendar, now: now, captureDates: captureDates)+            for: scope, calendar: calendar, captureDates: captureDates,+            earliest: earliestCapture)         let bars =             resolvedSpan.map {                 buckets(in: $0, unit: unit, calendar: calendar, captureDates: captureDates)             } ?? []+        let tallied = resolvedSpan.map { tally(in: $0, rows: rows) } ?? SpanTally.empty          return StatsGraph(             scope: scope,@@ -296,21 +670,42 @@ nonisolated enum StatsDerivation {             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) })+            earliestUsableCapture: earliestCapture,+            periodFigures: tallied.figures,+            topWorks: tallied.topWorks,+            topSites: tallied.topSites)     } -    /// Req 2.10's pair, over exactly the notes the bars count.+    /// How many rows a ranked list holds at most (Q3).+    static let rankingLimit = 5++    /// Everything the span itself says: Req 2.10's pair and the two ranked+    /// lists, from one pass over the rows.     ///     /// 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(+    private struct SpanTally {+        static let empty = SpanTally(+            figures: StatsPeriodFigures(notes: 0, works: 0), topWorks: [], topSites: [])++        let figures: StatsPeriodFigures+        let topWorks: [StatsRankingRow]+        let topSites: [StatsSiteRankingRow]+    }++    private static func tally(         in span: DateInterval, rows: [RecentPresentationRow]-    ) -> StatsPeriodFigures {+    ) -> SpanTally {         var notes = 0         var workIDs: Set<UUID> = []+        // Keyed on the work's identity, carrying its title alongside, rather+        // than on a `.work(id:title:)` case: the case's hash is over both+        // fields, so every counted row hashed a whole display title to reach a+        // bucket the id already names. The title is read off the first row of+        // each work and is the same string on the rest — a work resolves to one+        // display title (`category(of:)`).+        var workCounts: [UUID: (title: String, count: Int)] = [:]+        var siteCounts: [String: Int] = [:]         for row in rows {             guard let captured = usableCaptureDate(row),                 captured >= span.start, captured < span.end@@ -318,8 +713,51 @@ nonisolated enum StatsDerivation {             notes += 1             // A note with no work reference counts here and towards no work.             if let workID = row.entry.workID { workIDs.insert(workID) }+            // Q13/Q17: the same classification the breakdown uses, from the row+            // alone. Only a reference that resolves to a title is a work worth+            // ranking; the other two categories still count as sites.+            if case .work(let id, let title) = category(of: row) {+                workCounts[id, default: (title: title, count: 0)].count += 1+            }+            // Q4: a site is the note's capture hostname. A row without one is in+            // no site's count.+            if !row.hostname.isEmpty { siteCounts[row.hostname, default: 0] += 1 }         }-        return StatsPeriodFigures(notes: notes, works: workIDs.count)++        return SpanTally(+            figures: StatsPeriodFigures(notes: notes, works: workIDs.count),+            topWorks: rankedWorks(workCounts),+            topSites: rankedSites(siteCounts))+    }++    /// The top five works by the same ordering the breakdown uses (Q3, Q9),+    /// selected rather than sorted: a bounded insertion keeps at most+    /// `rankingLimit` rows, so a library of ten thousand works pays a scan+    /// rather than a full sort to publish five rows. The ordering is a total+    /// order (the uuid tie-break), so the five selected and their order are the+    /// same five a full sort would have put in front.+    private static func rankedWorks(+        _ counts: [UUID: (title: String, count: Int)]+    ) -> [StatsRankingRow] {+        var top: [StatsRankingRow] = []+        top.reserveCapacity(rankingLimit + 1)+        for (id, work) in counts {+            let row = StatsRankingRow(workID: id, title: work.title, count: work.count)+            // Already full and no better than the row that would fall off it.+            if top.count == rankingLimit, let last = top.last, !precedes(row, last) { continue }+            let position = top.firstIndex { precedes(row, $0) } ?? top.count+            top.insert(row, at: position)+            if top.count > rankingLimit { top.removeLast() }+        }+        return top+    }++    private static func rankedSites(_ counts: [String: Int]) -> [StatsSiteRankingRow] {+        Array(+            counts+                .map { StatsSiteRankingRow(hostname: $0.key, count: $0.value) }+                .sorted(by: precedes)+                .prefix(rankingLimit))     }      /// The per-work composition of one day (Req 6.1).@@ -386,11 +824,32 @@ nonisolated enum StatsDerivation {             // 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)+        return precedes(+            StatsRankingRow(workID: leftID, title: leftTitle, count: left.count),+            StatsRankingRow(workID: rightID, title: rightTitle, count: right.count))+    }++    /// The work ordering itself, shared by the breakdown and the ranked list so+    /// the two cannot come to disagree about a tie: count descending, then+    /// display title, then work UUID.+    ///+    /// Q9: display title alone is not a total order — two works can carry the+    /// same title, which is exactly the unresolved-duplicate case.+    private static func precedes(_ left: StatsRankingRow, _ right: StatsRankingRow) -> Bool {+        if left.count != right.count { return left.count > right.count }+        let titleOrder = left.title.localizedStandardCompare(right.title)         if titleOrder != .orderedSame { return titleOrder == .orderedAscending }-        return leftID.uuidString < rightID.uuidString+        return left.workID.uuidString < right.workID.uuidString+    }++    /// Count descending, then hostname. A plain `String` comparison rather than+    /// `localizedStandardCompare`: a hostname is an identifier, not display+    /// text, and it is already the row's own identity.+    private static func precedes(+        _ left: StatsSiteRankingRow, _ right: StatsSiteRankingRow+    ) -> Bool {+        if left.count != right.count { return left.count > right.count }+        return left.hostname < right.hostname     }      // MARK: - Spans and buckets@@ -401,29 +860,28 @@ nonisolated enum StatsDerivation {      /// 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).+    ///+    /// A bounded scope carries its own start, so the clock reaches this only+    /// through the navigation that resolved the scope: the whole of "which week"+    /// is decided there, and stepping is one `dateInterval(of:for:)` away from+    /// any anchor whatever (Req 3.3 — never fixed-length arithmetic, so a+    /// 23-hour day and a 28-day February are each one whole period).+    ///+    /// `earliest` is `captureDates.min()`, passed in rather than found again:+    /// the caller already needs it for `earliestUsableCapture`.     private static func span(-        for scope: StatsScope, calendar: Calendar, now: Date, captureDates: [Date]+        for scope: StatsScope, calendar: Calendar, captureDates: [Date], earliest: Date?     ) -> DateInterval? {         switch scope {+        case .week(let start):+            return calendar.dateInterval(of: .weekOfYear, for: start)         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):+        case .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(),+            guard let earliest, let latest = captureDates.max(),                 let first = calendar.dateInterval(of: .month, for: earliest),                 let last = calendar.dateInterval(of: .month, for: latest)             else { return nil }
Asterism/Asterism/Views/StatsView.swift Modified +380 / -133
diff --git a/Asterism/Asterism/Views/StatsView.swift b/Asterism/Asterism/Views/StatsView.swiftindex 68c1498..1768d74 100644--- a/Asterism/Asterism/Views/StatsView.swift+++ b/Asterism/Asterism/Views/StatsView.swift@@ -44,6 +44,8 @@ struct StatsView: View {     /// 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()+    /// Q6's sheet. Not persisted anywhere: a picker left open is not a period.+    @State private var isPickingDate = false     /// Q54: the selected band's outline is an accessibility affordance, not a     /// standing part of the graph's look.     @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor@@ -52,11 +54,6 @@ struct StatsView: 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@@ -92,16 +89,10 @@ struct StatsView: View {                     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))+                        figures(graph)+                        periodControl(graph)+                        graphAndBreakdown(graph)+                        rankedLists(graph)                     } else {                         ProgressView()                             .frame(maxWidth: .infinity)@@ -120,32 +111,6 @@ struct StatsView: View {         .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 {@@ -160,22 +125,24 @@ struct StatsView: View {     // 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`.+    /// neither half of it responds to the period; the shown period's own pair+    /// joins it under every unit, All time included (Q12 — Q55's reason for+    /// hiding it there is outweighed by a header that would otherwise change+    /// shape on one toggle position). 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)+    private func figures(_ graph: StatsGraph) -> some View {+        // The cached graph can still describe the period 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+        // period-independent and needs no such guard.+        let period = isGraphCurrent ? graph.periodFigures : nil+        let phrase = periodPhrase         return ViewThatFits(in: .horizontal) {             HStack(alignment: .top, spacing: 12) {                 lifetimeTiles(graph)@@ -246,46 +213,197 @@ struct StatsView: View {      // 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)+    /// The unit capsule, and beneath it the chevron row for a bounded unit+    /// (`stats-period-navigation`). All time shows no row at all — it is one+    /// period, so there is nothing to step to and nothing to name.+    ///+    /// **Rendered only where a graph exists**, which is what disables the+    /// chevrons and the picker until the first derivation: the bounds come from+    /// the graph's own earliest usable capture, and before it there are none.+    ///+    /// Reading the clock here is not the read Q14 forbids. That rule is that+    /// nothing *derives* on a body evaluation; comparing the shown period with+    /// the library's bounds derives nothing, and the temporal key already+    /// re-renders this at midnight and on a zone change — which is what keeps a+    /// chevron from going stale on a page left open.+    private func periodControl(_ graph: StatsGraph) -> some View {+        let bounds = navigation.bounds(+            calendar: .current, now: Date(), earliest: graph.earliestUsableCapture)+        return VStack(alignment: .center, spacing: 10) {+            unitCapsule+            if let label = periodLabel {+                chevronRow(label: label, bounds: bounds)+            }+        }+        .frame(maxWidth: .infinity, alignment: .center)+        // One sheet for the control rather than one per layout candidate:+        // `ViewThatFits` builds every candidate to measure it, and a+        // presentation declared inside one of them is declared more than once.+        .sheet(isPresented: $isPickingDate) { periodPicker(bounds) }+    }++    /// Q11's three-segment capsule, on the style guide's §7 recipe — literally+    /// the control work detail's sort uses, with a third segment (Q28).+    ///+    /// The container label is what makes the row announce what the three+    /// segments are *for*; `children: .contain` keeps each of them reachable on+    /// its own, which is what a segmented control has to stay.+    private var unitCapsule: some View {+        ConstellationSegmentedControl(+            values: StatsUnit.allCases,+            // A segment's action is a navigation transition rather than an+            // assignment, so the setter is where the transition runs.+            selection: Binding(+                get: { navigation.unit },+                set: { unit in+                    navigate { navigation, calendar, now, earliest in+                        navigation.select(+                            unit: unit, calendar: calendar, now: now, earliest: earliest)                     }+                }),+            containerLabel: "Period unit",+            title: \.title,+            identifier: \.controlIdentifier)+    }++    /// Back chevron, the period's name, forward chevron — stacked where the+    /// three do not fit one line, as the capsule stacks its segments (§10).+    private func chevronRow(label: String, bounds: StatsPeriodBounds) -> some View {+        ViewThatFits(in: .horizontal) {+            HStack(spacing: 8) {+                backButton(bounds)+                periodButton(label)+                forwardButton(bounds)+            }+            VStack(alignment: .center, spacing: 8) {+                periodButton(label)+                HStack(spacing: 8) {+                    backButton(bounds)+                    forwardButton(bounds)                 }             }+        }+    }++    private func backButton(_ bounds: StatsPeriodBounds) -> some View {+        stepButton(+            -1, systemImage: "chevron.left", identifier: "stats-period-back",+            spoken: "Previous \(navigation.unit.noun)",+            enabled: bounds.canStepBackward)+    }++    private func forwardButton(_ bounds: StatsPeriodBounds) -> some View {+        stepButton(+            1, systemImage: "chevron.right", identifier: "stats-period-forward",+            spoken: "Next \(navigation.unit.noun)",+            enabled: bounds.canStepForward)+    }++    /// One step, disabled at its limit. The enablement comes from the *bounds*+    /// rather than from the stored anchor, so a bound that moved after a+    /// republish disables the chevron rather than letting a step walk past it.+    private func stepButton(+        _ units: Int, systemImage: String, identifier: String, spoken: String, enabled: Bool+    ) -> some View {+        Button {+            navigate { navigation, calendar, now, earliest in+                navigation.step(by: units, calendar: calendar, now: now, earliest: earliest)+            }         } 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)+            Image(systemName: systemImage)+                .font(.subheadline.weight(.semibold))+                .foregroundStyle(enabled ? AsterismColors.cyan : AsterismColors.secondaryText)+                .frame(+                    minWidth: AsterismLayout.minHitTarget,+                    minHeight: AsterismLayout.minHitTarget)+                .contentShape(Rectangle())+                .constellationCard(cornerRadius: AsterismLayout.buttonRadius)+        }+        .buttonStyle(.plain)+        .disabled(!enabled)+        .accessibilityIdentifier(identifier)+        .accessibilityLabel(spoken)+    }++    /// The period's name, and the date picker's trigger (Q6). A `Button`+    /// presenting a `.graphical` picker rather than a `.compact` one being the+    /// label: a compact picker draws its own date text and cannot read "This+    /// week" or a week's range.+    private func periodButton(_ label: String) -> some View {+        Button {+            isPickingDate = true+        } label: {+            Text(label)+                .font(.subheadline.weight(.semibold))+                .foregroundStyle(AsterismColors.cyan)+                // Req 7.8's rule for this row: the name wraps rather than+                // truncating, whatever the text size.+                .fixedSize(horizontal: false, vertical: true)+                .padding(.vertical, 8)+                .padding(.horizontal, 14)+                .frame(+                    minWidth: AsterismLayout.minHitTarget,+                    minHeight: AsterismLayout.minHitTarget)+                .contentShape(Rectangle())+                .constellationCard(cornerRadius: AsterismLayout.buttonRadius)+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("stats-period-picker")+        .accessibilityLabel("Period, \(label)")+        .accessibilityHint("Opens a date picker")+    }++    /// The picker itself, bounded to what navigation allows (Q7): the lower+    /// period's start through now.+    private func periodPicker(_ bounds: StatsPeriodBounds) -> some View {+        NavigationStack {+            DatePicker(+                "Period", selection: pickedDate(bounds), in: bounds.selectableRange,+                displayedComponents: .date+            )+            .datePickerStyle(.graphical)+            .labelsHidden()+            .padding(16)+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)+            .navigationTitle("Show a Period")+            .navigationBarTitleDisplayMode(.inline)+            .toolbar {+                // §7: a sheet closes with the platform's X, and the word goes to+                // VoiceOver.+                ToolbarItem(placement: .cancellationAction) {+                    Button(role: .close) { isPickingDate = false }+                        .accessibilityIdentifier("stats-period-picker-close")+                        .accessibilityLabel("Cancel")+                }             }-            .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)")+    }++    /// The picker's selection.+    ///+    /// The get arm is the bounds' own `currentSelection` — the anchor-or-now+    /// clamped into the range, resolved where the clock and the range were both+    /// already in hand (Q18, Q24). The view neither reads the stored anchor nor+    /// repeats the clamp: one rule, in the place the range comes from.+    ///+    /// The set arm picks and dismisses. `pick(date:)` clamps and normalises, so+    /// a date anywhere in the range lands on a whole period.+    private func pickedDate(_ bounds: StatsPeriodBounds) -> Binding<Date> {+        Binding(+            get: { bounds.currentSelection },+            set: { date in+                navigate { navigation, calendar, now, earliest in+                    navigation.pick(date: date, calendar: calendar, now: now, earliest: earliest)+                }+                isPickingDate = false+            })     }      // MARK: - Graph and breakdown      @ViewBuilder-    private func graphAndBreakdown(_ graph: StatsGraph, for scope: StatsScope) -> some View {-        if graph.scope == scope {+    private func graphAndBreakdown(_ graph: StatsGraph) -> some View {+        if isGraphCurrent {             graphSection(graph)             breakdownSection()         }@@ -388,24 +506,47 @@ struct StatsView: View {     /// 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 }) {+        let workID: UUID? =+            switch row.category {+            case .work(let id, _): id+            case .unresolvedWork, .unattached: nil+            }+        return countRow(+            name: Self.categoryName(row.category), count: row.count, workID: workID,+            routableIdentifier: "stats-breakdown-work-row",+            inertIdentifier: "stats-breakdown-row")+    }++    /// The one row shape this page has: a name, its count, and — where the row+    /// names a work still resolving in the *current* snapshot — the route to it+    /// (Req 6.10). The breakdown, the works ranking and the sites ranking are+    /// all this row; only the two identifiers differ, and a site passes neither+    /// a work nor a routable identifier because no screen in this app is a site.+    ///+    /// The routable and inert branches are identified apart rather than sharing+    /// one identifier: a test that means "this row opens a work" would otherwise+    /// pass on a row that opens nothing.+    @ViewBuilder+    private func countRow(+        name: String, count: Int, workID: UUID? = nil, routableIdentifier: String? = nil,+        inertIdentifier: String+    ) -> some View {+        if let workID, let routableIdentifier, works.contains(where: { $0.id == workID }) {             Button {                 openWork(workID)             } label: {-                breakdownRowContent(name: name, count: row.count)+                breakdownRowContent(name: name, count: count)             }             .buttonStyle(.plain)-            .accessibilityIdentifier("stats-breakdown-work-row")+            .accessibilityIdentifier(routableIdentifier)             .accessibilityLabel("Open Work \(name)")-            .accessibilityValue(Pluralisation.count(row.count, "note", "notes"))+            .accessibilityValue(Pluralisation.count(count, "note", "notes"))         } else {-            breakdownRowContent(name: name, count: row.count)+            breakdownRowContent(name: name, count: count)                 .accessibilityElement(children: .ignore)-                .accessibilityIdentifier("stats-breakdown-row")-                .accessibilityLabel("\(name), \(Pluralisation.count(row.count, "note", "notes"))")+                .accessibilityIdentifier(inertIdentifier)+                .accessibilityLabel("\(name), \(Pluralisation.count(count, "note", "notes"))")         }     } @@ -427,6 +568,88 @@ struct StatsView: View {         .constellationCard()     } +    // MARK: - The ranked lists++    /// The shown period's most-read works and most-read sites+    /// (`stats-period-navigation`), below the graph and the breakdown.+    ///+    /// Both sections appear wherever the period holds a note, All time+    /// included, and both are absent where it holds none — a heading over+    /// nothing says less than no heading at all. Guarded by `isGraphCurrent` for+    /// the reason the period tiles are: a list under the wrong period's name is+    /// a wrong list rather than a stale one.+    @ViewBuilder+    private func rankedLists(_ graph: StatsGraph) -> some View {+        if isGraphCurrent, graph.periodFigures.notes > 0 {+            // One phrase for both headings, resolved once: it formats the stored+            // anchor through `Calendar.current`, and asking each section for its+            // own built the same week's range text twice per body evaluation.+            let phrase = rankingPhrase+            rankedSection(+                "Most read works", phrase: phrase, identifier: "stats-top-works",+                // Q13: an unattached note and one whose work resolves to no+                // title are counted as sites and in no work, so a period can+                // hold notes and rank no work at all.+                emptyMessage: "No note in this period belongs to a work.",+                emptyIdentifier: "stats-top-works-empty",+                rows: graph.topWorks+            ) { rankedWorkRow($0) }+            rankedSection(+                "Most read sites", phrase: phrase, identifier: "stats-top-sites",+                emptyMessage: "No note in this period carries a site.",+                emptyIdentifier: "stats-top-sites-empty",+                rows: graph.topSites+            ) { rankedSiteRow($0) }+        }+    }++    /// One ranked section. Generic over the rows rather than taking an `isEmpty`+    /// beside them: emptiness is a property of the collection, and a separately+    /// passed flag is a chance for a heading to claim rows it was not given.+    private func rankedSection<Rows: RandomAccessCollection, RowView: View>(+        _ title: String, phrase: String, identifier: String, emptyMessage: String,+        emptyIdentifier: String, rows: Rows, @ViewBuilder row: @escaping (Rows.Element) -> RowView+    ) -> some View where Rows.Element: Identifiable {+        VStack(alignment: .leading, spacing: 8) {+            // The identifier sits on the heading rather than on the stack: a+            // container's identifier is inherited by every descendant, which+            // would take the rows' own with it (`docs/agent-notes/testing.md`).+            Text("\(title) \(phrase)")+                .font(AsterismTypography.serifHeading)+                .foregroundStyle(AsterismColors.primaryText)+                .fixedSize(horizontal: false, vertical: true)+                .accessibilityIdentifier(identifier)++            if rows.isEmpty {+                Text(emptyMessage)+                    .font(.subheadline)+                    .foregroundStyle(AsterismColors.secondaryText)+                    .fixedSize(horizontal: false, vertical: true)+                    .padding(.vertical, 10)+                    .accessibilityIdentifier(emptyIdentifier)+            } else {+                ForEach(rows) { row($0) }+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+    }++    /// A work row routes exactly as a breakdown row does (Req 6.10), tap-time+    /// existence check included — and it is identified as one does too: the+    /// routable branch is a `Button` under `stats-top-work-row`, the inert one a+    /// plain row under `stats-top-row`.+    private func rankedWorkRow(_ row: StatsRankingRow) -> some View {+        countRow(+            name: row.title, count: row.count, workID: row.workID,+            routableIdentifier: "stats-top-work-row", inertIdentifier: "stats-top-row")+    }++    /// Q4: a site is the note's capture hostname, and there is nothing to open —+    /// no screen in this app is a site, so the row is the inert half only.+    private func rankedSiteRow(_ row: StatsSiteRankingRow) -> some View {+        countRow(name: row.hostname, count: row.count, inertIdentifier: "stats-top-site-row")+    }+     /// 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.@@ -694,24 +917,26 @@ struct StatsView: View {     /// 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.+    /// Q5's fork: an All-time bar switches the unit to Month at that bar's+    /// month, and every other bar selects its day.     private func activate(_ bar: StatsBar, in graph: StatsGraph) {         if graph.scope.isAllTime {-            navigation.open(month: bar.start)+            navigate { navigation, calendar, now, earliest in+                navigation.open(+                    month: bar.start, calendar: calendar, now: now, earliest: earliest)+            }         } 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() } })+    /// Every navigation transition, with the three things each one needs. The+    /// clock and the calendar are read **here**, inside a control's action,+    /// rather than in `body`: Q14's whole point is that a body evaluation reads+    /// neither. The backward clamp is the derived graph's own earliest capture,+    /// so it moves with the library rather than with the stored anchor.+    private func navigate(_ transition: (inout StatsNavigation, Calendar, Date, Date?) -> Void) {+        transition(&navigation, .current, Date(), graph?.earliestUsableCapture)     }      // MARK: - The derivation gate@@ -719,11 +944,28 @@ struct StatsView: View {     private var inputKey: StatsInputKey {         StatsInputKey(             generation: snapshotGeneration,-            scope: navigation.scope,+            unit: navigation.unit,+            anchor: navigation.anchor,             selectedDay: navigation.selectedDay,             temporal: temporal)     } +    /// Whether the derived graph describes the period the page is naming.+    /// The `graph.scope == scope` comparison this replaces cannot be made in+    /// `body` any more — resolving a scope needs the clock — so the *key* is+    /// compared instead, which is what decided the graph in the first place.+    ///+    /// The **period** half of the key, not `graphIdentity`: the generation is in+    /// `graphIdentity` because a republish must re-derive, but a republished+    /// snapshot does not move the period, and `body` re-evaluates before+    /// `.task(id:)` runs. Comparing the derivation trigger here would empty the+    /// graph, the breakdown and the period tiles for one frame on every+    /// republish. A generation behind is stale by a note or two; the wrong+    /// period is a wrong number under the right name, which is what this guards.+    private var isGraphCurrent: Bool {+        lastDerivedKey?.periodIdentity == inputKey.periodIdentity+    }+     /// 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).     ///@@ -739,12 +981,14 @@ struct StatsView: View {         let calendar = Calendar.current          if lastDerivedKey?.graphIdentity != key.graphIdentity {+            // Q14: the scope is resolved here, from the same clock and calendar+            // the key's temporal half was read under, so a nil anchor means the+            // current period at derivation time.             graph = StatsDerivation.graph(                 presentation: presentation,                 works: works,-                scope: navigation.scope,-                calendar: calendar,-                now: Date())+                scope: navigation.scope(calendar: calendar, now: Date()),+                calendar: calendar)         }         if lastDerivedKey?.breakdownIdentity != key.breakdownIdentity {             // Req 1.8: a republished snapshot re-derives the selected day rather@@ -777,27 +1021,36 @@ struct StatsView: View {         date.formatted(.dateTime.weekday(.wide).day().month(.wide).year())     } -    nonisolated private static func monthText(_ date: Date) -> String {-        date.formatted(.dateTime.month(.wide).year())+    /// The shown period named on its own — what the chevron row sits between its+    /// two chevrons. Nil for All time, whose row is absent rather than disabled.+    ///+    /// Q22: `Calendar.current` in a formatter, not in a derivation. The temporal+    /// key already re-renders this on a zone or first-weekday move.+    private var periodLabel: String? {+        StatsPeriodNaming.label(+            unit: navigation.unit, anchor: navigation.anchor, calendar: .current)     }      /// 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))"-        }+    /// read as a second lifetime total (Q19).+    private var periodPhrase: String {+        StatsPeriodNaming.tilePhrase(+            unit: navigation.unit, anchor: navigation.anchor, calendar: .current)+    }++    /// The same phrase, as the two ranked-list headings take it — "all time"+    /// rather than "in all", a heading over a list rather than a sentence.+    private var rankingPhrase: String {+        StatsPeriodNaming.rankingPhrase(+            unit: navigation.unit, anchor: navigation.anchor, calendar: .current)     }      /// 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)+        // Q22's exemption, as `weekText` takes it: naming a bar derives nothing,+        // and the temporal key already re-renders the graph on a zone change.+        unit == .day ? dayText(date) : StatsPeriodNaming.monthText(date, calendar: .current)     }      /// Where a date label sits relative to its tick: the span's first label@@ -819,30 +1072,24 @@ struct StatsView: View {     } } -// MARK: - Period titles+// MARK: - Unit titles -nonisolated extension StatsPeriod {-    /// Req 3.1's five labels, in the order the control offers them.+nonisolated extension StatsUnit {+    /// The three 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 .week: "Week"+        case .month: "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 {+    /// The capsule segment's accessibility identifier.+    var controlIdentifier: String {         switch self {-        case .thisWeek: "this week"-        case .lastWeek: "last week"-        case .thisMonth: "this month"-        case .lastMonth: "last month"-        case .allTime: "in all"+        case .week: "stats-unit-week"+        case .month: "stats-unit-month"+        case .allTime: "stats-unit-all"         }     } }
Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift Modified +140 / -0
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swiftindex ec07dc1..e4edf16 100644--- a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift@@ -193,3 +193,143 @@ extension View {             .contentShape(shape)     } }++// MARK: - Segmented capsule++/// The segmented capsule of §7: two or three short labels, all of them visible,+/// so the reader sees the state instead of opening something to read it.+///+/// One definition for both of its users — work detail's `Newest | Chapter` sort+/// (`work-detail-reading-redesign` Decision 1, Q21) and Stats' `Week | Month |+/// All time` unit (`stats-period-navigation` Q11, Q28). The two were+/// line-for-line copies of each other, and a copy is a chance for the two to+/// disagree.+///+/// Recipe: `cardFill` inside a 1 pt `cardBorder`; the selected segment takes a+/// cyan .14 fill, a cyan .4 border and cyan text, the rest dim. The visual is+/// 32 pt scaled with Dynamic Type, inside a 44 pt target (§10) — the scaled+/// visual is the floor once it passes 44, or the selected fill would overflow+/// the row it is drawn behind.+///+/// At the largest accessibility sizes the labels cannot share a line even on a+/// line of their own, so a second `ViewThatFits` candidate stacks them full+/// width in a card-radius rectangle. §10 forbids clipping and truncation, and a+/// hyphenated control label reads no better than either.+public struct ConstellationSegmentedControl<Value: Hashable>: View {+    private let values: [Value]+    @Binding private var selection: Value+    private let containerLabel: String+    private let title: (Value) -> String+    private let identifier: (Value) -> String++    /// The capsule's visible height inside its 44 pt target. Scaled, so a+    /// segment grows with the label it is measured by.+    @ScaledMetric(relativeTo: .caption) private var segmentHeight: CGFloat = 32++    /// - Parameters:+    ///   - values: the segments, in the order they are offered.+    ///   - selection: the value currently shown. Assigning it is the whole+    ///     action a segment takes, so a caller that has to do more than assign+    ///     passes a `Binding` whose setter does it.+    ///   - containerLabel: spoken name for the control as a whole. Required:+    ///     the control becomes an accessibility container labelled with it, and+    ///     `children: .contain` keeps every segment individually reachable —+    ///     which is what a segmented control has to stay. A row of segments that+    ///     never says what it is *for* is a gap rather than a caller's choice.+    ///   - title: the segment's visible label.+    ///   - identifier: the segment's accessibility identifier.+    public init(+        values: [Value],+        selection: Binding<Value>,+        containerLabel: String,+        title: @escaping (Value) -> String,+        identifier: @escaping (Value) -> String+    ) {+        self.values = values+        self._selection = selection+        self.containerLabel = containerLabel+        self.title = title+        self.identifier = identifier+    }++    public var body: some View {+        ViewThatFits(in: .horizontal) {+            HStack(spacing: 0) {+                segments(fillsWidth: false)+            }+            .background {+                surface(Capsule())+                    .frame(height: segmentHeight)+            }++            VStack(alignment: .leading, spacing: 0) {+                segments(fillsWidth: true)+            }+            .background {+                // A capsule that tall reads as a pill lying on its side; the+                // card radius is the shape the rest of the screen encloses with.+                surface(+                    RoundedRectangle(cornerRadius: AsterismLayout.cardRadius, style: .continuous))+            }+        }+        // `children: .contain` rather than `.combine`: the row says what the+        // choice is *for*, and each segment stays an element of its own.+        .accessibilityElement(children: .contain)+        .accessibilityLabel(containerLabel)+    }++    /// `cardFill` inside a 1 pt `cardBorder`, on whichever shape the layout needs.+    private func surface(_ shape: some InsettableShape) -> some View {+        shape+            .fill(AsterismColors.cardFill)+            .overlay { shape.strokeBorder(AsterismColors.cardBorder, lineWidth: 1) }+            .allowsHitTesting(false)+    }++    /// Every segment, so the two layouts of the control share one definition.+    private func segments(fillsWidth: Bool) -> some View {+        ForEach(values, id: \.self) { value in+            segment(value, fillsWidth: fillsWidth)+        }+    }++    /// `.plain` on every segment, as the cast pills are: sibling buttons in one+    /// row bleed their hit areas into each other otherwise.+    private func segment(_ value: Value, fillsWidth: Bool) -> some View {+        let isSelected = selection == value+        return Button {+            selection = value+        } label: {+            Text(title(value))+                .font(.caption.weight(.semibold))+                // A segment label never wraps: it is what the capsule is+                // measured by, and "All ti-/me" is not a control label.+                .lineLimit(1)+                .fixedSize(horizontal: true, vertical: false)+                .foregroundStyle(isSelected ? AsterismColors.cyan : AsterismColors.secondaryText)+                .padding(.horizontal, 14)+                // §10: the visual is 32 pt, the target 44. Stacked, the segment+                // spans the width so its selected fill does too.+                .frame(+                    maxWidth: fillsWidth ? .infinity : nil,+                    minHeight: max(AsterismLayout.minHitTarget, segmentHeight))+                .background {+                    if isSelected {+                        Capsule()+                            .fill(AsterismColors.cyan.opacity(0.14))+                            .overlay {+                                Capsule()+                                    .strokeBorder(AsterismColors.cyan.opacity(0.4), lineWidth: 1)+                            }+                            .frame(height: segmentHeight)+                    }+                }+                .contentShape(Rectangle())+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier(identifier(value))+        // The precedent entry detail's rating toggles and the Stats bars set:+        // the chosen one says so, and the choice clears with it.+        .accessibilityAddTraits(isSelected ? [.isSelected] : [])+    }+}
Asterism/Asterism/Views/WorkDetailView.swift Modified +33 / -91
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex 9b1eb05..065b951 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -73,9 +73,6 @@ struct WorkDetailView: View {     /// Which order the spine is read in. `@State` and not stored (Q3): Newest is     /// the reading default and every open of the screen starts there.     @State private var sortOrder: WorkDetailModel.ChapterSortOrder = .newest-    /// The sort capsule's visible height inside its 44 pt target. Scaled, so the-    /// two labels still fit it at the accessibility text sizes (§10).-    @ScaledMetric(relativeTo: .caption) private var sortSegmentHeight: CGFloat = 32      init(         model: WorkDetailModel,@@ -650,95 +647,17 @@ struct WorkDetailView: View {         }     } -    /// The two-segment capsule. `.plain` on both, as the cast pills are: sibling-    /// buttons in one row bleed their hit areas into each other otherwise.-    ///-    /// At the largest accessibility sizes the two labels cannot share a line-    /// even on a line of their own, so a second candidate stacks them. §10-    /// forbids clipping and truncation, and a hyphenated control label reads no-    /// better than either.+    /// Decision 1's two-segment capsule, on the shared §7 recipe — the same+    /// control Stats' unit toggle is, with one segment fewer (`ConstellationKit`,+    /// `stats-period-navigation` Q28). The stacking, the surface, the 44 pt+    /// target and the `.isSelected` trait all live there now.     private var sortControl: some View {-        ViewThatFits(in: .horizontal) {-            HStack(spacing: 0) {-                sortSegments(fillsWidth: false)-            }-            .background {-                sortSurface(Capsule())-                    .frame(height: sortSegmentHeight)-            }--            VStack(alignment: .leading, spacing: 0) {-                sortSegments(fillsWidth: true)-            }-            .background {-                // A capsule that tall reads as a pill lying on its side; the-                // card radius is the shape the rest of the screen encloses with.-                sortSurface(-                    RoundedRectangle(cornerRadius: AsterismLayout.cardRadius, style: .continuous))-            }-        }-    }--    /// Decision 1's surface: `cardFill` inside a 1 pt `cardBorder`, on whichever-    /// shape the layout needs.-    private func sortSurface(_ shape: some InsettableShape) -> some View {-        shape-            .fill(AsterismColors.cardFill)-            .overlay { shape.strokeBorder(AsterismColors.cardBorder, lineWidth: 1) }-            .allowsHitTesting(false)-    }--    /// Both segments, so the two layouts of the control share one definition.-    @ViewBuilder-    private func sortSegments(fillsWidth: Bool) -> some View {-        sortSegment(-            "Newest", order: .newest, identifier: "work-detail-sort-newest",-            fillsWidth: fillsWidth)-        sortSegment(-            "Chapter", order: .chapter, identifier: "work-detail-sort-chapter",-            fillsWidth: fillsWidth)-    }--    private func sortSegment(-        _ title: String, order: WorkDetailModel.ChapterSortOrder, identifier: String,-        fillsWidth: Bool-    ) -> some View {-        let isSelected = sortOrder == order-        return Button {-            sortOrder = order-        } label: {-            Text(title)-                .font(.caption.weight(.semibold))-                // A segment label never wraps: it is what the capsule is-                // measured by, and "New-/est" is not a control label.-                .lineLimit(1)-                .fixedSize(horizontal: true, vertical: false)-                .foregroundStyle(isSelected ? AsterismColors.cyan : AsterismColors.secondaryText)-                .padding(.horizontal, 14)-                // §10: the visual is 32 pt, the target is 44. Stacked, the-                // segment spans the width so its selected fill does too. The-                // scaled visual is the floor once it passes 44, or the fill-                // would overflow the row it is drawn behind.-                .frame(-                    maxWidth: fillsWidth ? .infinity : nil,-                    minHeight: max(AsterismLayout.minHitTarget, sortSegmentHeight))-                .background {-                    if isSelected {-                        Capsule()-                            .fill(AsterismColors.cyan.opacity(0.14))-                            .overlay {-                                Capsule().strokeBorder(AsterismColors.cyan.opacity(0.4), lineWidth: 1)-                            }-                            .frame(height: sortSegmentHeight)-                    }-                }-                .contentShape(Rectangle())-        }-        .buttonStyle(.plain)-        .accessibilityIdentifier(identifier)-        // The precedent entry detail's rating toggles and the Stats bars set:-        // the chosen one says so, and the choice clears with it.-        .accessibilityAddTraits(isSelected ? [.isSelected] : [])+        ConstellationSegmentedControl(+            values: WorkDetailModel.ChapterSortOrder.allCases,+            selection: $sortOrder,+            containerLabel: "Sort order",+            title: \.title,+            identifier: \.controlIdentifier)     }      /// Edit mode's structural actions (Decision 5). Merge and Delete change what@@ -1903,3 +1822,26 @@ private struct WorkChapterRowView: View {             : date.formatted(date: .abbreviated, time: .omitted)     } }++// MARK: - Sort order titles++/// The two orders as the capsule offers them, stated beside the control that+/// shows them rather than on the model that defines them — the split+/// `StatsUnit` takes for its own segments. Exhaustive switches, so an order+/// added to the model cannot reach the control unnamed.+nonisolated extension WorkDetailModel.ChapterSortOrder {+    var title: String {+        switch self {+        case .newest: "Newest"+        case .chapter: "Chapter"+        }+    }++    /// The capsule segment's accessibility identifier.+    var controlIdentifier: String {+        switch self {+        case .newest: "work-detail-sort-newest"+        case .chapter: "work-detail-sort-chapter"+        }+    }+}
Asterism/Asterism/ViewModels/WorkDetailModel.swift Modified +7 / -1
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex b75a772..365a748 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -34,7 +34,13 @@ public final class WorkDetailModel {      /// Which order the spine is read in. The choice is the view's `@State` and     /// lasts a visit (Q3): Newest is the reading default, and nothing is stored.-    public enum ChapterSortOrder: Equatable, Sendable {+    /// `Hashable` so the shared segmented capsule can key its segments on it+    /// (`ConstellationSegmentedControl`), and `CaseIterable` so the capsule is+    /// handed the orders themselves rather than a hand-written list of them —+    /// the declaration order *is* the order the segments are offered in, and an+    /// order added here reaches the control without a second edit.+    /// The cases carry nothing, so both conformances are synthesised.+    public enum ChapterSortOrder: Hashable, CaseIterable, Sendable {         /// The repository's own order — `lastSharedAt` descending, untouched.         case newest         /// Notes whose URL sequence is a site-wide id, by that id; then notes
Asterism/AsterismTests/StatsDerivationTests.swift Modified +1190 / -187
diff --git a/Asterism/AsterismTests/StatsDerivationTests.swift b/Asterism/AsterismTests/StatsDerivationTests.swiftindex 02a012b..a1acefa 100644--- a/Asterism/AsterismTests/StatsDerivationTests.swift+++ b/Asterism/AsterismTests/StatsDerivationTests.swift@@ -48,10 +48,12 @@ struct StatsDerivationTests {         capturedAt: Date,         workID: UUID? = nil,         workTitle: String? = nil,+        hostname: String = "example.com",         lastSharedAt: Date = TestFixtures.fixedDate,         attention: RecentRowAttention? = nil     ) -> RecentPresentationRow {         TestFixtures.makeRecentRow(+            hostname: hostname,             workDisplayTitle: workTitle,             lastSharedAt: lastSharedAt,             workID: workID,@@ -59,6 +61,18 @@ struct StatsDerivationTests {             attention: attention)     } +    /// The week holding `date`, which is what a Week unit and an anchor resolve+    /// to. Stated as a helper so a case names the day it means rather than a+    /// week-start instant worked out by hand.+    private func week(_ date: Date, in calendar: Calendar) -> StatsScope {+        .week(start: calendar.dateInterval(of: .weekOfYear, for: date)!.start)+    }++    /// The month holding `date`.+    private func month(_ date: Date, in calendar: Calendar) -> StatsScope {+        .month(start: calendar.dateInterval(of: .month, for: date)!.start)+    }+     /// 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 {@@ -71,20 +85,19 @@ struct StatsDerivationTests {         _ rows: [RecentPresentationRow],         scope: StatsScope,         works: [WorkSnapshot] = [],-        calendar: Calendar,-        now: Date+        calendar: Calendar     ) -> StatsGraph {         StatsDerivation.graph(             presentation: presentation(rows), works: works,-            scope: scope, calendar: calendar, now: now)+            scope: scope, calendar: calendar)     }      // MARK: - Period bounds (Reqs 3.1, 3.3, 4.1) -    @Test("This week spans the seven days of the calendar week holding now")+    @Test("The current 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))+        let result = graph([], scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.unit == .day)         #expect(result.bars.count == 7)@@ -92,21 +105,27 @@ struct StatsDerivationTests {         #expect(result.bars.last?.start == date(2026, 8, 16, 0, in: calendar))     } -    @Test("Last week is the seven days before this week")-    func lastWeekBounds() {+    /// Any week, not merely this one or the one before it: the scope carries its+    /// own start, so a week two years back derives exactly as the current one+    /// does.+    @Test("An arbitrary past week is seven days of its own, whatever its counts")+    func anArbitraryWeekBucketsByDay() {         let calendar = calendar()-        let result = graph([], scope: .period(.lastWeek), calendar: calendar, now: now(in: calendar))+        let result = graph(+            [row(capturedAt: date(2024, 1, 4, in: calendar))],+            scope: week(date(2024, 1, 3, in: calendar), in: calendar), calendar: 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))+        #expect(result.bars.first?.start == date(2024, 1, 1, 0, in: calendar))+        #expect(result.bars.last?.start == date(2024, 1, 7, 0, in: calendar))+        #expect(result.bars.map(\.count) == [0, 0, 0, 1, 0, 0, 0])     } -    @Test("This month spans the calendar month holding now")+    @Test("The current month spans the calendar month holding now")     func thisMonthBounds() {         let calendar = calendar()-        let result = graph([], scope: .period(.thisMonth), calendar: calendar, now: now(in: calendar))+        let result = graph([], scope: month(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.unit == .day)         #expect(result.bars.count == 31)@@ -114,41 +133,29 @@ struct StatsDerivationTests {         #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() {+    @Test("An arbitrary past month is its own days, short months included")+    func anArbitraryMonthBucketsByDay() {         let calendar = calendar()         let result = graph(-            [], scope: .period(.lastMonth), calendar: calendar,-            now: date(2026, 3, 31, in: calendar))+            [], scope: .month(start: date(2026, 2, 1, 0, in: calendar)), calendar: calendar) -        #expect(result.bars.first?.start == date(2026, 2, 1, 0, in: calendar))+        #expect(result.unit == .day)         #expect(result.bars.count == 28)+        #expect(result.bars.first?.start == date(2026, 2, 1, 0, in: calendar))+        #expect(result.bars.last?.start == date(2026, 2, 28, 0, in: calendar))     } -    @Test("An opened month is a per-day span over that calendar month (Req 5.3)")-    func openedMonthBounds() {+    /// A month anchored on any instant inside it resolves to the same span: the+    /// navigation normalises to the period start, and the derivation normalises+    /// again rather than trusting it.+    @Test("A month anchor anywhere inside the month resolves to the whole month")+    func aMonthAnchorIsNormalised() {         let calendar = calendar()         let result = graph(-            [], scope: .month(start: date(2026, 2, 1, 0, in: calendar)),-            calendar: calendar, now: now(in: calendar))+            [], scope: .month(start: date(2026, 3, 31, 23, 59, in: calendar)), calendar: calendar) -        #expect(result.unit == .day)-        #expect(result.bars.count == 28)-        #expect(result.bars.first?.start == date(2026, 2, 1, 0, in: calendar))+        #expect(result.bars.count == 31)+        #expect(result.bars.first?.start == date(2026, 3, 1, 0, in: calendar))     }      // MARK: - First weekday (Req 3.3)@@ -159,9 +166,9 @@ struct StatsDerivationTests {         let sunday = calendar(firstWeekday: 1)          let mondayWeek = graph(-            [], scope: .period(.thisWeek), calendar: monday, now: now(in: monday))+            [], scope: week(now(in: monday), in: monday), calendar: monday)         let sundayWeek = graph(-            [], scope: .period(.thisWeek), calendar: sunday, now: now(in: sunday))+            [], scope: week(now(in: sunday), in: sunday), calendar: sunday)          // Sunday 16 August closes the Monday-based week and opens the         // Sunday-based one.@@ -192,8 +199,7 @@ struct StatsDerivationTests {                 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))+            scope: month(date(2026, 3, 20, in: calendar), in: calendar), calendar: 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)@@ -203,8 +209,7 @@ struct StatsDerivationTests {                 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))+            scope: month(date(2026, 11, 20, in: calendar), in: calendar), calendar: calendar)         #expect(november.bars.count == 30)         #expect(november.bars.first(where: { $0.start == fallBack })?.count == 2)     }@@ -226,7 +231,7 @@ struct StatsDerivationTests {                 // Exactly the week's closing instant: the *next* week's.                 row(capturedAt: weekEnd),             ],-            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.bars.first?.count == 1)         #expect(result.bars.reduce(0) { $0 + $1.count } == 1)@@ -243,7 +248,7 @@ struct StatsDerivationTests {                 row(capturedAt: thursday),                 row(capturedAt: thursday.addingTimeInterval(-1)),             ],-            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.bars.first(where: { $0.start == thursday })?.count == 1)         #expect(@@ -263,7 +268,7 @@ struct StatsDerivationTests {                 row(capturedAt: Date(timeIntervalSince1970: -100)),                 row(capturedAt: date(2026, 8, 12, in: calendar)),             ],-            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))+            scope: .allTime, calendar: calendar)          #expect(result.totalNotes == 3)         #expect(result.undatedNoteCount == 2)@@ -289,7 +294,7 @@ struct StatsDerivationTests {         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))+                scope: .allTime, calendar: calendar)              #expect(result.totalNotes == 2, "\(captured)")             #expect(result.undatedNoteCount == 1, "\(captured)")@@ -307,7 +312,7 @@ struct StatsDerivationTests {          let result = graph(             [row(capturedAt: floor)],-            scope: .month(start: floor), calendar: calendar, now: now(in: calendar))+            scope: .month(start: floor), calendar: calendar)          #expect(result.undatedNoteCount == 0)         #expect(result.bars.count == 31)@@ -319,7 +324,7 @@ struct StatsDerivationTests {         let calendar = calendar()         let result = graph(             [row(capturedAt: Date(timeIntervalSince1970: 0))],-            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.bars.count == 7)         #expect(result.bars.allSatisfy { $0.count == 0 })@@ -338,7 +343,7 @@ struct StatsDerivationTests {                 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))+            scope: .allTime, calendar: calendar)          #expect(result.unit == .month)         #expect(@@ -360,7 +365,7 @@ struct StatsDerivationTests {                 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))+            scope: .allTime, calendar: calendar)          #expect(result.bars.count == 3)         #expect(result.bars.last?.start == date(2026, 10, 1, 0, in: calendar))@@ -374,7 +379,7 @@ struct StatsDerivationTests {         let calendar = calendar()         let result = graph(             [row(capturedAt: date(2026, 8, 12, in: calendar))],-            scope: .period(.thisMonth), calendar: calendar, now: now(in: calendar))+            scope: month(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.bars.count == 31)         #expect(result.bars.filter { $0.count == 0 }.count == 30)@@ -388,10 +393,10 @@ struct StatsDerivationTests {         // usable date, and stays selectable.         let noSpan = graph(             [row(capturedAt: Date(timeIntervalSince1970: 0))],-            scope: .period(.allTime), calendar: calendar, now: now(in: calendar))+            scope: .allTime, calendar: calendar)         // Req 4.6: an empty week is still seven bars.         let spanOfZeros = graph(-            [], scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            [], scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(noSpan.bars.isEmpty)         #expect(spanOfZeros.bars.count == 7)@@ -402,7 +407,7 @@ struct StatsDerivationTests {     @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))+        let result = graph([], scope: .allTime, calendar: calendar)          #expect(result.bars.isEmpty)         #expect(result.totalNotes == 0)@@ -433,7 +438,7 @@ struct StatsDerivationTests {         ]         let result = StatsDerivation.graph(             presentation: RecentPresentation(groups: groups, actionableCount: 0),-            works: [], scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            works: [], scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.bars.map(\.start) == result.bars.map(\.start).sorted())         #expect(result.bars.map(\.index) == Array(0..<7))@@ -458,7 +463,7 @@ struct StatsDerivationTests {                 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))+            scope: week(now(in: calendar), in: calendar), works: works, calendar: calendar)          #expect(result.totalWorks == 3)         #expect(result.totalNotes == 2)@@ -472,12 +477,16 @@ struct StatsDerivationTests {             row(capturedAt: date(2026, 8, 12, in: calendar)),             row(capturedAt: date(2024, 1, 2, in: calendar)),         ]-        let periods: [StatsPeriod] = [.thisWeek, .lastWeek, .thisMonth, .lastMonth, .allTime]+        let scopes: [StatsScope] = [+            week(now(in: calendar), in: calendar),+            week(date(2026, 8, 4, in: calendar), in: calendar),+            month(now(in: calendar), in: calendar),+            month(date(2026, 7, 15, in: calendar), in: calendar),+            .allTime,+        ] -        for period in periods {-            let result = graph(-                rows, scope: .period(period), works: works,-                calendar: calendar, now: now(in: calendar))+        for scope in scopes {+            let result = graph(rows, scope: scope, works: works, calendar: calendar)             #expect(result.totalNotes == 2)             #expect(result.totalWorks == 1)         }@@ -501,17 +510,22 @@ struct StatsDerivationTests {             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),+        let expected: [(StatsScope, StatsPeriodFigures)] = [+            (week(now(in: calendar), in: calendar), StatsPeriodFigures(notes: 2, works: 2)),+            (+                week(date(2026, 8, 4, in: calendar), in: calendar),+                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),+            (month(now(in: calendar), in: calendar), StatsPeriodFigures(notes: 4, works: 2)),+            (+                month(date(2026, 7, 15, in: calendar), in: calendar),+                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)")+        for (scope, figures) in expected {+            let result = graph(rows, scope: scope, calendar: calendar)+            #expect(result.periodFigures == figures, "\(scope)")         }     } @@ -526,8 +540,7 @@ struct StatsDerivationTests {                 // 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))+            scope: .month(start: date(2026, 6, 1, 0, in: calendar)), calendar: calendar)          #expect(result.periodFigures == StatsPeriodFigures(notes: 2, works: 1))     }@@ -537,7 +550,7 @@ struct StatsDerivationTests {         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))+            scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.periodFigures == StatsPeriodFigures(notes: 0, works: 0))     }@@ -554,7 +567,7 @@ struct StatsDerivationTests {                 // 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))+            scope: week(now(in: calendar), in: calendar), calendar: calendar)          #expect(result.periodFigures == StatsPeriodFigures(notes: 3, works: 2))     }@@ -564,20 +577,44 @@ struct StatsDerivationTests {         let calendar = calendar()         let result = graph(             [row(capturedAt: Date(timeIntervalSince1970: 0), workID: UUID(), workTitle: "Serial")],-            scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            scope: week(now(in: calendar), in: calendar), calendar: 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() {+    /// Q12: All time carries the pair too, so the header keeps one shape across+    /// the toggle. Its notes figure is the lifetime total less the undated+    /// notes, and that near-duplicate is expected.+    @Test("All time's figures are the lifetime notes total less the undated ones")+    func allTimeReportsTheWholePlottedLibrary() {+        let calendar = calendar()+        let serial = UUID()+        let result = graph(+            [+                row(capturedAt: date(2026, 8, 12, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2024, 1, 2, in: calendar), workID: serial, workTitle: "Serial"),+                row(capturedAt: date(2024, 3, 9, in: calendar), workID: UUID()),+                // Undated, so in neither figure.+                row(capturedAt: Date(timeIntervalSince1970: 0), workID: UUID()),+            ],+            scope: .allTime, calendar: calendar)++        #expect(result.periodFigures.notes == result.totalNotes - result.undatedNoteCount)+        #expect(result.periodFigures == StatsPeriodFigures(notes: 3, works: 2))+    }++    /// Req 3.6: All time over a library holding no usable capture date resolves+    /// no span at all, and the pair reads zero rather than being absent.+    @Test("A nil-span All time reports zero of each")+    func allTimeWithoutASpanReportsZeroes() {         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))+            [row(capturedAt: Date(timeIntervalSince1970: 0), workID: UUID())],+            scope: .allTime, calendar: calendar) -        #expect(result.periodFigures == nil)+        #expect(result.bars.isEmpty)+        #expect(result.periodFigures == StatsPeriodFigures(notes: 0, works: 0))     }      @Test("The period's notes figure equals the sum of the period's own bars")@@ -587,19 +624,213 @@ struct StatsDerivationTests {             row(capturedAt: date(2026, 8, 10 + index, in: calendar), workID: UUID())         }         let result = graph(-            rows, scope: .period(.thisWeek), calendar: calendar, now: now(in: calendar))+            rows, scope: week(now(in: calendar), in: calendar), calendar: calendar) -        #expect(result.periodFigures?.notes == result.bars.reduce(0) { $0 + $1.count })-        #expect(result.periodFigures?.works == 6)+        #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 scope = month(date(2026, 7, 15, in: calendar), in: calendar)+        let result = graph([], scope: scope, calendar: calendar)++        #expect(result.scope == scope)+        #expect(result.scope == .month(start: date(2026, 7, 1, 0, in: calendar)))+    }++    // MARK: - The earliest usable capture (the backward clamp)++    @Test("The graph carries the library's earliest usable capture date")+    func theGraphCarriesTheEarliestCapture() {+        let calendar = calendar()+        let earliest = date(2024, 1, 2, in: calendar)+        let rows = [+            row(capturedAt: date(2026, 8, 12, in: calendar)),+            row(capturedAt: earliest),+            // Decision 6's unhydrated row is not a capture date, so it is not+            // the clamp either — a 1970 anchor would put the picker's range 55+            // years wide.+            row(capturedAt: Date(timeIntervalSince1970: 0)),+        ]++        // Whatever the scope: the clamp is a property of the library, not of+        // the period on screen.+        for scope in [week(now(in: calendar), in: calendar), .allTime] as [StatsScope] {+            #expect(graph(rows, scope: scope, calendar: calendar).earliestUsableCapture == earliest)+        }+    }++    @Test("A library holding no usable capture date carries no earliest one")+    func noUsableCaptureLeavesNoEarliest() {         let calendar = calendar()         let result = graph(-            [], scope: .period(.lastMonth), calendar: calendar, now: now(in: calendar))+            [row(capturedAt: Date(timeIntervalSince1970: 0))],+            scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.earliestUsableCapture == nil)+    }++    // MARK: - The ranked lists (Q2, Q3, Q4, Q13, Q17) -        #expect(result.scope == .period(.lastMonth))+    @Test("The works list holds five rows at most, longest first")+    func theWorksListIsCappedAtFive() {+        let calendar = calendar()+        // Seven works, each read one note more than the next.+        let rows = (0..<7).flatMap { index in+            let workID = UUID()+            return (0...index).map { _ in+                row(+                    capturedAt: date(2026, 8, 12, in: calendar), workID: workID,+                    workTitle: "Serial \(index)")+            }+        }+        let result = graph(rows, scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topWorks.count == StatsDerivation.rankingLimit)+        #expect(result.topWorks.map(\.title) == ["Serial 6", "Serial 5", "Serial 4", "Serial 3", "Serial 2"])+        #expect(result.topWorks.map(\.count) == [7, 6, 5, 4, 3])+    }++    @Test("The sites list holds five rows at most, longest first")+    func theSitesListIsCappedAtFive() {+        let calendar = calendar()+        let rows = (0..<7).flatMap { index in+            (0...index).map { _ in+                row(capturedAt: date(2026, 8, 12, in: calendar), hostname: "site\(index).test")+            }+        }+        let result = graph(rows, scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topSites.count == StatsDerivation.rankingLimit)+        #expect(result.topSites.map(\.hostname) == [+            "site6.test", "site5.test", "site4.test", "site3.test", "site2.test",+        ])+        #expect(result.topSites.map(\.count) == [7, 6, 5, 4, 3])+    }++    /// The same total order the breakdown states (Q9): count, then display+    /// title, then work identity — because two works can carry one title, which+    /// is exactly the unresolved-duplicate case.+    ///+    /// "Serial 2" and "Serial 10" are the pair that tells the ordering apart: a+    /// plain `String` comparison puts "Serial 10" first, and only+    /// `localizedStandardCompare` reads the run of digits as a number.+    @Test("Works tie-break on title and then on identity")+    func theWorksListTieBreaks() {+        let calendar = calendar()+        let alpha = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!+        let alphaTwin = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!+        let second = UUID()+        let tenth = UUID()+        let zeta = UUID()+        let rows =+            (0..<2).map { _ in+                row(capturedAt: date(2026, 8, 12, in: calendar), workID: alphaTwin, workTitle: "Alpha")+            }+            + (0..<2).map { _ in+                row(capturedAt: date(2026, 8, 13, in: calendar), workID: alpha, workTitle: "Alpha")+            }+            + (0..<2).map { _ in+                row(+                    capturedAt: date(2026, 8, 13, in: calendar), workID: tenth,+                    workTitle: "Serial 10")+            }+            + (0..<2).map { _ in+                row(+                    capturedAt: date(2026, 8, 14, in: calendar), workID: second,+                    workTitle: "Serial 2")+            }+            + (0..<2).map { _ in+                row(capturedAt: date(2026, 8, 14, in: calendar), workID: zeta, workTitle: "Zeta")+            }++        let result = graph(rows, scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topWorks.map(\.workID) == [alpha, alphaTwin, second, tenth, zeta])+        #expect(result.topWorks.map(\.title) == ["Alpha", "Alpha", "Serial 2", "Serial 10", "Zeta"])+        #expect(result.topWorks.allSatisfy { $0.count == 2 })+    }++    @Test("Sites tie-break on the hostname itself")+    func theSitesListTieBreaks() {+        let calendar = calendar()+        let rows = ["zeta.test", "alpha.test", "middle.test"].map {+            row(capturedAt: date(2026, 8, 12, in: calendar), hostname: $0)+        }+        let result = graph(rows, scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topSites.map(\.hostname) == ["alpha.test", "middle.test", "zeta.test"])+    }++    /// Q13: a note that is not a work still came from a site.+    @Test("Unattached and unresolved-title notes are in no work's count and in their site's")+    func onlyResolvedWorksAreRanked() {+        let calendar = calendar()+        let serial = UUID()+        let result = graph(+            [+                row(+                    capturedAt: date(2026, 8, 12, in: calendar), workID: serial,+                    workTitle: "Serial", hostname: "one.test"),+                // A work reference that resolves to no title (Req 6.4).+                row(capturedAt: date(2026, 8, 13, in: calendar), workID: UUID(), hostname: "one.test"),+                // No work reference at all (Req 6.3).+                row(capturedAt: date(2026, 8, 14, in: calendar), hostname: "one.test"),+            ],+            scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topWorks == [StatsRankingRow(workID: serial, title: "Serial", count: 1)])+        #expect(result.topSites == [StatsSiteRankingRow(hostname: "one.test", count: 3)])+    }++    @Test("A note with no hostname is in no site's count")+    func anEmptyHostnameIsNotASite() {+        let calendar = calendar()+        let result = graph(+            [+                row(capturedAt: date(2026, 8, 12, in: calendar), hostname: ""),+                row(capturedAt: date(2026, 8, 13, in: calendar), hostname: "one.test"),+            ],+            scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topSites == [StatsSiteRankingRow(hostname: "one.test", count: 1)])+        #expect(result.periodFigures.notes == 2)+    }++    @Test("A period holding no notes ranks nothing")+    func anEmptyPeriodRanksNothing() {+        let calendar = calendar()+        let result = graph(+            [row(capturedAt: date(2024, 1, 2, in: calendar), workID: UUID(), workTitle: "Serial")],+            scope: week(now(in: calendar), in: calendar), calendar: calendar)++        #expect(result.topWorks.isEmpty)+        #expect(result.topSites.isEmpty)+    }++    @Test("All time ranks the whole plotted library")+    func allTimeRanksEverythingDated() {+        let calendar = calendar()+        let serial = UUID()+        let result = graph(+            [+                row(+                    capturedAt: date(2024, 1, 2, in: calendar), workID: serial,+                    workTitle: "Serial", hostname: "one.test"),+                row(+                    capturedAt: date(2026, 8, 12, in: calendar), workID: serial,+                    workTitle: "Serial", hostname: "one.test"),+                // Undated, so in no bar and in no ranking.+                row(+                    capturedAt: Date(timeIntervalSince1970: 0), workID: UUID(),+                    workTitle: "Never Plotted", hostname: "two.test"),+            ],+            scope: .allTime, calendar: calendar)++        #expect(result.topWorks == [StatsRankingRow(workID: serial, title: "Serial", count: 2)])+        #expect(result.topSites == [StatsSiteRankingRow(hostname: "one.test", count: 2)])     }      // MARK: - Breakdown categories (Reqs 6.3, 6.4, 6.5, Q22)@@ -859,10 +1090,24 @@ struct StatsDerivationInvariantTests {             totalNotes: rows.count)     } -    private func graph(_ library: SeededLibrary, scope: StatsScope, now: Date? = nil) -> StatsGraph {+    private func graph(_ library: SeededLibrary, scope: StatsScope) -> StatsGraph {         StatsDerivation.graph(             presentation: library.presentation, works: library.works, scope: scope,-            calendar: library.calendar, now: now ?? library.now)+            calendar: library.calendar)+    }++    /// The bounded scopes the page can ask for: the current week and month, and+    /// one of each five months back — the same shape as any other anchor, which+    /// is the point of a scope carrying its own start.+    private func boundedScopes(_ library: SeededLibrary) -> [StatsScope] {+        let calendar = library.calendar+        let earlier = calendar.date(byAdding: .month, value: -5, to: library.now)!+        return [+            .week(start: calendar.dateInterval(of: .weekOfYear, for: library.now)!.start),+            .week(start: calendar.dateInterval(of: .weekOfYear, for: earlier)!.start),+            .month(start: calendar.dateInterval(of: .month, for: library.now)!.start),+            .month(start: calendar.dateInterval(of: .month, for: earlier)!.start),+        ]     }      /// The bucket a bar covers: `[start, next start)`, and the final bar runs to@@ -880,7 +1125,7 @@ struct StatsDerivationInvariantTests {     @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))+        let result = graph(library, scope: .allTime)          for captured in library.captureDates {             let holders = result.bars.filter {@@ -893,7 +1138,7 @@ struct StatsDerivationInvariantTests {     @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))+        let result = graph(library, scope: .allTime)          #expect(result.totalNotes == library.totalNotes)         #expect(result.undatedNoteCount == library.totalNotes - library.captureDates.count)@@ -905,11 +1150,7 @@ struct StatsDerivationInvariantTests {     @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 {+        for scope in boundedScopes(library) + [.allTime] {             let result = graph(library, scope: scope)             let component: Calendar.Component = result.unit == .day ? .day : .month @@ -928,8 +1169,8 @@ struct StatsDerivationInvariantTests {     func aBoundedPeriodCountsItsOwnSpan(seed: UInt64) {         let library = library(seed: seed) -        for period in [StatsPeriod.thisWeek, .lastWeek, .thisMonth, .lastMonth] {-            let result = graph(library, scope: .period(period))+        for scope in boundedScopes(library) {+            let result = graph(library, scope: scope)             guard let first = result.bars.first, let last = result.bars.last else {                 Issue.record("a bounded period always resolves a span")                 continue@@ -941,19 +1182,59 @@ struct StatsDerivationInvariantTests {         }     } +    // MARK: - The ranked lists (Q3, Q13, Q17)++    @Test("The ranked lists are capped, ordered and never overcount", arguments: seeds)+    func rankedListsAreCappedAndOrdered(seed: UInt64) {+        let library = library(seed: seed)++        for scope in boundedScopes(library) + [.allTime] {+            let result = graph(library, scope: scope)++            #expect(result.topWorks.count <= StatsDerivation.rankingLimit)+            #expect(result.topSites.count <= StatsDerivation.rankingLimit)+            // A note counts towards at most one work and at most one site, and+            // only towards a period it falls inside.+            #expect(result.topWorks.reduce(0) { $0 + $1.count } <= result.periodFigures.notes)+            #expect(result.topSites.reduce(0) { $0 + $1.count } <= result.periodFigures.notes)+            // One row per identity, longest first.+            #expect(Set(result.topWorks.map(\.workID)).count == result.topWorks.count)+            #expect(Set(result.topSites.map(\.hostname)).count == result.topSites.count)+            for (left, right) in zip(result.topWorks, result.topWorks.dropFirst()) {+                #expect(left.count >= right.count)+            }+            for (left, right) in zip(result.topSites, result.topSites.dropFirst()) {+                #expect(left.count >= right.count)+            }+            // Q13: only a work reference that resolves to a title is ranked, and+            // the generator issues one such reference per work snapshot.+            #expect(result.topWorks.allSatisfy { row in library.works.contains { $0.id == row.workID } })+            #expect(result.topSites.allSatisfy { !$0.hostname.isEmpty })+        }+    }+     // MARK: - Purity -    @Test("Two instants in one day derive the same graph", arguments: seeds)-    func theHourOfDayDoesNotMoveTheGraph(seed: UInt64) {+    /// The clock reaches the derivation only through the scope the navigation+    /// resolved, so the property this used to state about `graph` is now a+    /// property of that resolution: two instants in one day name one period.+    @Test("Two instants in one day resolve the same scope", arguments: seeds)+    func theHourOfDayDoesNotMoveTheScope(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))+        for unit in StatsUnit.allCases {+            var navigation = StatsNavigation()+            navigation.select(+                unit: unit, calendar: library.calendar, now: library.now,+                earliest: library.captureDates.min())++            let atStart = navigation.scope(calendar: library.calendar, now: startOfDay)+            let atEnd = navigation.scope(calendar: library.calendar, now: endOfDay)+            #expect(atStart == atEnd)+            #expect(graph(library, scope: atStart) == graph(library, scope: atEnd))         }     } @@ -1042,94 +1323,564 @@ struct StatsDerivationInvariantTests {     } } -// 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).+// The page's whole state machine (Reqs 5.5, 6.7, 6.8, and the unit-and-anchor+// navigation `stats-period-navigation` puts in place of the five fixed periods).+// 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).+//+// The fixed clock is Sunday 16 August 2026 at noon under a Monday first weekday,+// so the current week is 10–16 August and the current month is August. The+// library's earliest usable capture is Wednesday 2 January 2024 unless a case+// says otherwise, which is what both chevrons are clamped against. @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+    private func calendar() -> Calendar {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        calendar.firstWeekday = 2+        return calendar+    }++    private func date(+        _ year: Int, _ month: Int, _ day: Int, _ hour: Int = 12, in calendar: Calendar+    ) -> Date {+        calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))!+    }++    /// Sunday 16 August 2026, noon.+    private func now(in calendar: Calendar) -> Date { date(2026, 8, 16, in: calendar) }++    private func earliest(in calendar: Calendar) -> Date { date(2024, 1, 2, in: calendar) }++    private func weekStart(_ date: Date, _ calendar: Calendar) -> Date {+        calendar.dateInterval(of: .weekOfYear, for: date)!.start+    }++    private func monthStart(_ date: Date, _ calendar: Calendar) -> Date {+        calendar.dateInterval(of: .month, for: date)!.start+    }++    /// What an anchor holds for the week holding `date`: its midpoint, not its+    /// start (Q18). Every assertion on a stored anchor goes through this, so the+    /// stored form is stated in one place.+    private func weekAnchor(_ date: Date, _ calendar: Calendar) -> Date {+        midpoint(of: .weekOfYear, holding: date, calendar)+    }++    private func monthAnchor(_ date: Date, _ calendar: Calendar) -> Date {+        midpoint(of: .month, holding: date, calendar)+    } -    @Test("The page opens on This week with no month and no selection (Reqs 3.2, 6.7)")+    private func midpoint(+        of component: Calendar.Component, holding date: Date, _ calendar: Calendar+    ) -> Date {+        let interval = calendar.dateInterval(of: component, for: date)!+        return interval.start + interval.duration / 2+    }++    // MARK: - Where the page opens++    @Test("The page opens on the current week, tracking the clock, with nothing selected")     func initialState() {+        let calendar = calendar()         let navigation = StatsNavigation() -        #expect(navigation.period == .thisWeek)-        #expect(navigation.openedMonth == nil)+        #expect(navigation.unit == .week)+        #expect(navigation.anchor == nil)         #expect(navigation.selectedDay == nil)-        #expect(navigation.scope == .period(.thisWeek))+        #expect(+            navigation.scope(calendar: calendar, now: now(in: calendar))+                == .week(start: date(2026, 8, 10, 0, in: calendar)))     } -    @Test("Changing the period leaves the opened month and clears the selection (Req 5.5)")-    func selectingAPeriodClearsEverythingElse() {+    /// Q9: nil is why Req 3.7 needs no timer. A stored week start would be the+    /// wrong week the moment midnight crossed into the next one.+    @Test("A nil anchor names whatever period the clock is in")+    func aNilAnchorTracksTheClock() {+        let calendar = calendar()+        let navigation = StatsNavigation()+        let nextWeek = date(2026, 8, 18, in: calendar)++        #expect(+            navigation.scope(calendar: calendar, now: now(in: calendar))+                == .week(start: date(2026, 8, 10, 0, in: calendar)))+        #expect(+            navigation.scope(calendar: calendar, now: nextWeek)+                == .week(start: date(2026, 8, 17, 0, in: calendar)))+    }++    // MARK: - Stepping++    @Test("A step back moves one period and a step forward returns to the current one")+    func steppingBackAndForwardReturnsToTheClock() {+        let calendar = calendar()+        let now = now(in: calendar)         var navigation = StatsNavigation()-        navigation.select(period: .allTime)-        navigation.open(month: march)-        navigation.toggle(day: day) -        navigation.select(period: .lastMonth)+        navigation.step(by: -1, calendar: calendar, now: now, earliest: earliest(in: calendar))+        #expect(navigation.anchor == weekAnchor(date(2026, 8, 3, 0, in: calendar), calendar))+        #expect(+            navigation.scope(calendar: calendar, now: now)+                == .week(start: date(2026, 8, 3, 0, in: calendar))) -        #expect(navigation.period == .lastMonth)-        #expect(navigation.openedMonth == nil)-        #expect(navigation.selectedDay == nil)-        #expect(navigation.scope == .period(.lastMonth))+        navigation.step(by: 1, calendar: calendar, now: now, earliest: earliest(in: calendar))+        // Q15: back on the current period is back to tracking the clock, not an+        // anchor that happens to name it today.+        #expect(navigation.anchor == nil)     } -    @Test("Opening a month makes it the scope (Reqs 5.2, 5.3)")-    func openingAMonthSetsTheScope() {+    @Test("Stepping months walks whole calendar months, short ones included")+    func steppingMonthsIsCalendarArithmetic() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)         var navigation = StatsNavigation()-        navigation.select(period: .allTime)+        navigation.select(unit: .month, calendar: calendar, now: now, earliest: earliest)+        navigation.pick(+            date: date(2026, 3, 31, in: calendar), calendar: calendar, now: now,+            earliest: earliest)++        #expect(navigation.anchor == monthAnchor(date(2026, 3, 1, 0, in: calendar), calendar)) -        navigation.open(month: march)+        // A fixed-length subtraction from the 31st lands in March again, which+        // is the defect Req 3.3 names.+        navigation.step(by: -1, calendar: calendar, now: now, earliest: earliest)+        #expect(navigation.anchor == monthAnchor(date(2026, 2, 1, 0, in: calendar), calendar)) -        #expect(navigation.openedMonth == march)-        #expect(navigation.scope == .month(start: march))+        navigation.step(by: 1, calendar: calendar, now: now, earliest: earliest)+        #expect(navigation.anchor == monthAnchor(date(2026, 3, 1, 0, in: calendar), calendar))     } -    @Test("A second month replaces the first")-    func openingAnotherMonthReplacesIt() {+    // MARK: - The clamp (Q7)++    @Test("Backward stops at the period holding the earliest usable capture")+    func backwardStopsAtTheEarliestPeriod() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)         var navigation = StatsNavigation()-        navigation.open(month: march)+        navigation.pick(date: earliest, calendar: calendar, now: now, earliest: earliest) -        navigation.open(month: april)+        #expect(navigation.anchor == weekAnchor(earliest, calendar))+        let bounds = navigation.bounds(calendar: calendar, now: now, earliest: earliest)+        #expect(bounds.canStepBackward == false)+        #expect(bounds.canStepForward) -        #expect(navigation.scope == .month(start: april))+        navigation.step(by: -1, calendar: calendar, now: now, earliest: earliest)+        #expect(navigation.anchor == weekAnchor(earliest, calendar))     } -    @Test("The selected period survives the month being open (Req 5.4)")-    func thePeriodSurvivesAnOpenedMonth() {+    @Test("Forward stops at the current period")+    func forwardStopsAtTheCurrentPeriod() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)         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))+        let bounds = navigation.bounds(calendar: calendar, now: now, earliest: earliest)+        #expect(bounds.canStepForward == false)+        #expect(bounds.canStepBackward)++        navigation.step(by: 1, calendar: calendar, now: now, earliest: earliest)+        #expect(navigation.anchor == nil)+    }++    @Test("A library holding no usable capture date refuses both chevrons")+    func noUsableCaptureRefusesBothChevrons() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation() -        navigation.closeMonth()+        let bounds = navigation.bounds(calendar: calendar, now: now, earliest: nil)+        #expect(bounds.canStepBackward == false)+        #expect(bounds.canStepForward == false)+        // Req: the picker offers the current period's start through now.+        #expect(bounds.selectableRange == weekStart(now, calendar)...now) -        #expect(navigation.period == .allTime)-        #expect(navigation.scope == .period(.allTime))+        navigation.step(by: -1, calendar: calendar, now: now, earliest: nil)+        #expect(navigation.anchor == nil)     } -    @Test("Closing a month clears the month and the selection (Req 5.4)")-    func closingAMonthClearsTheSelection() {+    @Test("The picker offers the lower period's start through now")+    func thePickerRangeIsTheClamp() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)         var navigation = StatsNavigation()-        navigation.select(period: .allTime)-        navigation.open(month: march)-        navigation.toggle(day: day) -        navigation.closeMonth()+        #expect(+            navigation.bounds(calendar: calendar, now: now, earliest: earliest).selectableRange+                == weekStart(earliest, calendar)...now)++        navigation.select(unit: .month, calendar: calendar, now: now, earliest: earliest)+        #expect(+            navigation.bounds(calendar: calendar, now: now, earliest: earliest).selectableRange+                == monthStart(earliest, calendar)...now)+    }++    /// The bound moves when the library does. Enablement is a comparison against+    /// the *current* bounds rather than a property of the stored anchor, so notes+    /// reconciled away disable the chevron instead of leaving a step past the new+    /// bound available — and the shown period itself stays where it is.+    @Test("A bound that moves after a republish disables the chevron and leaves the period")+    func aMovedBoundDisablesTheChevron() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation()+        navigation.pick(+            date: earliest(in: calendar), calendar: calendar, now: now,+            earliest: earliest(in: calendar))+        let shown = navigation.anchor++        let later = date(2025, 6, 2, in: calendar)+        let bounds = navigation.bounds(calendar: calendar, now: now, earliest: later)++        #expect(bounds.canStepBackward == false)+        #expect(bounds.canStepForward)+        #expect(navigation.anchor == shown)+        // Q20: the shown period is now *below* the lower bound, and the picker's+        // range has to hold it anyway — a `.graphical DatePicker(in:)` given a+        // selection outside its range is undefined.+        #expect(+            bounds.selectableRange == weekStart(earliest(in: calendar), calendar)...now)+        #expect(bounds.selectableRange.contains(weekStart(earliest(in: calendar), calendar)))++        // Q20's other half: forward from out of range lands on the new lower+        // bound rather than one week on from a period the library no longer has.+        navigation.step(by: 1, calendar: calendar, now: now, earliest: later)+        #expect(navigation.anchor == weekAnchor(later, calendar))+        #expect(+            navigation.bounds(calendar: calendar, now: now, earliest: later).canStepBackward+                == false)+    }++    /// Q31: the "both chevrons disabled, picker range current…now" clause+    /// describes the *fresh* state — a nil anchor with nothing to clamp to. With+    /// a period shown, Q20 governs instead: the shown period stays where it is,+    /// backward is refused because the bound has moved past it, and the one step+    /// left is forward onto the current period. The picker's range widens to+    /// hold the shown period, because a `.graphical DatePicker(in:)` given a+    /// selection outside its range is undefined.+    ///+    /// The other `earliest: nil` case covers only the fresh state, which is a+    /// different question about the same missing bound.+    @Test("A library losing every usable capture leaves a dated anchor where it is")+    func losingEveryCaptureLeavesADatedAnchor() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation()+        navigation.pick(+            date: earliest(in: calendar), calendar: calendar, now: now,+            earliest: earliest(in: calendar))+        let shown = navigation.anchor++        let bounds = navigation.bounds(calendar: calendar, now: now, earliest: nil)++        #expect(bounds.canStepBackward == false)+        #expect(bounds.canStepForward)+        #expect(navigation.anchor == shown)+        #expect(bounds.selectableRange == weekStart(earliest(in: calendar), calendar)...now)+        #expect(bounds.currentSelection == shown)++        // Forward from a period the library no longer bounds lands on the+        // current one, which is the nearest period it can still describe.+        navigation.step(by: 1, calendar: calendar, now: now, earliest: nil)+        #expect(navigation.anchor == nil)+    }++    /// Q18: an anchor is the shown period's midpoint, so a calendar at a lower+    /// UTC offset with a different first weekday still resolves it to a week+    /// holding the days the reader was looking at. A stored period *start* would+    /// not — it is a boundary instant, and the same boundary read ten hours+    /// earlier falls in the period before.+    @Test("An anchor stored under one calendar resolves to the same period under another")+    func anAnchorSurvivesATimeZoneMove() {+        var stored = Calendar(identifier: .gregorian)+        stored.timeZone = TimeZone(identifier: "Europe/Amsterdam")!+        stored.firstWeekday = 1+        // Sunday 16 August: the shown week (2–8 August) is not the current one.+        let now = date(2026, 8, 16, in: stored)+        let inTheShownWeek = date(2026, 8, 4, in: stored)+        var navigation = StatsNavigation()+        navigation.pick(+            date: inTheShownWeek, calendar: stored, now: now,+            earliest: date(2024, 1, 2, in: stored))++        let storedWeek = stored.dateInterval(of: .weekOfYear, for: inTheShownWeek)!+        #expect(navigation.anchor == storedWeek.start + storedWeek.duration / 2)++        var moved = Calendar(identifier: .gregorian)+        moved.timeZone = TimeZone(identifier: "Pacific/Honolulu")!+        moved.firstWeekday = 2++        // Monday 3 to Sunday 9 August under the moved calendar — six of the+        // seven days the reader was looking at.+        let resolved = navigation.scope(calendar: moved, now: now)+        #expect(resolved == .week(start: weekStart(date(2026, 8, 5, in: moved), moved)))+        // The naive form of the same anchor — the stored week's start instant —+        // resolves to the week before, most of it in July. That is the defect.+        #expect(weekStart(storedWeek.start, moved) < weekStart(date(2026, 8, 5, in: moved), moved))+    }++    // MARK: - Picking++    @Test("Picking a date shows the period holding it")+    func pickingADateShowsItsPeriod() {+        let calendar = calendar()+        let now = now(in: calendar)+        let picked = date(2025, 11, 20, in: calendar)+        var navigation = StatsNavigation()++        navigation.pick(date: picked, calendar: calendar, now: now, earliest: earliest(in: calendar))++        #expect(navigation.anchor == weekAnchor(picked, calendar))+        #expect(+            navigation.scope(calendar: calendar, now: now)+                == .week(start: weekStart(picked, calendar)))+    }++    @Test("Picking inside the current period keeps tracking the clock (Q15)")+    func pickingInsideTheCurrentPeriodStaysNil() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation()++        navigation.pick(+            date: date(2026, 8, 11, in: calendar), calendar: calendar, now: now,+            earliest: earliest(in: calendar))++        #expect(navigation.anchor == nil)+    }++    @Test("A pick outside the clamp is pulled back inside it")+    func aPickIsClamped() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)+        var navigation = StatsNavigation()++        navigation.pick(+            date: date(2019, 5, 4, in: calendar), calendar: calendar, now: now,+            earliest: earliest)+        #expect(navigation.anchor == weekAnchor(earliest, calendar))++        navigation.pick(+            date: date(2031, 5, 4, in: calendar), calendar: calendar, now: now,+            earliest: earliest)+        #expect(navigation.anchor == nil)+    }++    // MARK: - Switching unit (Q10, Q27)++    /// Q27: a nil anchor names "the current period" (Q15), so a switch from it+    /// has to land on the current period rather than on whatever period the+    /// old unit's *start* falls in.+    ///+    /// `now` is deliberately a day whose Monday-start week began in the previous+    /// month — Wednesday 2 September 2026, whose week began Monday 31 August.+    /// Under Q10 alone the default state would open Month on **August**.+    @Test("A unit switch from the current period stays on the current period")+    func switchingUnitFromTheCurrentPeriodStaysCurrent() {+        let calendar = calendar()+        let now = date(2026, 9, 2, in: calendar)+        let earliest = earliest(in: calendar)+        // The fixture verifies itself: without this the case is not about what+        // it claims to be about.+        #expect(weekStart(now, calendar) < monthStart(now, calendar))++        var toMonth = StatsNavigation()+        toMonth.toggle(day: date(2026, 9, 1, 0, in: calendar))+        toMonth.select(unit: .month, calendar: calendar, now: now, earliest: earliest)++        #expect(toMonth.unit == .month)+        #expect(toMonth.anchor == nil)+        // Req 5.5 holds on this path too — the period on screen changed.+        #expect(toMonth.selectedDay == nil)+        #expect(+            toMonth.scope(calendar: calendar, now: now)+                == .month(start: date(2026, 9, 1, 0, in: calendar)))++        // And back the other way: the current month's first day is a Tuesday, so+        // the week holding it is not the current week either.+        var toWeek = toMonth+        toWeek.select(unit: .week, calendar: calendar, now: now, earliest: earliest)++        #expect(toWeek.unit == .week)+        #expect(toWeek.anchor == nil)+        #expect(+            toWeek.scope(calendar: calendar, now: now)+                == .week(start: date(2026, 8, 31, 0, in: calendar)))+    } -        #expect(navigation.openedMonth == nil)+    /// Q10 is unchanged for a *dated* anchor: the month is the one holding the+    /// shown week's first day, even where the week runs on into the next one.+    @Test("A dated week spanning a month boundary switches to the month its first day is in")+    func datedWeekToMonthTakesTheWeeksFirstDay() {+        let calendar = calendar()+        let now = date(2026, 10, 14, in: calendar)+        let earliest = earliest(in: calendar)+        var navigation = StatsNavigation()+        // Thursday 3 September 2026 — the week of Monday 31 August.+        navigation.pick(+            date: date(2026, 9, 3, in: calendar), calendar: calendar, now: now, earliest: earliest)+        #expect(navigation.anchor == weekAnchor(date(2026, 8, 31, 0, in: calendar), calendar))++        navigation.select(unit: .month, calendar: calendar, now: now, earliest: earliest)++        #expect(navigation.unit == .month)+        #expect(navigation.anchor == monthAnchor(date(2026, 8, 1, 0, in: calendar), calendar))+    }++    @Test("Week to Month shows the month holding the shown week's start")+    func weekToMonthKeepsTheDate() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)+        let inJune = date(2026, 6, 15, in: calendar)+        var navigation = StatsNavigation()+        navigation.pick(date: inJune, calendar: calendar, now: now, earliest: earliest)++        navigation.select(unit: .month, calendar: calendar, now: now, earliest: earliest)++        #expect(navigation.unit == .month)+        #expect(navigation.anchor == monthAnchor(weekStart(inJune, calendar), calendar))+        #expect(navigation.anchor == monthAnchor(date(2026, 6, 1, 0, in: calendar), calendar))+    }++    /// The clamp doing the work Q10 leaves it: March 2024 opens on a Friday, so+    /// the week its first day sits in began in February — before the week holding+    /// the library's earliest capture, which is where the switch has to land.+    @Test("Month to Week moves forward to the lower bound when the month's first week begins before it")+    func monthToWeekClampsToTheLowerBound() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = date(2024, 3, 5, in: calendar)+        var navigation = StatsNavigation()+        navigation.select(unit: .month, calendar: calendar, now: now, earliest: earliest)+        navigation.pick(date: earliest, calendar: calendar, now: now, earliest: earliest)++        #expect(navigation.anchor == monthAnchor(earliest, calendar))+        // The fixture verifies itself: without this the case is not about what+        // it claims to be about.+        #expect(weekStart(monthStart(earliest, calendar), calendar) < weekStart(earliest, calendar))++        navigation.select(unit: .week, calendar: calendar, now: now, earliest: earliest)++        #expect(navigation.unit == .week)+        #expect(navigation.anchor == weekAnchor(earliest, calendar))+    }++    @Test("All time to Week or Month opens on the current period")+    func allTimeToABoundedUnitIsCurrent() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)++        for unit in [StatsUnit.week, .month] {+            var navigation = StatsNavigation()+            navigation.pick(date: earliest, calendar: calendar, now: now, earliest: earliest)+            navigation.select(unit: .allTime, calendar: calendar, now: now, earliest: earliest)++            #expect(navigation.unit == .allTime, "\(unit)")+            #expect(navigation.anchor == nil, "\(unit)")+            #expect(navigation.scope(calendar: calendar, now: now) == .allTime, "\(unit)")++            navigation.select(unit: unit, calendar: calendar, now: now, earliest: earliest)++            #expect(navigation.unit == unit, "\(unit)")+            #expect(navigation.anchor == nil, "\(unit)")+        }+    }++    @Test("Selecting the unit already shown changes nothing")+    func selectingTheSameUnitIsANoOp() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation()+        navigation.toggle(day: date(2026, 8, 12, 0, in: calendar))+        let before = navigation++        navigation.select(+            unit: .week, calendar: calendar, now: now, earliest: earliest(in: calendar))++        #expect(navigation == before)+    }++    // MARK: - Opening a month from All time (Q5)++    @Test("An All-time bar switches the unit to Month at that bar's month")+    func openingAMonthSwitchesTheUnit() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)+        let bar = date(2026, 3, 1, 0, in: calendar)+        var navigation = StatsNavigation()+        navigation.select(unit: .allTime, calendar: calendar, now: now, earliest: earliest)+        navigation.toggle(day: date(2026, 8, 12, 0, in: calendar))++        navigation.open(month: bar, calendar: calendar, now: now, earliest: earliest)++        #expect(navigation.unit == .month)+        #expect(navigation.anchor == monthAnchor(bar, calendar))         #expect(navigation.selectedDay == nil)+        #expect(navigation.scope(calendar: calendar, now: now) == .month(start: bar))+    }++    @Test("Opening the current month tracks the clock rather than pinning to it")+    func openingTheCurrentMonthStoresNil() {+        let calendar = calendar()+        let now = now(in: calendar)+        var navigation = StatsNavigation()++        navigation.open(+            month: monthStart(now, calendar), calendar: calendar, now: now,+            earliest: earliest(in: calendar))++        #expect(navigation.unit == .month)+        #expect(navigation.anchor == nil)+    }++    // MARK: - The selection (Reqs 5.5, 6.8)++    @Test("Every transition clears the selected day")+    func everyTransitionClearsTheSelection() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)+        let day = date(2026, 8, 12, 0, in: calendar)+        let transitions: [(String, (inout StatsNavigation) -> Void)] = [+            (+                "select(unit:)",+                { $0.select(unit: .month, calendar: calendar, now: now, earliest: earliest) }+            ),+            ("step(by:)", { $0.step(by: -1, calendar: calendar, now: now, earliest: earliest) }),+            (+                "pick(date:)",+                { $0.pick(date: earliest, calendar: calendar, now: now, earliest: earliest) }+            ),+            (+                "open(month:)",+                { $0.open(month: earliest, calendar: calendar, now: now, earliest: earliest) }+            ),+        ]++        for (name, transition) in transitions {+            var navigation = StatsNavigation()+            navigation.toggle(day: day)+            #expect(navigation.selectedDay == day, "\(name)")++            transition(&navigation)++            #expect(navigation.selectedDay == nil, "\(name)")+        }     }      @Test("Selecting a day sets it, and selecting it again clears it (Req 6.8)")     func togglingADay() {+        let calendar = calendar()+        let day = date(2026, 8, 12, 0, in: calendar)         var navigation = StatsNavigation()          navigation.toggle(day: day)@@ -1141,26 +1892,33 @@ struct StatsNavigationTests {      @Test("Selecting a different day replaces the selection (Req 6.8)")     func selectingAnotherDayReplacesIt() {+        let calendar = calendar()         var navigation = StatsNavigation()-        navigation.toggle(day: day)+        navigation.toggle(day: date(2026, 8, 12, 0, in: calendar)) -        navigation.toggle(day: march)+        navigation.toggle(day: date(2026, 8, 13, 0, in: calendar)) -        #expect(navigation.selectedDay == march)+        #expect(navigation.selectedDay == date(2026, 8, 13, 0, in: calendar))     } -    @Test("Clearing the selection leaves the period and the month alone (Req 6.8)")+    @Test("Clearing the selection leaves the unit and the shown period alone (Req 6.8)")     func clearingTheSelection() {+        let calendar = calendar()+        let now = now(in: calendar)+        let earliest = earliest(in: calendar)         var navigation = StatsNavigation()-        navigation.select(period: .allTime)-        navigation.open(month: march)-        navigation.toggle(day: day)+        navigation.open(+            month: date(2026, 3, 1, 0, in: calendar), calendar: calendar, now: now,+            earliest: earliest)+        navigation.toggle(day: date(2026, 3, 3, 0, in: calendar))          navigation.clearSelection()          #expect(navigation.selectedDay == nil)-        #expect(navigation.period == .allTime)-        #expect(navigation.scope == .month(start: march))+        #expect(navigation.unit == .month)+        #expect(+            navigation.scope(calendar: calendar, now: now)+                == .month(start: date(2026, 3, 1, 0, in: calendar)))     }      @Test("Clearing an empty selection changes nothing")@@ -1174,6 +1932,155 @@ struct StatsNavigationTests {     } } +// What the shown period is called, in the three places that name it: the+// chevron row's bare label, the Req 2.10 tiles' phrase, and the two ranked-list+// headings (Q19, `stats-period-navigation`). Here rather than in `StatsView` for+// the same Q41 reason the axes are.+//+// Most of the assertions pin which of the three forms is chosen and that the+// tile phrase is the label with a preposition in front of it. Two of them pin+// the formatted strings outright, which they can because the calendar these+// tests hand the rule states the locale as well as the zone and the first+// weekday — so a formatted date is this rule's output rather than the host's+// region settings.+@Suite("Stats period naming")+struct StatsPeriodNamingTests {++    /// en_AU, following `MarkdownExportModelTests`: the suites that assert a+    /// rendered date in this repository state the locale rather than inherit it.+    private func calendar() -> Calendar {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        calendar.firstWeekday = 2+        calendar.locale = Locale(identifier: "en_AU")+        return calendar+    }++    /// An instant inside Sunday 16 August 2026 — a stored anchor is a midpoint+    /// (Q18), and every one of these reads it through `dateInterval(of:for:)`.+    private func anchor(in calendar: Calendar) -> Date {+        calendar.date(from: DateComponents(year: 2026, month: 8, day: 16, hour: 12))!+    }++    @Test("The current period is named, not dated")+    func currentPeriodIsNamed() {+        let calendar = calendar()++        #expect(+            StatsPeriodNaming.label(unit: .week, anchor: nil, calendar: calendar) == "This week")+        #expect(+            StatsPeriodNaming.label(unit: .month, anchor: nil, calendar: calendar) == "This month")+        #expect(+            StatsPeriodNaming.tilePhrase(unit: .week, anchor: nil, calendar: calendar)+                == "this week")+        #expect(+            StatsPeriodNaming.rankingPhrase(unit: .month, anchor: nil, calendar: calendar)+                == "this month")+    }++    /// All time has no chevron row to label, says "in all" beside a number, and+    /// "all time" over a list.+    @Test("All time is labelless, reads in all beside a figure and all time over a list")+    func allTimeNaming() {+        let calendar = calendar()++        #expect(StatsPeriodNaming.label(unit: .allTime, anchor: nil, calendar: calendar) == nil)+        #expect(+            StatsPeriodNaming.tilePhrase(unit: .allTime, anchor: nil, calendar: calendar)+                == "in all")+        #expect(+            StatsPeriodNaming.rankingPhrase(unit: .allTime, anchor: nil, calendar: calendar)+                == "all time")+        // The anchor is ignored: All time is one period whatever is stored.+        #expect(+            StatsPeriodNaming.rankingPhrase(+                unit: .allTime, anchor: anchor(in: calendar), calendar: calendar) == "all time")+    }++    /// Q19: a dated period keeps its preposition in a tile and in a heading, and+    /// drops it on the chevron row, which is not a sentence.+    @Test("A dated period is the bare label, and the phrase is that label with in")+    func datedPeriodKeepsItsPreposition() throws {+        let calendar = calendar()+        let anchor = anchor(in: calendar)++        for unit in [StatsUnit.week, .month] {+            let dated = try #require(+                StatsPeriodNaming.label(unit: unit, anchor: anchor, calendar: calendar))+            #expect(!dated.isEmpty)+            #expect(!dated.hasPrefix("in "))+            #expect(dated.contains("2026"))+            #expect(+                StatsPeriodNaming.tilePhrase(unit: unit, anchor: anchor, calendar: calendar)+                    == "in \(dated)")+            #expect(+                StatsPeriodNaming.rankingPhrase(unit: unit, anchor: anchor, calendar: calendar)+                    == "in \(dated)")+        }+    }++    /// A month is named; a week is its first and last day. The two must not+    /// produce the same string for the same anchor, which is what would happen+    /// if the unit were ever dropped on the way through.+    @Test("A week is dated by its span and a month by its name")+    func weekAndMonthAreNamedDifferently() {+        let calendar = calendar()+        let anchor = anchor(in: calendar)++        let week = StatsPeriodNaming.label(unit: .week, anchor: anchor, calendar: calendar)+        let month = StatsPeriodNaming.label(unit: .month, anchor: anchor, calendar: calendar)++        #expect(week != month)+        #expect(month == StatsPeriodNaming.monthText(anchor, calendar: calendar))+        #expect(week == StatsPeriodNaming.weekText(anchor, calendar: calendar))+    }++    /// The strings themselves, for the one calendar these tests state entirely.+    /// The anchor is inside Sunday 16 August 2026, so a Monday-start week runs+    /// 10–16 August and the month is August.+    ///+    /// The week's separator is what ICU actually produces for en_AU — thin+    /// space, en dash, thin space — not the bare en dash the requirement's prose+    /// writes. Pinned as produced: an expectation written to the prose would+    /// fail against correct output.+    @Test("The two dated labels read as their dates")+    func theDatedLabelsAreTheirDates() {+        let calendar = calendar()+        let anchor = anchor(in: calendar)++        #expect(+            StatsPeriodNaming.label(unit: .week, anchor: anchor, calendar: calendar)+                == "10\u{2009}\u{2013}\u{2009}16 Aug 2026")+        #expect(+            StatsPeriodNaming.label(unit: .month, anchor: anchor, calendar: calendar)+                == "August 2026")+        #expect(+            StatsPeriodNaming.tilePhrase(unit: .month, anchor: anchor, calendar: calendar)+                == "in August 2026")+        #expect(+            StatsPeriodNaming.rankingPhrase(unit: .week, anchor: anchor, calendar: calendar)+                == "in 10\u{2009}\u{2013}\u{2009}16 Aug 2026")+    }++    /// The first weekday decides which week an instant is in, so the week text+    /// has to be a function of the calendar it is given rather than of+    /// `Calendar.current` (Q22's exemption is about *reading* the clock, not+    /// about ignoring the calendar in hand).+    @Test("The week text follows the calendar's first weekday")+    func weekTextFollowsTheCalendar() {+        var sundayStart = calendar()+        sundayStart.firstWeekday = 1+        let mondayStart = calendar()+        // Sunday: the last day of a Monday-start week and the first of a+        // Sunday-start one, so the two calendars name different weeks.+        let sunday = anchor(in: mondayStart)++        #expect(+            StatsPeriodNaming.weekText(sunday, calendar: mondayStart)+                != StatsPeriodNaming.weekText(sunday, calendar: sundayStart))+    }+}+ // 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@@ -1183,7 +2090,10 @@ 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], scope: StatsScope = .period(.thisMonth)) -> StatsGraph {+    private func graph(+        _ counts: [Int],+        scope: StatsScope = .month(start: Date(timeIntervalSince1970: 1_770_000_000))+    ) -> StatsGraph {         let bars = counts.enumerated().map { index, count in             StatsBar(                 index: index,@@ -1193,7 +2103,9 @@ struct StatsAxisArithmeticTests {         return StatsGraph(             scope: scope, unit: .day, bars: bars,             totalNotes: counts.reduce(0, +), totalWorks: 0, undatedNoteCount: 0,-            periodFigures: nil)+            earliestUsableCapture: nil,+            periodFigures: StatsPeriodFigures(notes: 0, works: 0),+            topWorks: [], topSites: [])     }      /// The bar shapes the page actually draws: an unresolved span, a single@@ -1292,10 +2204,9 @@ struct StatsAxisArithmeticTests {         "A bounded span is drawn to fit, however many bars it holds",         arguments: [1, 7, 28, 31])     func aBoundedSpanNeverScrolls(barCount: Int) {-        // Every bounded scope, because Req 5.3's opened month is one too and it-        // is the widest of them at 31 days.+        // Both bounded units, because a month is the widest of them at 31 days.         for scope: StatsScope in [-            .period(.thisWeek), .period(.lastWeek), .period(.thisMonth), .period(.lastMonth),+            .week(start: Date(timeIntervalSince1970: 1_770_000_000)),             .month(start: Date(timeIntervalSince1970: 1_770_000_000)),         ] {             let result = graph(Array(repeating: 0, count: barCount), scope: scope)@@ -1307,7 +2218,7 @@ struct StatsAxisArithmeticTests {      @Test("All time holds its band width and grows past the space available")     func allTimeScrollsRatherThanCompressing() {-        let scope = StatsScope.period(.allTime)+        let scope = StatsScope.allTime         let fitting = graph(Array(repeating: 0, count: 12), scope: scope)         // A young library's All time still fills its width rather than drawing         // twelve months against the leading edge of an empty plot.@@ -1348,12 +2259,13 @@ struct StatsInputKeyTests {      private func key(         generation: Int = 1,-        scope: StatsScope = .period(.thisWeek),+        unit: StatsUnit = .week,+        anchor: Date? = nil,         selectedDay: Date? = nil,         temporal: StatsTemporalKey? = nil     ) -> StatsInputKey {         StatsInputKey(-            generation: generation, scope: scope, selectedDay: selectedDay,+            generation: generation, unit: unit, anchor: anchor, selectedDay: selectedDay,             temporal: temporal ?? self.temporal)     } @@ -1377,13 +2289,59 @@ struct StatsInputKeyTests {         }     } -    @Test("A scope change moves the graph's identity and leaves the breakdown's alone")+    @Test("A unit or anchor 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)+        let before = key(unit: .week, selectedDay: day)++        for after in [key(unit: .month, selectedDay: day), key(anchor: day, selectedDay: day)] {+            #expect(before.graphIdentity != after.graphIdentity)+            #expect(before.breakdownIdentity == after.breakdownIdentity)+        }+    }++    /// Q14: the key holds no resolved scope, so a nil anchor re-resolves against+    /// the temporal half — which is what carries midnight and a zone change into+    /// the derivation without a `Date()` read in `body`.+    @Test("A temporal change with a nil anchor yields the new current period")+    func aNilAnchorFollowsTheTemporalKey() {+        let calendar = Calendar(identifier: .gregorian)+        let navigation = StatsNavigation()+        let thisWeek = Date(timeIntervalSince1970: 1_770_000_000)+        let nextWeek = thisWeek.addingTimeInterval(8 * 86_400)++        let before = key(temporal: temporal)+        let after = key(+            temporal: StatsTemporalKey(+                timeZoneIdentifier: temporal.timeZoneIdentifier,+                firstWeekday: temporal.firstWeekday,+                startOfDay: calendar.startOfDay(for: nextWeek)))          #expect(before.graphIdentity != after.graphIdentity)-        #expect(before.breakdownIdentity == after.breakdownIdentity)+        #expect(+            navigation.scope(calendar: calendar, now: thisWeek)+                != navigation.scope(calendar: calendar, now: nextWeek))+    }++    /// A pick that lands on the period already shown leaves the anchor exactly+    /// as it was, so nothing re-derives (`stats-period-navigation`).+    @Test("A same-period pick leaves the graph's identity unchanged")+    func aSamePeriodPickDerivesNothing() {+        var calendar = Calendar(identifier: .gregorian)+        calendar.timeZone = TimeZone(identifier: "UTC")!+        calendar.firstWeekday = 2+        let now = Date(timeIntervalSince1970: 1_786_000_000)+        let earliest = Date(timeIntervalSince1970: 1_700_000_000)+        var navigation = StatsNavigation()+        navigation.step(by: -1, calendar: calendar, now: now, earliest: earliest)+        let before = key(unit: navigation.unit, anchor: navigation.anchor)++        // A different day of the same week.+        let insideTheSameWeek = navigation.anchor!.addingTimeInterval(3 * 86_400)+        navigation.pick(+            date: insideTheSameWeek, calendar: calendar, now: now, earliest: earliest)++        let after = key(unit: navigation.unit, anchor: navigation.anchor)+        #expect(after.graphIdentity == before.graphIdentity)     }      @Test("A republished snapshot re-derives both (Reqs 1.8, 8.2)")@@ -1395,6 +2353,42 @@ struct StatsInputKeyTests {         #expect(before.breakdownIdentity != after.breakdownIdentity)     } +    /// The trigger and the question are not the same key. A republish must+    /// re-derive, and the view must go on rendering the graph it has while that+    /// happens — `body` re-evaluates before `.task(id:)` runs, so a view gated+    /// on the derivation trigger would blank the graph, the breakdown and the+    /// period tiles for a frame on every republish.+    @Test("A generation bump moves the derivation trigger and leaves the period alone")+    func aNewGenerationDoesNotChangeThePeriod() {+        let before = key(generation: 1, anchor: day)+        let after = key(generation: 2, anchor: day)++        #expect(before.graphIdentity != after.graphIdentity)+        #expect(before.periodIdentity == after.periodIdentity)+    }++    @Test("A unit, anchor or temporal change moves the period's identity too")+    func aPeriodChangeMovesThePeriodIdentity() {+        let before = key(unit: .week, anchor: day)++        let others = [+            key(unit: .month, anchor: day),+            key(unit: .week, anchor: otherDay),+            key(unit: .week, anchor: nil),+            key(+                unit: .week, anchor: day,+                temporal: StatsTemporalKey(+                    timeZoneIdentifier: "Europe/Lisbon", firstWeekday: 1,+                    startOfDay: temporal.startOfDay)),+        ]+        for after in others {+            #expect(before.periodIdentity != after.periodIdentity)+        }++        // The selection is not part of the period.+        #expect(before.periodIdentity == key(unit: .week, anchor: day, selectedDay: day).periodIdentity)+    }+     @Test("A time-zone move re-derives both, shared UTC offset or not (Req 3.7, Q39)")     func aTemporalChangeReDerivesBoth() {         let before = key(selectedDay: day)@@ -1426,7 +2420,7 @@ struct StatsInputKeyTests { @MainActor struct StatsReadDisciplineTests { -    @Test("Selecting the tab, every period, a month and every day issue no repository call (Req 1.5)")+    @Test("Selecting the tab, every unit, a step, a pick, a month and every day read nothing (Req 1.5)")     func derivingReadsNothing() async {         let now = Date(timeIntervalSince1970: 1_786_000_000)         let calendar = Calendar(identifier: .gregorian)@@ -1460,21 +2454,31 @@ struct StatsReadDisciplineTests {         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.+        // selected, each of the three units, a step back and forward, a picked+        // date, a month opened from an All-time bar, and every bar of each+        // resolved scope selected and cleared in turn.+        let earliest = now.addingTimeInterval(-400 * 86_400)         var navigation = StatsNavigation()-        var scopes: [StatsScope] = StatsPeriod.allCases.map { period in-            navigation.select(period: period)-            return navigation.scope+        var scopes: [StatsScope] = []+        for unit in StatsUnit.allCases {+            navigation.select(unit: unit, calendar: calendar, now: now, earliest: earliest)+            scopes.append(navigation.scope(calendar: calendar, now: now))+            navigation.step(by: -1, calendar: calendar, now: now, earliest: earliest)+            scopes.append(navigation.scope(calendar: calendar, now: now))+            navigation.step(by: 1, calendar: calendar, now: now, earliest: earliest)+            navigation.pick(date: earliest, calendar: calendar, now: now, earliest: earliest)+            scopes.append(navigation.scope(calendar: calendar, now: now))         }-        navigation.select(period: .allTime)-        navigation.open(month: calendar.dateInterval(of: .month, for: now)!.start)-        scopes.append(navigation.scope)+        navigation.select(unit: .allTime, calendar: calendar, now: now, earliest: earliest)+        navigation.open(+            month: calendar.dateInterval(of: .month, for: now)!.start, calendar: calendar,+            now: now, earliest: earliest)+        scopes.append(navigation.scope(calendar: calendar, now: now))          for scope in scopes {             let graph = StatsDerivation.graph(                 presentation: model.recentPresentation, works: model.worksSnapshot.works,-                scope: scope, calendar: calendar, now: now)+                scope: scope, calendar: calendar)             for bar in graph.bars {                 navigation.toggle(day: bar.start)                 guard let day = navigation.selectedDay else { continue }@@ -1711,15 +2715,14 @@ private struct StatsStoreFixture {     }      /// Built through the two published snapshots, exactly as `StatsView` does.-    func graph(scope: StatsScope = .period(.allTime)) async throws -> StatsGraph {+    func graph(scope: StatsScope = .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))+            calendar: calendar)     }      private func entry(
Asterism/AsterismUITests/StatsUITests.swift Modified +215 / -43
diff --git a/Asterism/AsterismUITests/StatsUITests.swift b/Asterism/AsterismUITests/StatsUITests.swiftindex b35c452..58f150a 100644--- a/Asterism/AsterismUITests/StatsUITests.swift+++ b/Asterism/AsterismUITests/StatsUITests.swift@@ -122,50 +122,48 @@ final class StatsUITests: XCTestCase {             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 — and then-    /// Req 1.9: the period it returns to survives a switch to another tab and-    /// back. The last leg extends this journey rather than repeating it as its-    /// own, because it needs exactly the state this one has already built: a+    /// Q5, replacing stats-page Reqs 5.2–5.4: an All-time bar switches the unit+    /// to Month at that bar's month, with no pushed screen and no back route —+    /// and then Req 1.9: the period it lands on survives a switch to another tab+    /// and back. The last leg extends this journey rather than repeating it as+    /// its own, because it needs exactly the state this one has already built: a     /// period that is not the default.     @MainActor-    func testAnAllTimeBarOpensItsMonthAndBackReturnsToAllTime() {+    func testAnAllTimeBarSwitchesToItsMonthAndTheUnitSurvivesATabSwitch() {         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()+        selectAllTime()          // The seeded library's captures are all from today, so All time is one-        // month — one band covering the plot.+        // month — one band covering the plot, and that month is the current one.         let band = app.buttons["stats-bar-0"]         XCTAssertTrue(band.waitForExistence(timeout: 15), "All time should draw its month")+        XCTAssertFalse(+            app.anyElement("stats-period-picker").exists,+            "All time shows no chevron row at all — it is one period")         band.tap() +        let period = app.buttons["stats-period-picker"]         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)+            period.waitForExistence(timeout: 10),+            "Q5: an All-time bar switches the unit to Month, which has a chevron row")+        XCTAssertTrue(+            app.buttons["stats-unit-month"].isSelected,+            "Q5: the capsule follows the bar to Month")         XCTAssertEqual(-            period.label, "Period, All time",-            "Req 5.4: the route out of a month returns to All time")--        // Req 1.9, which nothing asserted until now. The page's whole state-        // lives in `@State` inside a value-based `Tab`, and Q36 records that-        // that API's lifecycle is undocumented — so "a tab switch does not-        // reset the page" is a bet on the same undocumented behaviour the-        // explicit `isPresented` gate exists because of, and it needs a test-        // rather than a note in the design saying it holds without work.+            period.label, "Period, This month",+            "The band tapped is the current month, so the row names it as the current one")+        // The pushed month screen `stats-month-screen` identified is gone (Q5);+        // the unit switch above is what replaces it, and the chevron row it+        // brought is what this test asserts. Nothing renders that identifier any+        // more, so asserting its absence asserted nothing.++        // Req 1.9, which nothing asserted until the All-time journey did. The+        // page's whole state lives in `@State` inside a value-based `Tab`, and+        // Q36 records that that API's lifecycle is undocumented — so "a tab+        // switch does not reset the page" is a bet on the same undocumented+        // behaviour the explicit `isPresented` gate exists because of, and it+        // needs a test rather than a note in the design saying it holds.         app.tabBars.buttons["Recent"].tap()         XCTAssertTrue(             app.collectionViews["recent-list"].waitForExistence(timeout: 15),@@ -173,9 +171,100 @@ final class StatsUITests: XCTestCase {         app.tabBars.buttons["Stats"].tap()         XCTAssertTrue(             period.waitForExistence(timeout: 15), "Stats should come back with its period control")+        XCTAssertTrue(+            app.buttons["stats-unit-month"].isSelected,+            "Req 1.9: the selected unit survives a tab switch away and back")+    }++    /// The chevrons and their clamps (Q7), on the fixture that actually has a+    /// history to walk: All time's first band is the earliest month the library+    /// holds, so the month opened from it is where backward navigation stops.+    @MainActor+    func testTheChevronsStepMonthsAndAreDisabledAtBothLimits() {+        launchSpanningMonths()+        openStats()++        // The default period is the current week, which is the forward limit.+        let period = app.buttons["stats-period-picker"]+        XCTAssertTrue(period.waitForExistence(timeout: 20), "Week shows a chevron row")+        XCTAssertEqual(period.label, "Period, This week", "The page opens on the current week")+        XCTAssertFalse(+            app.buttons["stats-period-forward"].isEnabled,+            "Q7: the current period is the forward limit")+        XCTAssertTrue(+            app.buttons["stats-period-back"].isEnabled,+            "The fixture's earliest capture is 30 months back, so back is offered")++        // The earliest month, reached the way a reader reaches it.+        selectAllTime()+        let firstBand = app.buttons["stats-bar-0"]+        XCTAssertTrue(firstBand.waitForExistence(timeout: 20), "All time should draw its bands")+        let earliestMonth = firstBand.label+        firstBand.tap()++        XCTAssertTrue(period.waitForExistence(timeout: 10))         XCTAssertEqual(-            period.label, "Period, All time",-            "Req 1.9: the selected period survives a tab switch away and back")+            period.label, "Period, \(earliestMonth)",+            "The row names the month the band named")+        XCTAssertFalse(+            app.buttons["stats-period-back"].isEnabled,+            "Q7: the month holding the earliest usable capture is the backward limit")++        let forward = app.buttons["stats-period-forward"]+        XCTAssertTrue(forward.isEnabled, "Every other month is forward of the earliest one")+        forward.tap()+        XCTAssertNotEqual(+            period.label, "Period, \(earliestMonth)", "A forward step shows the next month")+        XCTAssertTrue(+            app.buttons["stats-period-back"].isEnabled,+            "One month in, backward is offered again")++        app.buttons["stats-period-back"].tap()+        XCTAssertEqual(+            period.label, "Period, \(earliestMonth)", "A backward step returns to it")+        XCTAssertFalse(+            app.buttons["stats-period-back"].isEnabled, "…and the limit still holds")+    }++    /// Q6: the label presents a bounded graphical picker, and picking a date+    /// shows the period holding it.+    @MainActor+    func testThePeriodLabelPresentsADatePickerThatMovesThePeriod() {+        launchSpanningMonths()+        openStats()++        let period = app.buttons["stats-period-picker"]+        XCTAssertTrue(period.waitForExistence(timeout: 20))+        XCTAssertEqual(period.label, "Period, This week")+        period.tap()++        let picker = app.datePickers.firstMatch+        XCTAssertTrue(picker.waitForExistence(timeout: 10), "The label presents a date picker")++        // The picker opens on the month holding the shown period. A month back+        // is inside the fixture's range — its earliest capture is 30 months+        // back — and is a week the page is certainly not showing.+        let previousMonth = picker.buttons["Previous Month"]+        XCTAssertTrue(+            previousMonth.waitForExistence(timeout: 10),+            "A graphical picker draws its own month chevrons")+        previousMonth.tap()++        let day = picker.buttons.matching(+            NSPredicate(format: "label MATCHES '.*[0-9]+.*' AND label != 'Previous Month' "+                + "AND label != 'Next Month'")+        ).element(boundBy: 0)+        XCTAssertTrue(day.waitForExistence(timeout: 10), "The picker offers days to pick")+        day.tap()++        waitUntilGone(picker, "Q6: the sheet dismisses on a pick", timeout: 10)+        XCTAssertTrue(period.waitForExistence(timeout: 10))+        XCTAssertNotEqual(+            period.label, "Period, This week",+            "A pick a month back shows the week holding that date, not the current one")+        XCTAssertTrue(+            app.buttons["stats-period-forward"].isEnabled,+            "An earlier period can always step forward")     }      /// Every seeded scenario carries one reachability check that is **not**@@ -190,6 +279,83 @@ final class StatsUITests: XCTestCase {             "The spanning-months fixture should seed and reach Recent")     } +    /// The ranked lists (`stats-period-navigation`): both sections for a period+    /// that holds notes, and a work row that routes to its work exactly as a+    /// breakdown row does (Req 6.10).+    @MainActor+    func testTheRankedListsNameTheSpansWorksAndSitesAndRouteToAWork() {+        launchSeeded()+        assignSeededEntryToItsWork()+        openStats()++        let works = app.staticTexts["stats-top-works"]+        XCTAssertTrue(+            works.waitForExistence(timeout: 15), "A period holding a note shows both lists")+        XCTAssertTrue(+            works.label.hasSuffix("this week"),+            "The heading names the period it ranks — was \(works.label)")+        XCTAssertTrue(app.staticTexts["stats-top-sites"].exists)+        XCTAssertTrue(+            app.anyElement("stats-top-site-row").exists,+            "The seeded note's hostname is a site of this period")++        let workRow = app.buttons["stats-top-work-row"]+        XCTAssertTrue(workRow.waitForExistence(timeout: 10), "The assigned work is ranked")+        XCTAssertEqual(workRow.value as? String, "1 note")+        // The row's own label names the work it offers, so the assertion after+        // the tap can be about *that* work rather than about a screen appearing.+        let ranked = workRow.label.replacingOccurrences(of: "Open Work ", with: "")+        XCTAssertFalse(ranked.isEmpty, "A routable row names the work it opens — was \(workRow.label)")+        workRow.tap()++        XCTAssertTrue(+            app.buttons["work-detail-edit-button"].waitForExistence(timeout: 15),+            "A ranked work row opens the work in the Works tab, as a breakdown row does")+        XCTAssertTrue(+            app.tabBars.buttons["Works"].isSelected,+            "Req 6.10: the route is a cross-tab one — the Works tab comes forward with it")+        XCTAssertEqual(+            app.staticTexts["work-detail-title"].label, ranked,+            "…and the work opened is the one the row ranked, not merely some work")+    }++    /// The other half: a period holding nothing shows neither list.+    ///+    /// Driven on the 31-month fixture because it is the only one with a period+    /// to step *to* — its populated months are 30, 22, 11, 3 and 0 back, so one+    /// step back from the current month is certainly empty.+    ///+    /// The Month tap is deterministic since Q27: from the default state the+    /// switch lands on the *current* month whatever weekday the suite runs on.+    /// Carrying the current week's start into the month resolution used to land+    /// it on the previous month whenever that week began in one, which on this+    /// fixture is an empty month — the opposite of what the first half asserts.+    @MainActor+    func testAnEmptyPeriodShowsNeitherRankedList() {+        launchSpanningMonths()+        openStats()++        app.buttons["stats-unit-month"].tap()+        let sites = app.staticTexts["stats-top-sites"]+        XCTAssertTrue(+            sites.waitForExistence(timeout: 20),+            "The current month holds notes, so it ranks their site")+        XCTAssertTrue(sites.label.hasSuffix("this month"), "…under the period's own name")+        // Q25's other case, on the one fixture that has it: this library's notes+        // belong to no work, so the works section is a heading over an+        // explanation rather than a heading over nothing.+        XCTAssertTrue(+            app.staticTexts["stats-top-works-empty"].exists,+            "Q25: a period with notes but no ranked work says so instead of listing nothing")++        app.buttons["stats-period-back"].tap()+        waitUntilGone(sites, "A period holding no note shows no ranked list", timeout: 10)+        XCTAssertFalse(app.staticTexts["stats-top-works"].exists)+        XCTAssertTrue(+            app.anyElement("stats-graph-empty").waitForExistence(timeout: 10),+            "…and the graph says the period is empty rather than drawing nothing")+    }+     /// Q49, confirmed on device, then measured here: an All-time graph whose     /// span runs past one screenful must scroll, and must still open the month     /// its band *names*.@@ -263,22 +429,28 @@ final class StatsUITests: XCTestCase {             "The first and last band of a 31-month span name different months")         lastBand.tap() +        // Q5: the band switches the unit to Month at its own month rather than+        // pushing a screen. The last band is the month holding `now`, so the+        // month it opens is the current one — which is exactly what the failure+        // this test was written for could not produce: opening *January 2025*+        // from the band labelled \(named) would name that month here.+        let period = app.buttons["stats-period-picker"]         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)")+            period.waitForExistence(timeout: 10), "The band should switch the unit to Month")+        XCTAssertEqual(+            period.label, "Period, This month",+            "The opened month must be the one the band names (\(named)), not another band's")+        XCTAssertFalse(+            app.buttons["stats-period-forward"].isEnabled,+            "The current month is the forward limit, which is what says this is that month")     }     #endif      // 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")+        let allTime = app.buttons["stats-unit-all"]+        XCTAssertTrue(allTime.waitForExistence(timeout: 15), "The capsule offers All time")         allTime.tap()     } 
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Modified +66 / -10
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 0bd4dc7..44bf972 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -56,6 +56,23 @@ final class AccessibilityJourneyUITests: XCTestCase {         scrollToElement(assignedEntry)         XCTAssertTrue(assignedEntry.waitForExistence(timeout: 10)) +        // The spine has a note, so it has a sort capsule — the shared §7 recipe+        // (`stats-period-navigation` Q28). Its container label names what the+        // choice is for, and `children: .contain` keeps both segments+        // individually reachable underneath it, the chosen one carrying+        // `.isSelected`. That is what "an existing caller's output is unchanged"+        // has to mean now that the label is required rather than optional.+        let newestOrder = app.buttons["work-detail-sort-newest"]+        let chapterOrder = app.buttons["work-detail-sort-chapter"]+        XCTAssertTrue(+            newestOrder.waitForExistence(timeout: 10), "The Newest sort segment is reachable")+        XCTAssertTrue(+            chapterOrder.waitForExistence(timeout: 10), "The Chapter sort segment is reachable")+        XCTAssertTrue(+            newestOrder.isSelected,+            "Newest is the order the spine opens in, and the segment says so")+        XCTAssertFalse(chapterOrder.isSelected, "Only the chosen order is selected")+         // Decision 5: the title is editable inside the editor, which is entered         // from the toolbar's pencil — so the journey enters it before it can         // assert the field or the confirmation that leaves it.@@ -286,20 +303,59 @@ final class AccessibilityJourneyUITests: XCTestCase {                 "\(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")+        // Q16: this is the *only* criterion for the capsule. If the three+        // segments cannot be drawn unclipped at this size, the toggle becomes a+        // `Menu` of the three units — by this assertion failing, not by an+        // implementer's eye.+        let firstBand = app.buttons["stats-bar-0"]+        XCTAssertTrue(firstBand.waitForExistence(timeout: 15), "The graph's bands should render")++        let period = app.buttons["stats-period-picker"]+        for identifier in [+            "stats-unit-week", "stats-unit-month", "stats-unit-all",+            "stats-period-back", "stats-period-picker", "stats-period-forward",+        ] {+            let control = app.buttons[identifier]+            XCTAssertTrue(control.waitForExistence(timeout: 15), "\(identifier) should render")+            XCTAssertFalse(+                control.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,+                "\(identifier) needs an accessibility label")+            // A chevron at its limit is disabled — the seeded library's only+            // capture is today, so both are — and a disabled control is still+            // drawn, still sized and still measured here.+            XCTAssertGreaterThanOrEqual(+                control.frame.width.rounded(), 44, "\(identifier) should be at least 44 points wide")+            XCTAssertGreaterThanOrEqual(+                control.frame.height.rounded(), 44,+                "\(identifier) should be at least 44 points tall")+            XCTAssertTrue(+                window.frame.contains(control.frame),+                "\(identifier) must stay inside the window at the largest Dynamic Type size")+            XCTAssertLessThanOrEqual(+                control.frame.maxY, firstBand.frame.minY,+                "\(identifier) must sit above the graph, not over it")+        }+        XCTAssertTrue(+            app.buttons["stats-unit-week"].isSelected,+            "The page opens on Week, which is the unit this journey walks")         XCTAssertTrue(             period.label.contains("This week"),-            "The period control names the current period rather than clipping it")+            "The chevron row names the current period rather than clipping it")++        // The ranked lists are the page's new tail, and a heading that carries+        // its period's name is exactly the kind of long label Req 7.8 is about.+        let ranked = app.staticTexts["stats-top-works"]+        // Below the graph, so at this text size it is below the fold: reached by+        // scrolling, as every other long page in this suite is. The control+        // frames above were read before this scroll.+        scrollToElement(ranked)+        XCTAssertTrue(ranked.waitForExistence(timeout: 15), "The ranked lists should render")         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")+            ranked.label.hasSuffix("this week"),+            "The heading names its period in full — was \(ranked.label)")         XCTAssertLessThanOrEqual(-            period.frame.maxY, firstBand.frame.minY,-            "The period control must sit above the graph, not over it")+            ranked.frame.width.rounded(), window.frame.width.rounded(),+            "A ranked-list heading wraps inside the window rather than running off it")     }      private func walkWorksAppearanceJourney(named appearance: String) {
CHANGELOG.md Modified +65 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b6eb45d..5c76159 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Stats period control centred; pre-push review polish (T-2216).** The+  unit capsule and chevron row centre on the page (Q33). Review fixes:+  one shared row builder for breakdown and ranked rows, a bounded top-5+  selection keyed on work UUID in the ranking tally, the picker binding+  reads `StatsPeriodBounds.currentSelection` instead of the stored+  anchor, `ConstellationSegmentedControl` requires a container label+  ("Sort order" on work detail), and `ChapterSortOrder` gained+  exhaustive titles/identifiers. Q31's no-capture-date departure and the+  work-detail sort segments are now pinned by tests.++- **The stats-page spec records what the period navigation superseded+  (stats-period-navigation, phase 3 "Documentation", T-2216).** Reqs 3.1,+  5.3 and 5.4 are superseded outright, Reqs 2.10, 3.2, 5.2 and 5.5 in+  part, and the "breakdown by site" and "five named periods" non-goals+  annotated; Decision 3's drill-down half, Q30, Q31 and Q55 carry dated+  supersession notes pointing at the smolspec. Design doc §5.4 describes+  the shipped page and `specs/OVERVIEW.md` marks the spec Done. No code+  change.++- **The Stats page navigates by unit, chevrons and a date picker, and+  ranks the period's works and sites (stats-period-navigation, phase 2+  "View", T-2216).** The five-period `Menu` is gone. A Week / Month /+  All time capsule sits over a back-chevron / label / forward-chevron+  row (absent for All time); the label opens a `.graphical` `DatePicker`+  bounded to the library's earliest usable capture through now, and the+  chevrons and picker read their enablement from+  `StatsNavigation.bounds(...)`. The pushed month screen is deleted — an+  All-time bar switches the unit to Month at that month (Q5). Two+  ranked sections, most-read works and most-read sites, render below the+  graph and breakdown with period-phrased headings whenever the period+  holds a note; a work row routes to its work, and a period with notes+  but no rankable work says so (Q25). Period naming lives in+  `StatsPeriodNaming`, formatting through the calendar it is handed+  (Q23). Switching unit from the current period stays on the current+  period; a dated anchor still carries its start into the new unit+  (Q27 over Q10). The capsule is the new shared+  `ConstellationSegmentedControl` recipe, which work detail's sort+  control now uses too (Q28). At the largest accessibility size the+  capsule stacks and nothing clips, so the Q16 `Menu` fallback was not+  applied (Q26). New identifiers `stats-unit-*`, `stats-period-*`,+  `stats-top-*`. Tests in `StatsDerivationTests` (naming suite),+  `StatsUITests` and `AccessibilityJourneyUITests`.++- **Stats derivation moves to units, anchors and ranked lists+  (stats-period-navigation, phase 1 "Derivation", T-2216).** `StatsPeriod`+  becomes `StatsUnit {week, month, allTime}` and `StatsScope` carries its+  own `.week(start:)` / `.month(start:)`, so `span(for:)` is one+  `dateInterval(of:for:)` and any past week or month derives. `StatsNavigation`+  holds `unit`, an optional `anchor` (nil means the current period, Q15;+  stored as the period midpoint so a time-zone move cannot cross a boundary,+  Q18) and `selectedDay`, with `select(unit:)`, `step(by:)`, `pick(date:)`+  and `open(month:)` routed through one clamp that clears the selection.+  `bounds(...)` reports both chevrons and the picker range against the+  current earliest usable capture (Q20). `StatsGraph` gains+  `earliestUsableCapture`, non-optional `periodFigures` (All time included),+  and top-five `topWorks` / `topSites` tallied in the same pass as the+  figures with the existing `precedes` order. `StatsInputKey` is keyed on+  `(generation, unit, anchor, selectedDay, temporal)`, with a separate+  `periodIdentity` for the view's stale-graph guard so a republish no longer+  blanks the graph for a frame. `StatsView` drops the pushed month screen —+  an All-time bar switches to Month (Q5) — and keeps a three-unit `Menu`+  until phase 2 replaces it. Dated tile phrases keep their preposition+  (Q19). Tests in `StatsDerivationTests` (navigation, derivation, keys,+  invariants, read discipline).+ - **The work page and the share sheet list characters by prominence   (character-ranking, phase 2, T-2273).** `workDetail` builds a   `StoryPositionIndex` from the work's logical entries (duplicate rows
docs/asterism-design.md Modified +1 / -1
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 9fd4a51..83f093a 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -299,7 +299,7 @@ Sites list: every stored site — untaught, taught, and articles — with mode a  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/`.+Two lifetime totals (notes, works) over a bar graph of reading activity, with the shown period's own notes and works beside them. A `Week | Month | All time` toggle over a back chevron, a period label and a forward chevron picks the period: any week or month the library holds is one step, or one tap on the label's date picker, away, clamped between the earliest usable capture and now. 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. An All-time bar selects nothing and instead switches the toggle to Month at that month. Below the graph, two top-five lists for the period: most-read works and most-read sites. Derived from the snapshots the app already publishes: no new entity, no stored counts. Full behaviour in `specs/stats-page/`, with the period control and the ranked lists in `specs/stats-period-navigation/`.  --- 
docs/asterism-style-guide.md Modified +2 / -2
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex 26df75d..059dd69 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -97,7 +97,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - **Meta line** (work detail): `{n} notes  ▲ {up}  ▼ {down}` on one `.caption` line beside the site identity — notes count in primary text, semibold; ▲ and its count cyan; ▼ and its count violet. It replaces the three equal pulse cards this section used to name (`specs/work-detail-reading-redesign/`): the counts are metadata, and three cards made them the page. It wraps rather than truncates — a truncated count is a wrong count. - **Selected pill**: a pill that has been opened — work detail's cast pill, with its chevron pointing up — takes the type-tag recipe lifted: violet .26 fill, violet .6 border, no glow (`ConstellationPillKind.selectedTypeTag`, Q11/Q14). Selection changes no other pill. - **Spine row** (work detail's chapter notes): no card. A gutter (≥ 44 pt, growing with its label) holds a 10 pt rating dot — cyan filled for ▲, violet for ▼, a 1.5 pt dim ring when unrated — over the chapter number in SF Mono dim (nothing beneath the dot where the note has no number — an interlude, or a site whose URL carries only an id, Q25), and a 1 pt `cardBorder` rail runs through the dots from the first row's to the last row's. Beside it: the row title (13 pt semibold, one line, truncating) with the date trailing in `.caption2` dim, then the whole note at 15 pt with no line limit. The rail is drawn in the row container's background, outside the row's button, so pressing a row does not dim it.-- **Two-segment capsule, or a menu**: use a **capsule** when a choice has two short labels and both must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. It sits beside its section header, drops to its own line beneath the header where the two do not fit, and at the largest accessibility sizes stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). Use a **`Menu`** instead when the choice has more than two options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods (`specs/stats-page/`, Q31).+- **Segmented capsule (two or three short labels), or a menu**: use a **capsule** when a choice has two or three short labels and all of them must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1) and Stats' `Week | Month | All time` unit (`specs/stats-period-navigation/`, Q11). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. Placement follows what the control governs: **beside its section header** where it has one, dropping to its own line beneath that header where the two do not fit — work detail's sort (Q21) — and **centred above what it governs** where it stands alone, with no header to sit beside, which is Stats' unit toggle and its chevron row (`specs/stats-period-navigation/`, Q33). Either way, at the largest accessibility sizes it stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). **One implementation, not a copy per screen**: `ConstellationSegmentedControl` in `ConstellationKit` — values, a selection binding, a title and an accessibility identifier per value, and an optional container label (`stats-period-navigation`, Q28). Use a **`Menu`** instead when the choice has more than three options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods before this control replaced them (`specs/stats-page/`, Q31). - **Provenance disclosure**: quieter-than-card fill (.035), SF Mono 11 pt, dim, labels slightly brighter. Collapsed by default. *(No longer used on entry detail — the block was removed from that screen entirely, `specs/polish-and-export/` Q52. The recipe stands for any diagnostics surface that needs it.)*  ## 8. Iconography@@ -118,7 +118,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - Respect Reduce Transparency (fall back to opaque `#12162a` dark / `#f2f3f8` light card fills) and Reduce Motion (nothing extra to do — motion is already minimal). - Hit targets ≥ 44 pt even where visuals are smaller (Teach pill, tags, chips). - Dynamic Type: serif titles scale with the system; truncate single-line work/chapter names with ellipsis rather than wrapping in rows. **Rows only** — a detail screen's own heading wraps: work detail's header title is multi-line with no line limit, and the navigation bar carries the collapsed, truncating form of it (Q58).-- Where a row cannot hold everything on one line at the accessibility sizes, the trailing element moves to its own line rather than clip, hyphenate, or squeeze its neighbour: work detail's meta line drops under the site identity (`specs/work-detail-reading-redesign/`, Q18), and its sort capsule drops under the section header and then stacks its two segments (Q21).+- Where a row cannot hold everything on one line at the accessibility sizes, the trailing element moves to its own line rather than clip, hyphenate, or squeeze its neighbour: work detail's meta line drops under the site identity (`specs/work-detail-reading-redesign/`, Q18), and its sort capsule drops under the section header and then stacks its two segments (Q21). The drop-under-the-header half is specific to a capsule that *has* a header: Stats' period control has none and is centred above the graph it governs, where the same size increase stacks its segments and drops the chevron row beneath the toggle (`specs/stats-period-navigation/`, Q33).  ## 11. Anti-rules 
specs/OVERVIEW.md Modified +23 / -3
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 6c3ba9f..c529d1a 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -20,7 +20,7 @@ | [Sharesheet Polish](#sharesheet-polish) | 2026-08-14 | Done — all 6 tasks complete 2026-08-15, the last from the on-device check; `make test-core` and `make test-quick` green, no new warnings; post-review fixes applied with mutation-verified test coverage | Smolspec (T-2190): the capture sheet hides the raw page title whenever a parsed title is shown (taught chapter or articles display title, even a no-op clean — Q9) and drops its hostname row outright (Q12); the parsed card lists the work above the chapter (Q13); the re-share edit sheet gains the entry's title (`carrier.chapterTitle ?? representative.captureTitle`, `snapshot(_:)`'s sourcing — Q3, articles divergence deferred to T-2194); rating toggles move above the note field on both sheets. | | [Recent Window Cap](#recent-window-cap) | 2026-08-15 | Done — all 8 tasks implemented, reviewed and green | Caps the Recent list at the newest 100 logical rows (T-2191), with everything beyond it reachable through Works behind a truncation footer. A 14-day window was specified and then dropped (Decision 2): it would empty Recent for a reader returning after a break. Entirely app-layer — `AsterismCore`, the fixtures and the performance suites are untouched, because the cap is applied where the screen reads the presentation rather than in the derivation (Decision 1). | | [Pending-Capture Queue](#pending-capture-queue) | 2026-08-16 | Done — all 20 tasks implemented 2026-08-17; two device checks (protection class before first unlock, share-flow fsync latency) remain open in `prerequisites.md` | v2 plan item 2 (T-2217). The share extension writes every share to a durable record in the App Group container **before** opening the library, and removes it only once the capture commits or the reader cancels; anything failing in between leaves the record for the app to drain on its next activation. No schema, migration or archive change. The directory layout is the queue state (Decision 7) — one file per record, every transition an atomic rename. Reverses two earlier calls after review: a busy library no longer retries (Decision 3, whose original premise about `bootstrapLockTimeout` was wrong), and the queue bounds bytes rather than a count of 100 (Decision 4). |-| [Stats Page](#stats-page) | 2026-08-16 | Done — all 16 tasks implemented; Reqs 7.1, 7.2 and 7.5's visual half await the reader's own device check (`prerequisites.md`) | A third tab (T-2192) with two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. Entirely app-layer (Decision 5): notes, dates and work attribution come from `recentPresentation.allRows`, the works total from `worksSnapshot` — `AsterismCore` is untouched. The graph counts **first captures**, so re-reads are deliberately invisible (Decision 1). Supersedes in part `polish-and-export` Reqs 9.3 and 11.2, design §5, and style guide §7/§8. |+| [Stats Page](#stats-page) | 2026-08-16 | Done — all 16 tasks implemented; Reqs 7.1, 7.2 and 7.5's visual half await the reader's own device check (`prerequisites.md`). **Its period selection is superseded** (2026-08-31) by [Stats Period Navigation](#stats-period-navigation): Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, the site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55 are annotated in place | A third tab (T-2192) with two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. Entirely app-layer (Decision 5): notes, dates and work attribution come from `recentPresentation.allRows`, the works total from `worksSnapshot` — `AsterismCore` is untouched. The graph counts **first captures**, so re-reads are deliberately invisible (Decision 1). Supersedes in part `polish-and-export` Reqs 9.3 and 11.2, design §5, and style guide §7/§8. | | [Rule Suggestion](#rule-suggestion) | 2026-08-17 | Done | v2 plan item 3 (T-2156). The on-device Foundation Model proposes a title rule and URL rule for an untaught hostname from its captures; the composed teaching editor opens pre-filled with a "Suggested" marker and the reader saves as normal. Suggestions are precomputed in the background, verified against every capture on the hostname before they are shown, never written without a save, and their absence — model unavailable, verification failed, not finished — leaves the editor exactly as today. First `FoundationModels` use in the tree; adds an `AsterismIntelligence` package target the extension never links. No schema, migration or archive change. | | [Character Extraction](#character-extraction) | 2026-08-19 | Done | v2 plan item 4 (T-2229). The on-device model reads a work's notes and proposes characters — each fact a verbatim quote citing its source — held until the reader accepts them in a per-candidate, per-fact review; accepted characters are fully reader-editable (and combinable) and live in a new `Character` entity. New schema V7, new archive generation 6/7, CloudKit-synced with the full torn/duplicate machinery. Prototype over a real archive gated the design (Decision 3) and set the schema: aliases yes, confidence no. | | [Work Detail Reading Redesign](#work-detail-reading-redesign) | 2026-08-22 | Done | Smolspec. Makes the notes the content of the work detail screen: the header folds into title, site line, a `{n} notes ▲ ▼` meta line and the work's notes as a paragraph; chapter notes become a spine — a rail with a rating dot and a multi-part chapter key per note (`341`, `5.07`) and the full note text, no cards, no clamp — with a Newest/Chapter sort (unnumbered notes last); the expanded character card is rebuilt on the same gutter. Two derived fields on the `WorkChapterRow` projection, no schema change. Supersedes polish-and-export Reqs 5.4 and 9.4 (and 5.1's order, in part) and design-doc §7's "no chapter-number parsing from titles" (Decision 2). |@@ -33,6 +33,7 @@ | [Rule Citation by UUID](#rule-citation-by-uuid) | 2026-08-29 | Done — all 10 tasks complete 2026-08-29; `make test-core`, `make test-quick` and `make build` green with no new warnings, the Req 7.1 grep gate clean, and two `make test-performance-m4` runs inside every ceiling with the Req 5.4 capture-projection arms unmoved (verification-run.md). Branch not yet merged | Full spec (T-2281), spec B of Post-V8 Convergence; supersedes T-2055. `CitedRule` becomes `{id}` — the version integer leaves every citation, and with it the Site-unique/greatest-version invariants, `SiteUnionProjection` renumbering, both reconcilers' citation-rewrite walks and the export rewrite map. "Current" is the marked row with a `(createdAt desc, id)` tiebreak behind two `Site` accessors (Decision 1, Q11); the row `version` column stays advisory. No schema stage; archive 7/8 → 8/9 with the `BackupV7*` → `BackupV8*` rename (Q14). Single device, so no rollout gate (Q8). | | [Wrong-Host Work URL Heal](#wrong-host-work-url-heal) | 2026-08-29 | Done | Full spec (T-2294). A membership Work URL on another host — residue of builds before per-hostname Work URLs — becomes a Work-keyed tolerated diagnosis that quarantines nothing, produced by both the full validation and the foreground scan; a `MembershipReconciler` phase moves each value to the membership for its host (minting the membership, never a Site row), backup import applies the same precedence where the destination write is known to land, and the three membership folds carry a discarded row's URL. Supersedes multi-site-works Q97; amends library-integrity-tolerance Decisions 3 and 4. | | [Character Ranking](#character-ranking) | 2026-08-30 | Done | T-2273. Orders a work's characters by prominence instead of name: each character's facts are bucketed by story position, scored `Σ 2^(-d/10) · log2(n+1)` with `d` the ordinal distance from the latest chapter, and ranked descending with name order as the tie-break. One derived order for the work page and the share sheet (overrules share-sheet-characters Q1); no schema change. |+| [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed: the capsule passes the no-clipping assertion at `AccessibilityExtraExtraExtraLarge` (Q26) | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. |  --- @@ -330,7 +331,7 @@ Item 2 of the v2 plan (T-2217). Closes the hole in M1's "nothing captured is los  ## Stats Page -**Created:** 2026-08-16 · **Status:** Done — all 16 tasks implemented across four phases. Three requirements settled by eye rather than by test (7.1 cyan accent, 7.2 not a glass surface, 7.5's visual half) remain the reader's own device check, per `prerequisites.md`; the repo has no snapshot testing, so nothing here could have closed them.+**Created:** 2026-08-16 · **Status:** Done — all 16 tasks implemented across four phases. Three requirements settled by eye rather than by test (7.1 cyan accent, 7.2 not a glass surface, 7.5's visual half) remain the reader's own device check, per `prerequisites.md`; the repo has no snapshot testing, so nothing here could have closed them. **The page's period selection has since been replaced** by [Stats Period Navigation](#stats-period-navigation) (2026-08-31) — read the annotations on Reqs 2.10, 3.1, 3.2 and 5.2–5.5, the two non-goals, Decision 3, Q30, Q31 and Q55 before treating any of them as current.  A third tab (T-2192) holding two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. @@ -338,7 +339,7 @@ A third tab (T-2192) holding two lifetime totals, a bar graph of reading activit  - **The graph counts first captures, not last shares** (Decision 1). 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. This puts Stats in the same chronological register as markdown export (`polish-and-export` Decision 2), not Recent's recency register. - **Notes come from `recentPresentation.allRows`; works come from `worksSnapshot`** (Decision 5). `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, a carrier-unattached note with an attached sibling appears twice, and each fragment carries its own earliest-capture date. The presentation has already collapsed all of that globally. The Req 2.6 real-store test exists to catch a future maintainer undoing this.-- **All-time bars navigate, they do not select** (Decision 3). All time buckets by month and drills into a month's days; it is the only graph that scrolls and the only one with no selection, so `chartXSelection` and `chartScrollableAxes` are never combined.+- **All-time bars navigate, they do not select** (Decision 3). All time buckets by month and drills into a month's days; it is the only graph that scrolls and the only one with no selection, so `chartXSelection` and `chartScrollableAxes` are never combined. The drilldown is now a unit switch rather than a pushed screen (`stats-period-navigation` Q5); the rest of the posture is unchanged. - **No binge is computed** (Decision 4). A threshold would be an invented constant and would leave ordinary days opaque; instead any day can be inspected for its per-work composition. - **The lifetime totals can fall on their own.** Duplicate resolution deletes losing identities and reconciliation does it silently, so Req 2.8 states the decrease rather than promising stability. - **Half-arrived notes count but do not plot** (Decision 6). `firstCapturedAt` defaults to the 1970 epoch, and one unhydrated row would otherwise drag All time back to 1970 and render hundreds of empty bars.@@ -557,3 +558,22 @@ T-2273. A work's characters list by prominence — how many facts, across how ma - [decision_log.md](character-ranking/decision_log.md) - [verification-run.md](character-ranking/verification-run.md) - [implementation.md](character-ranking/implementation.md)++---++## Stats Period Navigation++**Created:** 2026-08-30 · **Status:** Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation). `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. No `prerequisites.md`: nothing here is gated on a device run.++Smolspec (T-2216). The Stats page's `Menu` of five fixed periods becomes a Week / Month / All time toggle with back and forward chevrons and a date picker, so any week or month is one step or one pick away, clamped between the earliest usable capture and now. Two top-five lists join the page for the shown period: most-read works and most-read sites.++- **Anchor nil means "the current period"** (Q9, Q15): a stored week start goes stale at midnight; nil re-resolves against the temporal key, and a step or pick that lands back on the current period stores nil again, so stats-page Req 3.7 holds without a timer.+- **The pushed month screen is gone** (Q5): tapping an All-time bar switches the toggle to Month at that month, and the chevrons supply the "next month" the push never could. Supersedes stats-page Decision 3's drill-down half and Q30.+- **Sites are capture hostnames** (Q4): every `RecentPresentationRow` already carries one, so the rankings need no new snapshot and `AsterismCore` is untouched (stats-page Decision 5 stands). Unattached and unresolved-title notes rank under sites but not under works (Q13, Q17).+- **A three-segment capsule, not a `Menu`** (Q11, Q16, Q26): three short labels fit where five long ones did not, and the fallback was not needed — the accessibility journey's no-clipping assertion, the only criterion Q16 admits, passes at `AccessibilityExtraExtraExtraLarge`. The capsule is the shared `ConstellationSegmentedControl`, extracted from work detail's sort control rather than copied (Q28).+- **The picker is presented, not inline** (Q6): a `.compact` `DatePicker` draws its own date text and cannot read "This week", so the label is a button presenting a bounded `.graphical` picker. A nil anchor resolves to `now` for the binding, not to the period's start (Q24) — the anchor is the period's **midpoint** (Q18), so that nothing re-resolves a boundary instant under a calendar at a lower UTC offset and lands in the period before.+- **Enablement comes from the bounds, never from the stored anchor** (Q20, Q31): a republish that moves the earliest usable capture disables a chevron rather than allowing a step past it, and the shown period stays where it is; a forward step from a period the bounds have left behind clamps to the new lower bound, and the picker's range widens so it is never handed a selection outside itself.++- [smolspec.md](stats-period-navigation/smolspec.md)+- [tasks.md](stats-period-navigation/tasks.md)+- [decision_log.md](stats-period-navigation/decision_log.md)
specs/stats-page/decision_log.md Modified +8 / -6
diff --git a/specs/stats-page/decision_log.md b/specs/stats-page/decision_log.mdindex e35c871..907b2d2 100644--- a/specs/stats-page/decision_log.md+++ b/specs/stats-page/decision_log.md@@ -33,8 +33,8 @@ | Q27 | 2026-08-16 | `AppLibraryModel` gains a `snapshotGeneration` counter, and the view keys its derivation on it | `.task(id:)` over the snapshots themselves 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 | | Q28 | 2026-08-16 | Bounded periods use `chartXSelection`; All time uses `chartGesture` with a spatial tap | Keeps `chartXSelection`'s built-in gesture off the one graph that scrolls, so the two are never combined. A tap and a scroll pan are distinguishable by construction, where a built-in selection gesture of unknown kind is not | | Q29 | 2026-08-16 | The pre-publication state is distinguished by `snapshotGeneration == 0` | Both snapshots initialise empty, so an unread library and an empty one are otherwise the same values — and Req 1.7 forbids reporting the second when it is the first. Production reaches `.ready` only after `refreshAll()`, so a zero generation means that refresh threw |-| Q30 | 2026-08-16 | An opened month is a `NavigationStack` push | Supplies Req 5.4's back affordance and Req 5.3's month title without hand-built controls, and matches Decision 2's stated consequence that Stats owns a stack of its own |-| Q31 | 2026-08-16 | The period control is a `Menu`, not a segmented picker | Five labels of "This month" length cannot fit a segmented control at accessibility Dynamic Type sizes, which Req 7.8 forbids clipping. A menu is one 44 pt target at every size |+| Q30 | 2026-08-16 | An opened month is a `NavigationStack` push | Supplies Req 5.4's back affordance and Req 5.3's month title without hand-built controls, and matches Decision 2's stated consequence that Stats owns a stack of its own. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q5: with Month a first-class unit the push duplicates it, so an All-time bar switches the unit instead, the chevron row carries the month title, and the chevrons supply the "next month" the push never could. The `NavigationStack` stays, with one screen in it |+| Q31 | 2026-08-16 | The period control is a `Menu`, not a segmented picker | Five labels of "This month" length cannot fit a segmented control at accessibility Dynamic Type sizes, which Req 7.8 forbids clipping. A menu is one 44 pt target at every size. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q11 and Q26: there are three short labels now, not five long ones, and the segmented capsule recipe stacks its segments full width at those sizes. Verified rather than judged — the accessibility journey's no-clipping assertion passes at `AccessibilityExtraExtraExtraLarge`. The reasoning stands as written for five long labels, and the style guide keeps the `Menu` for that case | | Q32 | 2026-08-16 | The x scale is declared categorical: `.chartXScale(domain: bars.map(\.index), type: .category)` | An `Int` x value alone yields a *quantitative* scale — `Int` is `Plottable` with a `Double` primitive — which centres bars with gaps and resolves touches by interpolation, giving "nearest bar" a silent half-band error. Q23 named the right axis and not the mechanism that produces it | | Q33 | 2026-08-16 | Zero-count bars are load-bearing twice | They are Req 4.6, and they are what keeps the categorical domain hole-free. Dropping them as an optimisation would puncture the domain and silently change what `chartXVisibleDomain(length:)` counts | | Q34 | 2026-08-16 | The chart carries an `.accessibilityRepresentation` of real `Button`s, one per bar | `ChartContent` exposes only label, value, identifier and hidden — no per-mark action and no `.isSelected` trait — so labelled marks satisfy neither Req 7.5 nor Req 7.7. A representation is a separate view tree, which also makes every bar of the scrolling graph reachable regardless of the viewport |@@ -45,7 +45,7 @@ | Q39 | 2026-08-16 | The temporal key carries time-zone identifier and `firstWeekday`, not just the day stamp | Moving between zones that share a UTC offset but differ in week rules changes what "This week" means without changing `startOfDay`. `NSSystemTimeZoneDidChange` joins the significant-time observer for the same reason | | Q41 | 2026-08-16 | The navigation transitions live in a `StatsNavigation` value type, not in `StatsView`'s `body` | No app-layer test instantiates a SwiftUI view, so Reqs 5.4, 5.5, 6.7 and 6.8 would otherwise have no test at all. Same reasoning that produced `RecentDisplayPlan` (`recent-window-cap` Q26). `scope` is derived from the stored fields rather than stored, so an opened month and a period selection cannot disagree | | Q40 | 2026-08-16 | No separate spike; each unproven chart construction carries a check and a fallback, verified while building it | This is the repo's first Swift Charts usage, so categorical-domain exactness, tap-versus-pan arbitration and proxy resolution under scroll offset are all unproven here. But every one of them has a fallback that costs no requirement, so finding out during implementation is recoverable — a spike would front-load a day to de-risk work that de-risks itself. The scrolling path, which carries two of the three, is about two years from being reachable in this library |-| Q42 | 2026-08-16 | Req 5.5's "leave any opened month" means *depart from* it: `select(period:)` clears `openedMonth` as well as the selection | The word reads both ways in isolation, and the two readings are opposites. The design's scope table and task 9's stated transition both have a period change clearing the month, and the other reading would let a month opened from All time survive a switch to This week — leaving the graph showing a scope the period control no longer describes |+| Q42 | 2026-08-16 | Req 5.5's "leave any opened month" means *depart from* it: `select(period:)` clears `openedMonth` as well as the selection | The word reads both ways in isolation, and the two readings are opposites. The design's scope table and task 9's stated transition both have a period change clearing the month, and the other reading would let a month opened from All time survive a switch to This week — leaving the graph showing a scope the period control no longer describes. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q5: both things this decided about are gone. `select(period:)` and `openedMonth` are deleted — the unit is chosen on a three-segment capsule and the period by an anchor, and an All-time bar switches the unit to Month rather than opening a month. The ambiguity the decision resolved cannot arise, and what it protected is now structural: `scope` is derived from the unit and the anchor, so there is no second field left to disagree with the control | | Q43 | 2026-08-16 | `open(month:)` clears nothing; it only sets the month | It is reachable only from All time, where Req 5.2 has already established that no day is selected, and `closeMonth()` clears both on the way out. Adding a defensive clear would assert something about a state the type cannot reach, and would make the invariant look distributed when it is local | | Q44 | 2026-08-16 | The Req 2.6 real-store fixture's split entry group points its two rows at *different* works | A converged split group would not have caught the `WorksSnapshot` flattening the test exists to catch — the flattened sum matches. Pointing them at different works is the case Decision 5 actually names, and lets the test assert that the flattened count differs from the store's | | Q45 | 2026-08-16 | The categorical x value is the bar's index **rendered as a `String`**, not the `Int` itself | Q32's literal construction, `.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 diagnoses `Int` correctly (a `Plottable` with a `Double` primitive, hence quantitative) and then assumes `type:` can override it; a category scale in fact requires a plottable whose primitive is a `String`. The band name is the smallest correction that still delivers Q32's mechanism, and it was measured to deliver it: seven bands tile a 382 pt plot with zero gap between adjacent `positionRange(forX:)` values, and a 24-band visible domain puts exactly 24 in the viewport. The explicit domain array fixes the order, so string collation never reaches the axis |@@ -53,12 +53,12 @@ | Q47 | 2026-08-16 | ~~Q34's `.accessibilityRepresentation` is kept for the All-time graph only~~ **Superseded by Q56, and it stays deleted under Decision 7** (2026-08-16): All time scrolls again, but the band controls now sit *inside* the thing that scrolls, so assistive technology reaches a band past the viewport by scrolling to it exactly as it reaches a row below the fold in Recent — the premise that an overlay is clipped by a viewport a separate tree escapes no longer holds, because there is no separate viewport. Measured: `stats-bar-30` of the 31-band fixture is absent from the accessibility tree at rest and present, hittable and correctly targeted after a pan. Its retention had a measured cost — Q53's caveat, now a confirmed defect: over 31 months the representation published 31 stacked ~20 pt rows whose frames match no band, and activating the row naming *August 2026* opened *January 2025* | Its remaining job is the one Q34 named that the overlay cannot do: an overlay is clipped to a scrolling viewport, so Req 7.7's "including bars outside the visible viewport" needs a separate tree on the one graph that can scroll. Everywhere else the band controls *are* the accessibility elements, and a representation would replace real controls with a parallel copy of themselves | | Q48 | 2026-08-16 | Req 1.1's tab identifier is applied as the other two apply theirs and is asserted by nothing; the UI test reaches the tabs by label | Measured on iOS 26: an `.accessibilityIdentifier` on a `SwiftUI.Tab` never reaches the tab-bar button, which publishes its label and its symbol image and no identifier at all — and moving the identifier onto a custom `label:` view does not change that. Decision 2 suspected `tab-recent` / `tab-works` were inert; they are, and so is `tab-stats`. Asserting the three-tab structure by label is the coverage that was actually missing | | Q49 | 2026-08-16 | ~~**Open.**~~ ~~**Resolved by Q56**~~ **Resolved by Decision 7** (2026-08-16, measured twice): both halves of the risk were real of a chart-owned scroll, and both are answered by owning the scroll instead. The controls do not track a chart's scroll offset, so the chart is put inside a `ScrollView` with the controls, where there is no offset to track | Q47 recorded that an overlay is *clipped* by a scrolling viewport. It did not address a second and worse failure: if `positionRange(forX:)` returns content-space positions while `.chartOverlay` lays out in viewport space, then after a scroll the visible bands are covered by band 0–23's buttons and a tap opens the **wrong month** — wrong rather than unreachable, and silent. Not fixed here, because both remedies are unsafe without measurement: compensating with `chartScrollPosition(x:)` would *introduce* the bug if positions are already viewport-relative, and no fixture in this repo spans more than 24 months to measure against (the M4 scale fixture dates all 5,000 entries at epoch + *n* seconds, `M4PerformanceFixture.swift:110`). Decision 3's stated fallback — All time non-scrolling with compressed bands — costs no requirement and remains available. Reachable in roughly two years; see the design's Chart Assumptions outcomes |-| Q50 | 2026-08-16 | 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 invisible while it lasts; only the tail of the pop animation can show it, and only on a library large enough for the derivation to outlast the animation. Two caches keyed by their own identities would fix it and would add a second piece of view state to keep in step with `lastDerivedKey` — declined on the repo's stated preference for the simpler shape, and recorded here so the trade is visible rather than accidental |+| Q50 | 2026-08-16 | 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 invisible while it lasts; only the tail of the pop animation can show it, and only on a library large enough for the derivation to outlast the animation. Two caches keyed by their own identities would fix it and would add a second piece of view state to keep in step with `lastDerivedKey` — declined on the repo's stated preference for the simpler shape, and recorded here so the trade is visible rather than accidental. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q5: there is no push and no pop, so there is no blank to show at the tail of one. The cache question this decision left open is answered rather than merely retired — the single cache is kept, and `StatsInputKey.periodIdentity` says whether the graph in hand is the one the screen is naming, so a period change withholds the graph while a republish does not blank it | | Q51 | 2026-08-16 | The breakdown renders only when `breakdown.day` matches the selected day | Gating on `selectedDay != nil` alone let the previous day's heading, total and rows render under the newly highlighted bar for the length of one derivation — not one frame on a large library. `StatsBreakdown.day` exists to make the check possible, and the graph path already guards the same way on `scope` | | Q52 | 2026-08-16 | A time-zone change with a day selected leaves the selection set but unmatched, and it self-corrects on the next tap rather than being cleared | `selectedDay` holds the old zone's midnight, so after re-derivation no bar's `start` matches it: the highlight disappears and Q51's guard withholds the breakdown, which is the honest rendering — the selected day no longer names a bar that exists. The next tap sets rather than toggles, so one gesture restores it. Clearing it in the time-change observer would be a third place that mutates the selection, for a state that already resolves itself | | Q53 | 2026-08-16 | The All-time UI journey's bar tap passes on a property of the fixture, not of the layout | The representation replaces the chart's accessibility tree and XCUI activates by coordinate, so the tap lands on whatever real view is at that point. It lands correctly because the seeded library spans exactly one month and a single band button covers the whole plot. With several months the representation's element frames and the band buttons' frames need not coincide, so this test does **not** cover the multi-band All-time case — the same fixture gap as Q49, and worth knowing before it is cited as coverage. **Closed** (2026-08-16, Q56/Q57): the caveat was right — over 31 months the frames did not coincide and the tap opened the wrong month. The representation is gone, the multi-band case has its own journey over the new fixture, and a tapped band is now a real control | | Q54 | 2026-08-16 | The selected band's outline is drawn **only** under Differentiate Without Color; selection otherwise reads from fill strength alone (Req 7.5 amended) | Requested by the author after seeing it on device: a dark outline around one band is a heavy mark to carry permanently on a page whose whole visual register is a row of cyan bars. Be plain about the trade this makes — full-strength versus dimmed cyan is a difference in one colour's *rendering*, not an independent channel, so the amended requirement no longer claims "distinct by more than colour alone" for the default case. That is precisely why the outline is bound to the system setting that exists to ask for a non-colour cue, rather than to nothing at all. The `.isSelected` trait stays unconditional: it is a separate mechanism and Req 7.5's second clause never depended on the mark |-| Q55 | 2026-08-16 | The selected scope's own notes and works are shown beside the lifetime pair, for every scope but All time (Req 2.10) | Requested by the author after using the built page: two lifetime numbers answer "how big is the library" and nothing on the page answered "how much of it is this week". The works figure counts **distinct non-nil `workID`s among the period's counted notes** — a note attached to nothing counts in the notes figure and towards no work — because that is the question a reader asks of a period ("how many things was I reading") and it is the only definition derivable from the rows the bars already count. Derived in `StatsDerivation.graph` and carried on `StatsGraph` rather than computed in the view, per Q41's rule. All time is excluded because its pair would restate the lifetime totals less the undated notes, which is a worse way to say something the page says already. An opened month is a bounded scope and carries the pair too, so the pushed screen reports the same header the root does |+| Q55 | 2026-08-16 | The selected scope's own notes and works are shown beside the lifetime pair, for every scope but All time (Req 2.10) | Requested by the author after using the built page: two lifetime numbers answer "how big is the library" and nothing on the page answered "how much of it is this week". The works figure counts **distinct non-nil `workID`s among the period's counted notes** — a note attached to nothing counts in the notes figure and towards no work — because that is the question a reader asks of a period ("how many things was I reading") and it is the only definition derivable from the rows the bars already count. Derived in `StatsDerivation.graph` and carried on `StatsGraph` rather than computed in the view, per Q41's rule. ~~All time is excluded because its pair would restate the lifetime totals less the undated notes, which is a worse way to say something the page says already. An opened month is a bounded scope and carries the pair too, so the pushed screen reports the same header the root does.~~ **Superseded in part** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q12: the pair is shown for All time too. The restatement is real and still unwelcome, but it is outweighed by a header that would otherwise change shape as the reader moves along one three-segment toggle. There is no pushed screen left to match (Q30) | | Q56 | 2026-08-16 | ~~**No graph scrolls.** Decision 3's stated fallback is taken: All time renders non-scrolling with compressed bands, `visibleBandLimit` and `chartScrollableAxes` are deleted, and Q47's All-time `.accessibilityRepresentation` goes with them~~ **Promoted to Decision 7** (2026-08-16) and reversed there. The fallback was priced as costing "tap comfort only"; on the author's own library it cost the graph. `chartScrollableAxes` and the representation stay deleted — the finding below about them is unchanged and is why the scroll is now the page's own | Q49's risk was measured over the new 31-month fixture (Q57) and confirmed. The band `Button`s sit in `.chartOverlay`, which lays out in *viewport* space, while `positionRange(forX:)` reports positions in the scroll *content* space: past the first viewport a band is covered by another band's control, so a tap opens the wrong month — silently. **Measured**: a UI test written against the unfixed code activated the band labelled *August 2026* and landed on *January 2025*. The pan is the other half and is the one that decides this: the overlay covers the plot outright, which is why the author could not scroll the graph on device, and no supported API puts a control inside a chart's scrolling content — so the mis-targeting could be compensated for (at the risk Q49 names) and the scroll could not be restored at all. Shipping a graph that mis-targets *or* one that cannot be scrolled is worse than one that needs neither. The fallback costs no requirement (Decision 3: "tap comfort only"), makes control and bar coincide by construction, and removes the parallel accessibility tree whose only job was escaping a viewport that no longer exists. Bands compress to about 11 pt over 31 months, which Req 7.3's position-resolved selection is explicitly built for | | Q57 | 2026-08-16 | A UI-test scenario seeding a 31-month library (`seeded-spanning-months`) is added to `AsterismCore` | Q49 and Q53 both blocked on the same missing thing: no fixture in this repository spanned more than one month, so the All-time graph's multi-band and scrolling behaviour could not be exercised in the simulator at all, and the one All-time journey passed on a property of its fixture. The seeder writes a wholly legal library — one untaught Site, twelve conservative captures over five populated months of a 31-month span, no rules and no Works — so it guards on the build rather than on a capability gate (`docs/agent-notes/testing.md`), and it carries the mandated non-device-gated reachability check. Dates are calendar arithmetic from launch, so the span stays 31 bands whatever day the suite runs | | Q58 | 2026-08-16 | Date axis labels are drawn at their ideal width — `Text(...).fixedSize()` — with the span's first label anchored to the plot's leading end and its last to the trailing end | Reported from the built app: in an opened month the day labels "21" and "31" rendered as "2" and "3", and All time was "unreadable date wise". One cause for both. On a categorical scale each axis label is proposed **its own band's width**, and a label wider than its band is clipped with no ellipsis to say so — reproduced in the simulator, where a 12 pt band clipped every label of a 31-day month to a bare "…". `.fixedSize()` makes the label take its ideal width and overflow the band instead, which is affordable because `dateAxisTicks` already thins the axis to roughly four labels. The two end labels are anchored rather than centred because a centred label on the first or last band hangs off the plot, where a scroll view now clips it: without the anchors "31 Aug" lost its last letter. Verified by screenshot on all four graph shapes — a 7-day week, a 31-day month, an opened month and a 31-band All time |@@ -189,7 +189,7 @@ test is new work, not an amendment. ## Decision 3: All Time Buckets by Month and Drills Down to Days  **Date**: 2026-08-15-**Status**: accepted+**Status**: accepted; the drill-down half superseded in part (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md) Q5  ### Context @@ -212,6 +212,8 @@ tapping a month opens it as a per-day graph, where selecting a day produces the per-work breakdown the bounded periods produce. Where the months no longer fit at a usable width, the All time graph scrolls horizontally. +**Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md), Q5 — **the month bucketing, the navigate-not-select rule and the scrolling stand; the *form* of the drilldown changes.** Month is now one of three units the reader picks directly, so tapping an All-time bar switches the unit to Month at that bar's month instead of pushing a screen, and the chevrons then step months. Every consequence below that turns on the drilldown holds unchanged — every day stays reachable and inspectable, and the only scrolling graph is still the only one with no selection. The one that does not is "Stats gains a second screen and a back route": there is no second screen and no back route, and reaching a day older than the current month is a unit tap plus chevron steps or a date pick rather than two taps.+ ### Rationale  The drilldown is what makes month bucketing acceptable rather than lossy. Every day in
specs/stats-page/requirements.md Modified +12 / -12
diff --git a/specs/stats-page/requirements.md b/specs/stats-page/requirements.mdindex 65ccfa0..62d0fad 100644--- a/specs/stats-page/requirements.md+++ b/specs/stats-page/requirements.md@@ -40,8 +40,8 @@ criteria here are settled against the style guide's stated values. - **No computed binge threshold.** Any day can be inspected; the app does not decide what counts as a binge. - **No capture-time work ownership.** Breakdowns reflect current assignments and titles, so a later merge or rename changes what an old bar reports. - **No work-detail route of the page's own.** A breakdown row hands off to the Works tab rather than rebuilding its navigation graph (Req [6.10](#6.10)).-- **No streaks, goals, targets, or pace projections**, and no breakdown by site, rating, genre, or work type.-- **No custom date range.** The five named periods are the whole selection.+- **No streaks, goals, targets, or pace projections**, and no breakdown by site, rating, genre, or work type. **Superseded in part** (2026-08-31) — the *site* clause is retired by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): the page now carries a "Most read sites" top-five list for the shown period, counted by capture hostname (its Q4). Rating, genre and work type are still not broken down, and the per-day breakdown is still by work alone.+- **No custom date range.** ~~The five named periods are the whole selection.~~ **Superseded in part** (2026-08-31) — the five named periods are gone, replaced by a Week / Month / All time unit with chevrons and a date picker ([`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md); see Reqs [3.1](#3.1) and [3.2](#3.2)). A custom *range* is still out of scope: the selection is one whole week, one whole month, or all time. - **No export, sharing, widget, or Shortcuts surface for the stats.** - **No combined-snapshot publication.** One store instant across all three tabs would need an `AsterismCore` change; see Req [1.4](#1.4). @@ -63,7 +63,7 @@ criteria here are settled against the style guide's stated values. 6. <a name="1.6"></a>WHILE the library is still receiving synced records, the page SHALL present the records it currently holds and update as later arrivals republish, rather than blocking.   7. <a name="1.7"></a>WHILE no snapshot has yet been published, the page SHALL distinguish that state from a genuinely empty library rather than reporting zero notes and zero works.   8. <a name="1.8"></a>WHEN a republished snapshot changes a selected day's contents, the breakdown SHALL be re-derived from the new snapshot, and a day drained to zero SHALL be reported as Req [6.9](#6.9) requires rather than clearing the selection.  -9. <a name="1.9"></a>The selected period, any opened month, and any bar selection SHALL survive switching away from the Stats tab and back within one launch.  +9. <a name="1.9"></a>The selected period, ~~any opened month,~~ and any bar selection SHALL survive switching away from the Stats tab and back within one launch. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): there is no opened month to survive anything, the pushed month screen having been deleted (its Q5). What survives is the selected unit, the shown period's anchor and the bar selection — all three being `@State` on the tab, as they always were.    --- @@ -82,7 +82,7 @@ criteria here are settled against the style guide's stated values. 7. <a name="2.7"></a>WHEN the library holds no notes or no works, the corresponding total SHALL read zero rather than being omitted.   8. <a name="2.8"></a>Resolving or reconciling a duplicate set SHALL reduce the corresponding total by the number of identities removed, and merging two works SHALL reduce the works total by exactly one. Both totals MAY therefore decrease without any reader action, because duplicate sets are also reconciled silently.   9. <a name="2.9"></a>Each total SHALL be labelled with a noun that agrees with its number in English.  -10. <a name="2.10"></a>WHERE the selected scope is not All time — the four bounded periods and an opened month — the page SHALL show, **beside and not instead of** the two lifetime totals, two figures for that scope: the number of notes whose first capture falls inside it, and the number of **distinct works those notes came from**, counted as the distinct non-nil work references among them. A note holding no work reference SHALL count in the first figure and towards no work in the second. Each figure SHALL name the scope it describes, SHALL be labelled with a noun agreeing with its number, and SHALL be zero rather than omitted where the scope holds no notes. All time is excluded because the pair would restate the lifetime totals less the notes carrying no usable capture date.  +10. <a name="2.10"></a>WHERE the selected scope is not All time — the four bounded periods and an opened month — the page SHALL show, **beside and not instead of** the two lifetime totals, two figures for that scope: the number of notes whose first capture falls inside it, and the number of **distinct works those notes came from**, counted as the distinct non-nil work references among them. A note holding no work reference SHALL count in the first figure and towards no work in the second. Each figure SHALL name the scope it describes, SHALL be labelled with a noun agreeing with its number, and SHALL be zero rather than omitted where the scope holds no notes. ~~All time is excluded because the pair would restate the lifetime totals less the notes carrying no usable capture date.~~ **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md) (its Q12): the pair is shown for **every** scope, All time included, so the header keeps one shape across the unit toggle; for All time its notes figure is the lifetime total less the undated notes, and that near-duplicate is expected. "The four bounded periods and an opened month" is now any week or month the toggle can reach — there is no opened month (Req [5.3](#5.3)). Everything else here stands, including the distinct-non-nil-work-reference definition.    --- @@ -92,8 +92,8 @@ criteria here are settled against the style guide's stated values.  **Acceptance Criteria:** -1. <a name="3.1"></a>The Stats page SHALL offer exactly five periods: This week, Last week, This month, Last month, All time.  -2. <a name="3.2"></a>The page SHALL open on This week at each launch, and the selection SHALL NOT persist across launches.  +1. <a name="3.1"></a>~~The Stats page SHALL offer exactly five periods: This week, Last week, This month, Last month, All time.~~ **Superseded** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): the page offers exactly three *units* — Week, Month, All time — and, for Week and Month, a back chevron, a period label and a forward chevron, so **any** week or month in the library's history is reachable by stepping or by tapping the label's date picker. Navigation is clamped at both ends: forward at the current period, backward at the period holding the library's earliest usable capture date.  +2. <a name="3.2"></a>~~The page SHALL open on This week at each launch~~, and the selection SHALL NOT persist across launches. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): the page opens on Week at the **current** week. Non-persistence across launches stands, and the shown period tracks the clock while it is the current period (Req [3.7](#3.7)) — an earlier period, once reached, stays where it is.   3. <a name="3.3"></a>Week and month boundaries SHALL follow the device's current calendar, including its first weekday, and SHALL be computed by calendar arithmetic in the device's current time zone rather than by fixed-length day arithmetic, so a daylight-saving day of 23 or 25 hours is one whole day.   4. <a name="3.4"></a>Every period and every bar SHALL be half-open: a note whose first capture falls exactly on a boundary SHALL belong to the later interval, and SHALL be counted in exactly one.   5. <a name="3.5"></a>All time SHALL span from the start of the calendar month holding the library's earliest usable capture date through the end of the calendar month holding its latest, so that every note carrying a usable capture date falls inside a bar.  @@ -109,7 +109,7 @@ criteria here are settled against the style guide's stated values.  **Acceptance Criteria:** -1. <a name="4.1"></a>The graph SHALL show one bar per day for This week, Last week, This month and Last month, and one bar per calendar month for All time.  +1. <a name="4.1"></a>The graph SHALL show one bar per day for ~~This week, Last week, This month and Last month~~, and one bar per calendar month for All time. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): the enumeration of four fixed periods is gone — the day-bar case is now any Week or Month period. The rule it enumerates is kept exactly, and the smolspec cites this criterion as kept for that reason: one bar per day for a bounded period, one bar per calendar month for All time.   2. <a name="4.2"></a>Each bar SHALL count the notes whose first capture falls within that bar's day or month.   3. <a name="4.3"></a>Each note identity SHALL contribute to at most one bar, and a note identity whose stored rows disagree about first capture SHALL be dated by the earliest across all of its rows. Both hold by inheritance from the published presentation, which carries one row per note identity already so dated; the page SHALL NOT re-derive either from stored rows.   4. <a name="4.4"></a>A note SHALL NOT move to a later bar when it is re-shared.  @@ -129,11 +129,11 @@ criteria here are settled against the style guide's stated values.  **Acceptance Criteria:** -1. <a name="5.1"></a>WHERE the selected period is one of the four bounded periods, selecting a bar SHALL select that day.  -2. <a name="5.2"></a>WHERE the selected period is All time, a bar SHALL open its month rather than being selectable; the All-time graph SHALL have no selected bar and SHALL show no breakdown.  -3. <a name="5.3"></a>An opened month SHALL be the graph's span for the purposes of Requirement 4: its bar unit is the day and its span is that calendar month. It SHALL name the month it covers, and selecting a bar there SHALL select that day.  -4. <a name="5.4"></a>The route out of an opened month SHALL return to All time.  -5. <a name="5.5"></a>Changing the selected period SHALL leave any opened month and clear any selection.  +1. <a name="5.1"></a>WHERE the selected period is ~~one of the four bounded periods~~, selecting a bar SHALL select that day. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): there are no four bounded periods left — the bounded case is now *any* Week or Month anchor the chevrons or the date picker reach. The rule itself stands unchanged for every one of them.  +2. <a name="5.2"></a>WHERE the selected period is All time, a bar SHALL ~~open its month~~ rather than being selectable; the All-time graph SHALL have no selected bar and SHALL show no breakdown. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md) (its Q5): the bar still navigates rather than selecting, but it **switches the unit to Month at that bar's month** in place of opening a pushed screen. The rest of the criterion stands.  +3. <a name="5.3"></a>~~An opened month SHALL be the graph's span for the purposes of Requirement 4: its bar unit is the day and its span is that calendar month. It SHALL name the month it covers, and selecting a bar there SHALL select that day.~~ **Superseded** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): there is no opened month. Month is a first-class unit — its bar unit is the day, its span is that calendar month, the chevron row names it, and selecting a bar selects that day — so what this criterion required of the pushed screen is what the Month unit does on the one screen.  +4. <a name="5.4"></a>~~The route out of an opened month SHALL return to All time.~~ **Superseded** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): there is no pushed month screen and so no back route. The reader returns to All time by tapping the All time segment, and the chevrons step months in the meantime.  +5. <a name="5.5"></a>Changing the selected period SHALL ~~leave any opened month and~~ clear any selection. **Superseded in part** (2026-08-31) by [`specs/stats-period-navigation/`](../stats-period-navigation/smolspec.md): there is no opened month to leave. Clearing the selection stands and widens — a unit switch, a chevron step, a date pick, and opening a month from an All-time bar all clear it. Re-selecting the unit already shown is a no-op and keeps the selection (its Q21).    --- 
specs/stats-period-navigation/smolspec.md Added +52 / -0
diff --git a/specs/stats-period-navigation/smolspec.md b/specs/stats-period-navigation/smolspec.mdnew file mode 100644index 0000000..a6235b4--- /dev/null+++ b/specs/stats-period-navigation/smolspec.md@@ -0,0 +1,52 @@+# Stats Period Navigation++Transit: T-2216. Amends `specs/stats-page/` (see "Spec amendments" below and `decision_log.md`).++## Overview++The Stats page offers five fixed periods through a `Menu` — This week, Last week, This month, Last month, All time — so nothing older than last month is reachable except by drilling into an All-time bar. This change replaces the menu with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history can be reached directly. It also adds two ranked lists for the shown period: the most-read works and the most-read sites.++## Requirements++Terms from `specs/stats-page/requirements.md` apply: **usable capture date**, **note identity**, **half-open** spans. "Read" means first-captured, as everywhere on this page (stats-page Decision 1). The **current period** is the week or month holding `now`.++**Period control**+- The page MUST offer exactly three units: Week, Month, All time. The page MUST open on Week at the current week, and the selection MUST NOT persist across launches.+- WHERE the unit is Week or Month, the page MUST show a back chevron, a label naming the shown period, and a forward chevron. WHERE the unit is All time, that row MUST be absent (not disabled). Tapping the label MUST open a date picker; picking a date MUST show the week or month containing it. The chevrons MUST step one unit back or forward.+- The label MUST read "This week" / "This month" for the current period, and otherwise the period's dates — a week as its first and last day ("23–29 Aug 2026", via `Date.IntervalFormatStyle`), a month as `monthText` ("August 2026"). The Req 2.10 tiles MUST use the same phrase in lower case and keep its preposition — "this week", "in August 2026", "in 23–29 Aug 2026" — and "in all" for All time (Q19); the bare label, without the preposition, is what the chevron row shows; the ranked-list headings MUST use the same phrase, with "all time" for All time. Where the chevrons and label do not fit one line at the accessibility Dynamic Type sizes, the row MUST stack rather than clip, as the capsule does.+- Navigation MUST be clamped at both ends: forward stops at the current period; backward stops at the period holding the library's earliest usable capture date. A chevron at its limit MUST be disabled and the picker's range MUST be the lower period's start through `now`. WHEN the library holds no usable capture date, both chevrons MUST be disabled and the picker's range MUST be the current period's start through `now`. Chevron and picker enablement MUST come from comparing the shown period with those bounds, never from the stored anchor alone, so a bound that moves after a republish (notes reconciled away) disables the chevron rather than allowing a step past it; the shown period itself stays.+- The shown period MUST track the clock while it is the current period: after midnight crosses into a new week or month, or the time zone changes, a page showing the current period MUST still show the current one (stats-page Req 3.7). A page showing an earlier period MUST stay on it. A step or pick that lands on the current period MUST make the page track the clock again, exactly as if the current period had never been left.+- Week and month boundaries, half-openness and the 1971 floor keep stats-page Reqs 3.3, 3.4 and 3.8. Bars stay one per day for Week and Month and one per month for All time (Req 4.1).+- Switching unit MUST keep the shown date: Week → Month shows the month containing the shown week's start; Month → Week shows the week containing the shown month's start, moved forward to the lower bound if it begins before it; All time → Week or Month shows the current period. Switching unit, stepping, picking, or opening a month MUST clear any selected day (Req 5.5).+- WHERE the unit is All time, tapping a bar MUST switch the unit to Month at that bar's month. There is no pushed month screen and no back route; the chevrons then step months. This replaces stats-page Reqs 5.2–5.4.+- Until the first graph has been derived, the chevrons and picker MUST be disabled — the bounds are not yet known.++**Ranked lists**+- WHERE the shown period holds at least one note, the page MUST show, below the graph and breakdown, two sections headed "Most read works" and "Most read sites" followed by the period phrase: the up-to-five works with the most notes first-captured in the period and the up-to-five sites with the most, each row carrying its count. Works are ordered count descending, then display title (`localizedStandardCompare`), then work identity (Req 6.6); sites count descending, then hostname by plain `String` comparison. Both lists apply to All time as well.+- A work row MUST route to its work as a breakdown row does (Req 6.10), including the same tap-time existence check. A note with no work reference, or whose work reference resolves to no display title (`workDisplayTitle == nil`, the test `category(of:)` already applies), MUST NOT appear in the works list; it still counts in the sites list. A site is the note's capture hostname; a note with an empty hostname MUST NOT appear in the sites list.+- WHERE the period holds no notes, both sections MUST be absent.++**Kept as they are**+- Lifetime totals (Reqs 2.1–2.9) and the shown period's notes/works pair (Req 2.10). The pair now appears for All time too, so the header keeps one shape across the toggle; there its notes figure is the lifetime total less the undated notes, and that near-duplicate is expected. WHEN All time resolves no span (Req 3.6), the pair reads zero.+- Per-day bar selection and the breakdown (Reqs 5.1, 6.x). Presentation and accessibility (Req 7) and derivation cost (Req 8): the toggle, chevrons and label MUST be 44 pt targets and the control MUST NOT clip at the accessibility Dynamic Type sizes.++## Implementation Approach++- `Asterism/Asterism/ViewModels/StatsDerivation.swift`: replace `StatsPeriod` with `StatsUnit { week, month, allTime }` and `StatsScope` with `.week(start:)`, `.month(start:)`, `.allTime`. `StatsNavigation` stores `unit`, an `anchor: Date?` (nil = the current period; a `Date` is a period start under the calendar that stored it) and `selectedDay`; it gains `select(unit:)`, `step(by:)` and `pick(date:)`, and `open(month:)` sets `unit = .month`, `anchor`, and clears the selection. Every transition takes a `Calendar`, `now` and the earliest usable capture, normalises its result to the period start, stores nil when that start is the current period's, and clamps — so the whole rule is a pure value-type transition testable without a view (stats-page Q41). `scope(calendar:now:)` is derived, never stored. `span(for:)` collapses to `dateInterval(of:for:)` on the scope's start; All time is unchanged. A `bounds(calendar:now:earliest:)` helper on the same type answers "can step back / forward" and the picker range, and the view reads only that.+- `StatsInputKey` keys on `(generation, unit, anchor, selectedDay, temporal)`; `deriveIfNeeded()` resolves the scope from the temporal key's calendar and `now`, so `body` gains no clock read. A same-period pick leaves `anchor` unchanged and derives nothing.+- `StatsGraph` gains `earliestUsableCapture: Date?` (the minimum the All-time span already takes), `topWorks: [StatsRankingRow]` and `topSites: [StatsSiteRankingRow]`, derived in the same pass as `periodFigures(in:rows:)` from `row.entry.workID` / `row.workDisplayTitle` / `row.hostname` alone — never from the `works` array, which stays the source of the works total only (stats-page Decision 5). `periodFigures` becomes non-optional (zeros when the span is nil). Ranking is a full sort by the existing `precedes` rule then `prefix(5)`.+- `Asterism/Asterism/Views/StatsView.swift`: delete the `Menu` (`periodControl`), the `navigationDestination` push, `monthScreen` and the `openedMonth` binding; the `NavigationStack` stays with one screen. Add a three-segment capsule (recipe in `docs/asterism-style-guide.md` line 100, from `specs/work-detail-reading-redesign/` Decision 1 and Q21: it stacks its segments full width at accessibility sizes) and a chevron / label / chevron row where the label is a `Button` presenting a sheet holding a `.graphical` `DatePicker(displayedComponents: .date)` bounded to the clamp range (a `.compact` picker draws its own date text and cannot read "This week" or a week range); its binding maps nil → the resolved current start on get and calls `pick(date:)` on set, and the sheet dismisses on pick. Add two ranked-list sections reusing `breakdownRowContent` and `openWork`. Identifiers: `stats-unit-week`, `stats-unit-month`, `stats-unit-all`, `stats-period-back`, `stats-period-forward`, `stats-period-picker`, `stats-top-works`, `stats-top-sites`, `stats-top-work-row`, `stats-top-site-row`. `stats-period-menu` and `stats-month-screen` go away. Every `switch` over `StatsScope` in the view (`scopePhrase`, `figures`, `graphAndBreakdown`, `activate`, `plotContentWidth`) follows the new shape.+- Tests: `Asterism/AsterismTests/StatsDerivationTests.swift` (`Stats derivation`, `Stats navigation`, `Stats derivation keys`, `Stats read discipline` suites — the `closeMonth` / `select(period:)` cases are replaced, not kept), `Asterism/AsterismUITests/StatsUITests.swift` (the All-time drill-down and spanning-months journeys), `Asterism/AsterismUITests/AccessibilityJourneyUITests.swift:256-300` (asserts on the capsule and chevron row with the unit on Week, where the row is present). Scenario `seeded-spanning-months` (no works) covers clamping, stepping and the sites list; `seeded-m1` (works present) covers the works list and its route.+- Dependencies: `RecentPresentationRow.entry.firstCapturedAt`, `.entry.workID`, `.workDisplayTitle`, `.hostname` (a non-optional `String`) — all already published by `AppLibraryModel`. `AsterismCore` is not modified.+- Spec amendments, in the same change: `specs/stats-page/requirements.md` Reqs 3.1, 3.2, 5.2–5.5 and the "breakdown by site" non-goal marked superseded by this spec; `specs/stats-page/decision_log.md` Decision 3 status "superseded in part by stats-period-navigation", Q30, Q31 and Q55 annotated; `docs/asterism-design.md` §5.4; `docs/asterism-style-guide.md` line 100 (Stats is no longer the `Menu` example); `specs/OVERVIEW.md` rows for both specs.+- Out of scope: any `AsterismCore` change or new published snapshot; site display names (hostnames are shown); ranking on anything other than first captures; custom date ranges; persisting the shown period; changes to the chart's construction.++## Risks and Assumptions++- Risk: three segments may not fit at the largest accessibility sizes, which is why stats-page Q31 chose a `Menu` for five. | Mitigation: the capsule recipe already stacks its segments full width at those sizes. The accessibility journey UI test's no-clipping assertion is the arbiter: if it fails on the capsule, the toggle becomes a `Menu` with the three items — not a judgement by eye.+- Risk: a time-zone change while an earlier period is shown leaves the stored anchor off the new calendar's boundary. | Mitigation: the scope resolves through `dateInterval(of:for:)` on the anchor, which lands on one whole period either way, and the next transition re-normalises it.+- Assumption: the minimum usable capture date over `allRows` is the right lower clamp — every plotted note lies at or after it (Req 3.5).+- Assumption: the `Stats read discipline` suite keeps proving zero repository calls; nothing here reads the store.++## Escalation Note+This change was scoped as a smolspec. If implementation reveals ambiguity only the user can resolve, an irreversible boundary (public API, persisted schema, auth path), or a contested architectural choice, stop and escalate to the full spec workflow rather than deciding it inline.
specs/stats-period-navigation/tasks.md Added +59 / -0
diff --git a/specs/stats-period-navigation/tasks.md b/specs/stats-period-navigation/tasks.mdnew file mode 100644index 0000000..d0cf7ea--- /dev/null+++ b/specs/stats-period-navigation/tasks.md@@ -0,0 +1,59 @@+---+references:+    - specs/stats-period-navigation/smolspec.md+    - specs/stats-period-navigation/decision_log.md+---+# Stats Period Navigation++## Derivation++- [x] 1. The navigation state machine moves by unit and anchor: Week/Month/All time selection, step back/forward, pick a date, open a month from All time — clamped at both ends, clearing the selected day, and returning to clock-tracking (nil anchor) whenever a transition lands on the current period <!-- id:tqfxo9o -->+  - Rewrite StatsPeriod/StatsScope/StatsNavigation in Asterism/Asterism/ViewModels/StatsDerivation.swift per the smolspec.+  - Verify in the Stats navigation suite of Asterism/AsterismTests/StatsDerivationTests.swift: default is Week at the current week (anchor nil); step back then forward re-nils; a pick inside the current period stays nil; back is refused at the earliest-capture period and forward at the current one; no usable capture leaves both refused; Month→Week moves to the lower bound when the month's first week begins before it; Week→Month keeps the month; All time→Week/Month is current; open(month:) sets unit Month, the anchor, and clears selectedDay; every transition clears selectedDay.+  - make test-quick passes.+  - References: specs/stats-period-navigation/smolspec.md++- [x] 2. The graph derives for any week or month anchor, carries the earliest usable capture date and a non-optional period-figures pair (zeros when All time resolves no span), and the derivation key is (generation, unit, anchor, selectedDay, temporal) so a same-period pick derives nothing and body reads no clock <!-- id:tqfxo9p -->+  - span(for:) resolves any .week(start:)/.month(start:) via dateInterval(of:for:); StatsGraph gains earliestUsableCapture; StatsPeriodFigures becomes non-optional; StatsInputKey is keyed on unit+anchor with the scope resolved in deriveIfNeeded().+  - Verify in the Stats derivation and Stats derivation keys suites: an arbitrary past week and month bucket by day with zero bars, half-open boundaries and DST days; All time period figures equal the lifetime notes total less undated notes; a nil-span All time gives zero figures; a temporal-key change with a nil anchor yields the new current period; a same-period pick leaves GraphIdentity unchanged.+  - The Stats read discipline suite still records zero repository calls across step, pick, unit switch and open(month:). make test-quick passes.+  - Blocked-by: tqfxo9o (The navigation state machine moves by unit and anchor: Week/Month/All time selection, step back/forward, pick a date, open a month from All time — clamped at both ends, clearing the selected day, and returning to clock-tracking (nil anchor) whenever a transition lands on the current period)+  - References: specs/stats-period-navigation/smolspec.md++- [x] 3. The graph carries up-to-five most-read works and up-to-five most-read sites for the span, ordered by count then title/hostname then identity, excluding unattached and unresolved-title notes from works and empty hostnames from sites, classified from the row alone <!-- id:tqfxo9q -->+  - Add StatsRankingRow / StatsSiteRankingRow and derive them in the same pass as periodFigures using the existing precedes ordering, then prefix(5).+  - Verify in the Stats derivation and Stats derivation invariants suites: the top-5 cap; ordering and tie-breaks (title localizedStandardCompare then UUID; hostname plain <); unattached and workDisplayTitle == nil notes absent from works yet counted in sites; empty hostname absent from sites; sum of works counts <= period notes; both lists empty when the span holds no notes; All time ranks the whole plotted library.+  - make test-quick passes.+  - Blocked-by: tqfxo9p (The graph derives for any week or month anchor, carries the earliest usable capture date and a non-optional period-figures pair (zeros when All time resolves no span), and the derivation key is (generation, unit, anchor, selectedDay, temporal) so a same-period pick derives nothing and body reads no clock)+  - References: specs/stats-period-navigation/smolspec.md++## View++- [x] 4. The Stats page shows the Week/Month/All time capsule and the chevron/label/chevron row in place of the Menu; the label opens a bounded graphical date picker; chevrons and picker are disabled at the bounds and before the first derivation; the pushed month screen is gone and an All-time bar switches to Month at its month <!-- id:tqfxo9r -->+  - In Asterism/Asterism/Views/StatsView.swift delete periodControl (the Menu), navigationDestination, monthScreen and the openedMonth binding; add the three-segment capsule (docs/asterism-style-guide.md line 100 recipe), the chevron/label/chevron row (This week / This month / dated via Date.IntervalFormatStyle or monthText; absent for All time; stacks at accessibility sizes), the Button-presented sheet with a bounded .graphical DatePicker, and the new accessibility identifiers. Update scopePhrase, figures, graphAndBreakdown, activate and plotContentWidth for the new StatsScope.+  - Verify in Asterism/AsterismUITests/StatsUITests.swift on seeded-spanning-months: an All-time bar tap leaves the capsule on Month with the label naming that month; back and forward chevrons change the label; back is disabled at the earliest month and forward at the current one; the picker sheet selects a month; the old month-screen and menu journeys are replaced.+  - make test-ui passes for StatsUITests.+  - Blocked-by: tqfxo9p (The graph derives for any week or month anchor, carries the earliest usable capture date and a non-optional period-figures pair (zeros when All time resolves no span), and the derivation key is (generation, unit, anchor, selectedDay, temporal) so a same-period pick derives nothing and body reads no clock)+  - References: specs/stats-period-navigation/smolspec.md++- [x] 5. Most-read works and most-read sites sections render below the graph and breakdown with period-phrased headings, are absent when the period holds no notes, and a work row routes to its work <!-- id:tqfxo9s -->+  - Two sections (stats-top-works, stats-top-sites) reusing breakdownRowContent and openWork, with headings phrased by period (this week / dated / all time).+  - Verify in StatsUITests on seeded-m1: both sections exist for a period with notes; a stats-top-work-row tap lands on the Works tab with that work open; the sections are absent on an empty period (step back past the seeded data or pick an empty week). Unit-test the heading phrase helper.+  - make test-ui passes for StatsUITests.+  - Blocked-by: tqfxo9q (The graph carries up-to-five most-read works and up-to-five most-read sites for the span, ordered by count then title/hostname then identity, excluding unattached and unresolved-title notes from works and empty hostnames from sites, classified from the row alone), tqfxo9r (The Stats page shows the Week/Month/All time capsule and the chevron/label/chevron row in place of the Menu; the label opens a bounded graphical date picker; chevrons and picker are disabled at the bounds and before the first derivation; the pushed month screen is gone and an All-time bar switches to Month at its month)+  - References: specs/stats-period-navigation/smolspec.md++- [x] 6. The period control, chevron row and ranked lists stay unclipped and reachable at the largest accessibility Dynamic Type size, with the Menu fallback applied only if that assertion fails (Q16) <!-- id:tqfxo9t -->+  - Rewrite Asterism/AsterismUITests/AccessibilityJourneyUITests.swift:256-300 to assert stats-unit-week/month/all, stats-period-back/forward, stats-period-picker and stats-top-works are inside the window and above stats-bar-0 with the unit on Week at the largest accessibility size.+  - If the capsule assertion fails, replace the capsule with a Menu of the three units and record the switch in specs/stats-period-navigation/decision_log.md (Q16).+  - make test-ui passes for AccessibilityJourneyUITests.+  - Blocked-by: tqfxo9r (The Stats page shows the Week/Month/All time capsule and the chevron/label/chevron row in place of the Menu; the label opens a bounded graphical date picker; chevrons and picker are disabled at the bounds and before the first derivation; the pushed month screen is gone and an All-time bar switches to Month at its month), tqfxo9s (Most-read works and most-read sites sections render below the graph and breakdown with period-phrased headings, are absent when the period holds no notes, and a work row routes to its work)+  - References: specs/stats-period-navigation/smolspec.md++## Documentation++- [x] 7. The stats-page spec, design doc, style guide and specs overview record the supersession: Reqs 3.1, 3.2, 5.2–5.5 and the site non-goal; Decision 3, Q30, Q31, Q55; design §5.4; style-guide Menu example; OVERVIEW rows for both specs <!-- id:tqfxo9u -->+  - Mark the superseded requirements and decisions in place with a pointer to specs/stats-period-navigation/; note the supersession in the stats-page OVERVIEW row and add a row and detail section for stats-period-navigation.+  - No code changes; verify the links resolve and make test-core still passes.+  - Blocked-by: tqfxo9t (The period control, chevron row and ranked lists stay unclipped and reachable at the largest accessibility Dynamic Type size, with the Menu fallback applied only if that assertion fails (Q16))+  - References: specs/stats-period-navigation/smolspec.md, specs/stats-page/requirements.md, specs/stats-page/decision_log.md, docs/asterism-design.md, docs/asterism-style-guide.md, specs/OVERVIEW.md
specs/stats-period-navigation/decision_log.md Added +40 / -0
diff --git a/specs/stats-period-navigation/decision_log.md b/specs/stats-period-navigation/decision_log.mdnew file mode 100644index 0000000..f6b6ca6--- /dev/null+++ b/specs/stats-period-navigation/decision_log.md@@ -0,0 +1,40 @@+# Decision Log: Stats Period Navigation++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-30 | Smolspec, not full spec | The owner answered every open product question up front (Q2–Q8); the remaining choice is a local refactor of `StatsNavigation` |+| Q2 | 2026-08-30 | "Most read" counts notes first-captured in the period | Same basis as the graph and breakdown (stats-page Decision 1); re-shares stay invisible |+| Q3 | 2026-08-30 | Ranked lists are top five, works and sites, ordered by count, then title/hostname, then identity | A single headline discards the shape; a full list duplicates the breakdown. Owner's choice |+| Q4 | 2026-08-30 | A site is the note's capture hostname | Already on every `RecentPresentationRow`; display names would need sites published from `AppLibraryModel`. Multi-site works make work-level attribution wrong |+| Q5 | 2026-08-30 | Tapping an All-time bar switches the unit to Month at that month; the pushed month screen is deleted | With Month a first-class unit the push duplicates it, and chevrons supply the "next month" the push could not. Supersedes stats-page Decision 3's drill-down half and Q30 |+| Q6 | 2026-08-30 | Tapping the period label opens a system date picker; the label is a `Button` presenting a bounded `.graphical` `DatePicker` in a sheet | Owner asked for the picker on the label. A `.compact` picker was the first shape, but it draws its own date text and cannot read "This week" or a week range, so the label presents the picker instead of being one |+| Q7 | 2026-08-30 | Navigation clamps at both ends: earliest usable capture and now | Owner's choice; a future period is always all zeros and an earlier one always empty |+| Q8 | 2026-08-30 | Scoped figures and per-day breakdown are kept; the ranked lists are added below the graph | Owner's choice |+| Q9 | 2026-08-30 | `anchor: Date?` with nil meaning the current period | A stored week start goes stale at midnight; nil re-resolves against the temporal key, keeping stats-page Req 3.7 without a timer |+| Q10 | 2026-08-30 | A unit switch keeps the shown date (period start), clamped | Switching Week → Month to widen the view and landing on today would lose the place |+| Q11 | 2026-08-30 | The unit toggle is a three-segment capsule, not a `Menu` | Three short labels fit where five long ones did not (stats-page Q31); the capsule recipe stacks at accessibility sizes. A `Menu` is the stated fallback |+| Q12 | 2026-08-30 | Period figures and ranked lists are shown for All time too | Q55's reason (restating the totals) is outweighed by a header that would otherwise change shape on one toggle position |+| Q13 | 2026-08-30 | Unattached and unresolved-work notes are excluded from the works ranking, included in the sites ranking | They are not works; they do have a hostname |+| Q14 | 2026-08-30 | `StatsInputKey` keys on `(unit, anchor)`; the scope is resolved inside `deriveIfNeeded()` | Keying on a resolved scope would put a `Date()` / `Calendar.current` read in `body`; the page's stance is that nothing derives on a body evaluation |+| Q15 | 2026-08-30 | A step or pick landing on the current period stores `anchor = nil` | Otherwise a period reached by navigation stops tracking the clock at midnight — the failure Q9 exists to prevent |+| Q16 | 2026-08-30 | The `Menu` fallback for the toggle is triggered only by the accessibility journey test's no-clipping assertion failing | One measured criterion rather than an implementer's eye |+| Q17 | 2026-08-30 | Rankings classify from `workID` / `workDisplayTitle` / `hostname` on the row, never by consulting the `works` array | Same rule as `category(of:)`; keeps the works array the source of the works total only (stats-page Decision 5) |+| Q18 | 2026-08-31 | The anchor stores the shown period's **midpoint**, not its start | A period start is a boundary instant, and re-resolving it under a calendar at a lower UTC offset lands in the period before — 2026-08-03 00:00 UTC is the Sunday of the *previous* Monday-start week in Honolulu, so a zone move would silently shift the shown week. The midpoint is half a period from either boundary, further than any zone change moves it. Nil still means the current period, and every read still normalises through `dateInterval(of:for:)` |+| Q19 | 2026-08-31 | The Req 2.10 tiles keep the preposition on a dated period: "in August 2026", "in 23–29 Aug 2026" | The tile reads as a sentence and sits beside "80 notes in all"; "12 notes August 2026" next to it reads as a defect. The bare label stays available for the chevron row, which is not a sentence |+| Q20 | 2026-08-31 | A forward step from a period that a moved bound has put out of range clamps to the new lower bound, and `selectableRange` widens to `min(lower, shown)...now` | The shown period stays where it is after a republish (the requirement), so the *next* step has to land somewhere the library still holds — the lower bound is the nearest such period. The range widens for the same state: a `.graphical DatePicker(in:)` handed a selection outside its range is undefined, and widening costs nothing because `show(_:)` clamps a pick either way |+| Q21 | 2026-08-31 | Re-selecting the unit already shown is a no-op and does not clear `selectedDay` | Req 5.5's "switching unit" means a change of unit. Tapping the segment already selected changes no period, so the selected day still points at the bar the reader chose |+| Q22 | 2026-08-31 | `weekText` may read `Calendar.current` in `body` | Q14's rule is that nothing *derives* on a body evaluation; formatting a stored anchor into "23–29 Aug 2026" derives nothing and re-renders correctly anyway, because the temporal key already forces a re-render on a zone or first-weekday change |+| Q23 | 2026-08-31 | The naming rules (`label`, tile phrase, ranking phrase) live in `StatsPeriodNaming` beside the derivation, not as computed properties on `StatsView` | Q41's rule: no app-layer test instantiates a view, so a rule left in `body` is a rule nothing can check — and the three arms differ only by a preposition. The calendar is a parameter, so a test can state the first weekday a week's dates depend on |+| Q24 | 2026-08-31 | The date picker's binding resolves a nil anchor to `now`, not to the current period's start | Q18 made an anchor an instant *inside* its period rather than its boundary, and `now` is that instant for the current period. It is inside the picker's range by construction, where a period start re-introduces the boundary Q18 exists to avoid. The pick is normalised and clamped by `show(_:)` either way |+| Q25 | 2026-08-31 | A period that holds notes but ranks no work (or no site) shows the heading with a one-line explanation, not a bare heading | The requirement is that both sections appear wherever the period holds a note, and Q13 means a period of unattached notes ranks no work at all. The precedent is Req 6.9: a zero-value bar says so rather than rendering an empty list. The two explanations carry their own identifiers — `stats-top-works-empty` and `stats-top-sites-empty` — so a test can tell "the section is present and says why it is empty" from "the section is missing", which is the whole distinction this decision draws |+| Q26 | 2026-08-31 | The capsule stays; Q16's `Menu` fallback is not applied | The accessibility journey's no-clipping assertion — the only criterion Q16 admits — passes at `AccessibilityExtraExtraExtraLarge`: all three segments, both chevrons and the picker render inside the window, at 44 pt, above the graph |+| Q27 | 2026-08-31 | A unit switch from a **nil** anchor keeps the anchor nil, exempting it from Q10 | Nil means "the current period" (Q15), so a switch from the current period must land on the current period. Q10's rule carries the shown period's *start*, and the current week's start lies in the previous month whenever the week spans a boundary — so from the default state, tapping Month showed last month. A non-nil anchor keeps Q10 unchanged |+| Q28 | 2026-08-31 | The segmented capsule is one shared recipe, `ConstellationSegmentedControl` in `ConstellationKit` | Stats' three-segment unit toggle and work detail's two-segment sort were line-for-line copies — the `ViewThatFits` pair, the `@ScaledMetric` visual, the fills, the 44 pt target and the `.isSelected` trait, twice. A copy is a chance for the two to disagree, and §7 describes one recipe, not two. The control takes an optional container label so an existing caller's accessibility output is unchanged unless it asks otherwise (Q29's sibling: the Stats capsule asks). **This overrules the smolspec's scope line** ("`AsterismCore` is not modified"; "out of scope: any `AsterismCore` change"): the shared recipe is a new public SwiftUI view in `ConstellationKit`, which is a package the app links rather than a stored shape — no schema change, no archive or backup format change, no snapshot published or altered, so nothing the scope line exists to protect is touched. **Amended** (2026-08-31, pre-push review): the container label is now **required** rather than optional. A row of segments that never says what it is *for* is a gap rather than a caller's choice, and with both callers passing one — work detail's sort passes "Sort order" — the optional arm had no user left, only a second accessibility shape to keep working. `children: .contain` keeps every segment individually reachable, which is what "an existing caller unchanged" actually meant, and the work-detail accessibility journey now pins it |+| Q29 | 2026-08-31 | `body` may read `Date()` for the period control's enablement | Q14's rule is that nothing *derives* on a body evaluation; comparing the shown period with `now` to decide whether a chevron is disabled derives nothing and issues no library read. It re-renders correctly for the same reason Q22's formatting does — the `temporal` key already forces a re-render on a significant time change or a zone change. Extends Q22 from formatting to enablement |+| Q30 | 2026-08-31 | While the first derivation is still running the period control is **absent**, not disabled | Req "until the first graph has been derived, the chevrons and picker MUST be disabled" is satisfied by absence: the bounds come from the graph, so before there is a graph there is no control to disable and nothing for a reader to reach. The `ProgressView` is what the page shows instead, and a disabled control beside a spinner says less than no control at all |+| Q31 | 2026-08-31 | When the library loses its last usable capture date while an earlier period is shown, Q20 governs | The spec's "both chevrons disabled, picker range current…now" clause describes the *fresh* state — a nil anchor with no capture to clamp to. With a period shown, the requirement that the shown period stays wins: the shown period stays, a forward step lands on the current period, and the picker's range is `shown...now` so a `.graphical DatePicker(in:)` is never handed a selection outside its range |+| Q32 | 2026-08-31 | Task 6's "above `stats-bar-0`" wording is not applied to the ranked sections; the journey asserts width ≤ window width after scrolling | The requirement puts the ranked lists *below* the graph and breakdown, so nothing about them can be asserted by position above the first bar. What the no-clipping criterion (Q16, Q26) actually needs is that no element runs off the window, which the journey checks by frame width after scrolling to each section |+| Q33 | 2026-08-31 | The period control is **centred** on the Stats page (commit 329f3a2) | The style guide's §7 entry for the segmented capsule states its recipe and its stacking but no alignment, because its only caller until now sat beside a section header that fixed its place. The Stats control has no header to sit beside — it is the unit toggle with the chevron row beneath it, governing the whole page below — and left-aligning a two-part control under a leading-aligned column left the chevron row hanging off one edge of what it names. Centred, both parts read as one control over the graph. This settles the alignment for Stats; §7 and §10 now say which of the two placements applies to which caller rather than leaving it unstated |+| Q34 | 2026-08-31 | The works ranking publishes two identifiers, not one: `stats-top-work-row` for the routable row and `stats-top-row` for the inert one | Whether a ranked work can be opened is a property of the *current* snapshot, not of the derivation — a work merged away since the graph was derived carries no route (Req 6.10) — so the two rows are genuinely different controls: one is a `Button` announcing "Open Work …", the other a plain row announcing its name and count. One identifier over both would let a test that means "this row opens a work" pass on a row that opens nothing. The same split the breakdown already draws between `stats-breakdown-work-row` and `stats-breakdown-row`, and since the pre-push review all four come from one row helper in `StatsView`, so the two lists cannot come to disagree about the shape |

Things to double-check

The centred control at accessibility sizes, on a device

Q16 made the accessibility journey's no-clipping assertion the sole arbiter of whether the capsule survives, deliberately in place of a judgement by eye — and Q26 declares it verified. But what the journey actually asserts (Q32) is frame containment: every element's frame lies inside the window after scrolling. A segment whose text overflowed its own fixed-width frame while the frame stayed inside the window would pass.

In practice the segment label carries .lineLimit(1) and .fixedSize(horizontal: true), so the label pushes the frame rather than being clipped by it, and the likelier outcome is the stacked ViewThatFits candidate. Still: this is the one criterion the capsule-vs-Menu decision rests on, and it is narrower than the property it stands for. Worth a look at AccessibilityExtraExtraExtraLarge on the phone.

Month selection through a day grid

Q6 settled on a .graphical DatePicker because a .compact one draws its own date text and cannot read “This week” or a week range. The consequence is that selecting a month means tapping a day inside it. pick(date:) normalises, so the resulting period is correct — the question is whether the interaction reads as intended when the unit is Month. Nothing automated can answer that.

The ICU thin-space literal pinned in the naming test

StatsPeriodNamingTests asserts "10\u{2009}\u{2013}\u{2009}16 Aug 2026" — thin space, en dash, thin space, which is what ICU actually produces for en_AU, not the bare en dash the requirement's prose writes. Pinned as produced, and the test comment says so. Be aware that this expectation is a fact about ICU's locale data rather than about this code, so a future OS could break it without anything here regressing.

Q33 was recorded after the UI suites first went green

The centring landed in 329f3a2 and its decision entry (Q33) in 95cf817, both after the UI-suite results quoted in 0bce76f's commit body. The suites were re-run in 95cf817 and are green (StatsUITests 10/10, AccessibilityJourneyUITests 9/9, WorkDetailActionsUITests 13/13), so nothing is stale — but if you read the phase-2 commit body on its own, its evidence predates the layout it describes.