A fourth reading status, catchingUp, and a Catching up section on Today. Seventeen commits since the merge base, two implementation phases each reviewed by a design critic, then this pre-push pass.
ReadingStatus gains catchingUp, ordered second, stored in the existing raw-string column: no schema or archive bump (Q2, Q6).catchingUp carrier writes only over reading, and the reverse direction is left as it stands (Q10, Q15).TodaySchedule and TodayDayPlan carry a catch-up list; the view draws it after the releases on the current and future days, never on a past day, nothing when empty (Q4, Q5).main; the full M4 target has not been run against the changed fixture.Ready to push
Core is green (3,025 tests, zero known issues), the unit bundle and the Mac build are green, and every one of this feature's own journeys passes. Two things a reviewer should not wave through: make test-ui and make test-ui-ipad carry four failures that reproduce verbatim on main at 215c843 yet were green the day before, and two of them sit on the Status card this feature did change; and the M4 fixture now holds 66 authored works, so every performance band except todayPublication is unverified against it.
Pass rate: 100% (2977 of 2977)
New tests: 22
Diff coverage: 97% (339 of 349 added lines)
8f5c65b5 [feat]: pre-push review fixes for catch-up-mode e618fb23 [doc]: catch-up-mode is done 3d381536 [doc]: changelog for phase 2 81a82192 [feat]: the catch-up journey asserts Q9's words and the header takes its accent from the one place 38c0e867 [doc]: the phase 2 verification run 7b09d8c9 [feat]: the today fixture carries a catch-up work and the journeys prove it c681b5c6 [feat]: the Today screen draws the Catching up section ecbd6bf8 [feat]: the release-day note tells a catching-up reader the days are parked 0cc37411 [feat]: every status surface shows Catching up b6075040 [doc]: catch-up-mode is in progress 4d99e434 [doc]: changelog for phase 1 6cff63bf [doc]: catch-up-mode phase 1 review notes 4d479b19 [feat]: the blocked catch-up group is a fixed point 8afb999b [feat]: the M4 fixture holds catch-up works a442e7c1 [feat]: the Today schedule and day plan carry catch-up rows a3e08b9b [feat]: catchingUp is authored content in the duplicate phase ab7c8b4c [feat]: catchingUp reading status Asterism tracks what you read. Every *work* (a book, a webcomic, a serial) has a reading status: Reading, Finished or Abandoned. It can also carry release days, the weekdays its author publishes on, which the Today screen uses to show what is due.
This adds a fourth status, Catching up, for a story you are behind on. If you are twenty chapters back, waiting for Thursday makes no sense. So a catch-up work leaves the weekly schedule entirely (its release days are kept, just parked) and is listed instead in its own Catching up section on Today, on the current day and every future day.
forward.fill arrow in the cyan the reading side of the app already uses, shown wherever the other three are: detail meta line, Works row, filter menu and pill, duplicate review card, merge preview.Catching up section under Releasing, hidden completely when empty and never drawn on a past day.Release days apply again once you're back to Reading while the draft is catching up.Before this, a reader behind on a story could leave it on Reading and have Today nag them on a day they were not waiting for, or clear the release days and lose the author's schedule. Now the state is named, the schedule survives, and the work is offered every day rather than once a week. It was also cheap: the status is stored as a plain string with a tolerant reader.
ongoing, hiatus, finished) is about the *author*. Independent, which is why a finished story can still be caught up on.catchingUp as reading.14/15, unmoved here.In Core: DomainEnums.swift gains case catchingUp second, after reading, with isDone untouched. TodayDayPlan.swift gives TodaySchedule a catchingUp: [ScheduledWork] and TodayDayPlan a catchingUp: [ReleaseRow], both public inits taking the parameter with no default, and a private row(for:on:calendar:) shared by the release and catch-up maps. DuplicateReconciler.swift gains one condition on the convergence carry. M4PerformanceFixture.swift gains isM4FixtureCatchUpIndex and m4FixtureCatchUpWorkCount (66), read by both the seeder and the pin.
In the app: WorkStatusPresentation.swift's name, systemImage and verdictPrompt gain the case; these are the only exhaustive switches over ReadingStatus, and the picker and filter menu iterate allCases. WorkDetailModel.showsReleaseDaysNote: Bool becomes releaseDaysNote: String?, wordings moved into ReleaseDaysText. TodayView.swift gains catchingUpSection, a shared releaseRows builder, ReleaseRowView.identifierPrefix (today-release- / today-catch-up-), TodaySectionAccents.catchingUp and the catch-up ids in TodayDisplayPlan.visibility.works. WorksView.swift moves the abandoned knock-down to WorksRowPresentation.isKnockedDown, still abandoned-only. AppLibraryModel.seedTodayWork takes a reading status and the today fixture gains Ember Road, finished and catching up.
The pivot is that a catch-up work is not a scheduled work (Q8). Rather than threading a flag through the scheduled list, TodaySchedule.build partitions the snapshots into two disjoint buckets in one walk: the scheduled filter is unchanged (ongoing, reading, non-empty days) and anything else on catchingUp falls into the second. dueWeekdays derives from the scheduled bucket only, which is why no strip dot appears.
TodayDayPlan.build maps the catch-up bucket with no weekday filter and returns an empty list when day < currentDay. The rows are the same ReleaseRow with the same second-line rule (Q13), so Noted today, Last noted ... yesterday and Not noted yet all work unchanged.
The view draws the section only when non-empty, a deliberate deviation from update-schedule Req 5.6 (Q5). Because both sections draw the same row view, the identifier prefix is the only thing distinguishing them for a UI journey, so ReleaseRowView takes the prefix and builds both the row's and the line's identifier from it.
finished, which is nonsense; a status cannot, and it stays inside the existing raw-string column so nothing structural moves.ScheduledWork and ReleaseRow (Q13): the names read wide for a work that is not scheduled, but a parallel pair would drift.The carry guard (Q10, Q15). In DuplicateReconciler.apply(_:to:types:) around line 1754:
``swift let carryMayOverwrite = carriedReadingStatus != .catchingUp || row.readingStatus == .reading ``
The ordinary case never reaches it: WorkAuthoredContent.isBare asks for reading and nothing else, so catchingUp is authored content and a group disagreeing between it and a done status is classified as two variants and routed to review before any write. The guard covers only the scan-to-write race, where classification and write read the rows at two different moments. It is deliberately one-directional (Q15): a finished carrier may still write over a catchingUp row. Closing that would reduce the carry to "writes only over the default", which also changes finished over abandoned, a convergence change outside this feature. DuplicateReconcilerTests.catchingUpCarrierNeverOverwritesADoneSibling drives reconcileToFixedPoint() after the blocked pass, so the assertions describe a resting state: the group stays divergent and goes to the reader.
The pre-feature build (Q6, Q7). An older build on the same iCloud account reads catchingUp as reading, so a catch-up work with days reappears in its releases section there and a Save writes reading back. Worse, its export refuses the whole library by name while any row holds the value, background export included, because writes are strict even though reads are tolerant. Archives from the new build are likewise refused as malformed. Accepted for a single-reader library; the mitigation is "update every device", and a stale Development Mac counts, since both configurations mirror.
Q17, the midnight tie. Ember Road's entry is backdated to max(startOfDay(previousDate), previousDate - 60). Recent orders by instant and breaks ties on the minted UUID, which is random per run, so seeding both at the same instant would have made "Paper Lantern first" a coin toss the journey asserts. The floor stops a launch in the first minute after midnight pushing the row into the day before, which is all the Last noted ... yesterday line reads.
The M4 fixture. seedM4ScheduleLayer marks catchingUp on every base work whose index is a multiple of 10 and not of the schedule stride, 66 of 1,000, in the same walk that assigns release days. The exclusion makes the two layers disjoint by construction rather than by ordering (index 0 is a multiple of both and belongs to the schedule). The consequence reaches past the one measured arm: isBare counts those 66 as authored, so the duplicate scan, the reconcile arms, the settling pass and the backup arms now see 66 more non-bare works than update-schedule measured, and their bands are unverified.
No schema, migration, readiness marker or archive generation moves; the live schema stays V15 and the archive pair 14/15. BackupV14Types.swift types readingStatus as the enum, so export and import needed no code, only a round-trip test. Both public inits took the new parameter without a default, forcing every call site to be looked at. The edit-on-Save rule is untouched: the note is read off drafts and nothing new writes before Save.
{catchingUp, reading} group shows the representative's status until the next reconcile trigger, so a work can be absent from Catching up until launch or arrival. True of every authored field, visible here.finished carrier flatten a catchingUp row inside the race window. The next scan calls the group divergent, so it surfaces rather than disappearing. Watch whether reading-status review cards become common.make test-performance-m4 may move on the duplicate and backup arms purely from the fixture change, and that must not be read as another branch's regression.WorksSort.abandonedLast still spells the abandoned-only rule inline, duplicating WorksRowPresentation.isKnockedDown, because a view model may not call a Views type. Both are pinned; they can still drift.DomainEnums.swift; WorkStatusPresentation.swift (name, forward.fill, cyan hue, Reading: Catching up). Picker (WorkDetailView.swift:1067), filter menu (WorksView.swift:355), pill, duplicate card and merge preview read the table or allCases. Pinned by ModelContractTests, WorkStatusPresentationTests, WorksFilterPresentationTests, DuplicateSurfaceTests.isDone unchanged, verdictPrompt nil, isKnockedDown abandoned-only. WorkDetailModelTests pins the absent precondition and prompt and Q16; WorksListOptionsTests pins sort and draw.TodayDayPlan.swift:395, TodayView.swift:446; collation by WorkSnapshot.titleOrder; second line by the shared row(for:on:calendar:); prefix at TodayView.swift:1512. Pinned by catchUpRowsTakeTheReleaseLine and theRowPrefixesTellTheSectionsApart.day < currentDay ? [] and if !plan.day.catchingUp.isEmpty, pinned by pastDaysCarryNoCatchUpRows, theSectionIsEmptyWithNothingToCatchUpOn and testAPastDayDrawsNoCatchUpSection.TodaySchedule.build and dueWeekdays from the scheduled bucket only. Both halves pinned, including the working tree's new catchUpDaysReturnWithTheReadingStatus.WorkDetailModel.releaseDaysNote and ReleaseDaysText.catchingUpNote, pinned by WorkDetailModelTests and, after the phase 2 review fix, by testTheReleaseDaysNoteFollowsTheDraftStatuses.showsDayChrome branch and the non-chrome plan passes catchingUp: []; the empty-library branch keeps it (theEmptyLibraryKeepsTheCatchUpSection); the prune fix at TodayView.swift:1098 plus testACatchUpRowTakesTheDetailColumnAndADayChangeClearsIt.DuplicateScanTests pins the silent fold and the divergent pair, WorkMergePlannerTests target-keeps and source-discarded, BackupV14ArchiveTests.catchingUpRoundTrips the 14/15 round trip.todayPublication measured 0.8397 s median against 2 s, with 66 catch-up works asserted in the schedule and on the current day outside the timed closure. But it is one run on a host at load average 9.02, and this suite's history says one run is not a baseline. Met on the evidence available; the evidence is thin.Nothing from the MUST list. Everything the smolspec placed out of scope (automatic detection, strip dots, a past-day section, a badge, Stats, markdown export, share sheet, generation bump, schema bump, the segmented control) is correctly absent.
make test-core green, zero known issues; make test-quick green (1,650 tests, build-mac included). make test-ui exited 2 with 3 failures, make test-ui-ipad exited 2 with 1. All four reproduce verbatim at 215c843 on main, in a worktree holding no branch code, and are recorded as unexplained rather than attributed. Two of them (WorkDetailStatusUITests) compare frames inside the Status card, which this feature did change, so the exoneration rests on the reproduction, not on an untouched surface.make test-performance-m4 was never run against the changed fixture. The one real gap: 66 base works moved from bare to authored, so every arm below todayPublication has an unverified band. CLAUDE.md and specs/OVERVIEW.md both now say so..releasesFirst; the committed code drew it in both placement arms anyway, and the working tree removed the .notedFirst copy for a comment. Behaviour-identical, but the past-day rule now lives only in TodayDayPlan.build, with no view-level guard.TodaySectionAccents.catchingUp became a static let in the working tree, no longer reachable through an instance. Req 5.7's rule is still in one place, but a reader looking beside releases and noted finds a constant rather than a computed answer.releaseRows, the visibleWorks set, the single-walk TodaySchedule.build and M4 seeder) are review-driven and carry no behaviour change. They are the largest and most skimmable part of the uncommitted diff.Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift
Why it matters. This is the whole feature's foundation and the one irreversible-looking change that turns out not to be. The case is inserted second, between reading and finished, which moves display order for the picker and the filter menu but not the stored value, because the column stores the raw string. Confirm isDone was left alone: everything that hides the verdict field reads it, so a wrong edit there would put a verdict prompt in front of a reader who is still reading.
What to look at. Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift:54-73 (enum ReadingStatus, case catchingUp, isDone)
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift
Why it matters. The only behaviour change inside the convergence pass, and the subtlest hunk on the branch. A carrier on catchingUp may now write only over a sibling still on reading. It is deliberately one-directional: a finished or abandoned carrier may still write over a catchingUp row. A reviewer should check that the asymmetry is intentional and that the comment says why, because the guard looks half-finished if you do not know Q15.
What to look at. Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift:1754-1770 (carryMayOverwrite)
Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swift
Why it matters. The heart of the feature. TodaySchedule.build now partitions the snapshots into two disjoint buckets in one walk, dueWeekdays still derives from the scheduled bucket only (which is why no strip dot appears), and TodayDayPlan.build maps the catch-up bucket with no weekday filter and returns empty when day is before currentDay. Both public initialisers took the new parameter with no default, so check every call site was actually considered rather than handed an empty list by reflex. The one-walk partition and the shared row helper are working-tree review fixes on top of the committed filter-twice version.
What to look at. Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swift:195-240 (TodaySchedule) and 346-421 (TodayDayPlan.build, private row helper)
Asterism/Asterism/Views/TodayView.swift
Why it matters. Three things to read together. The section is drawn only when non-empty, which deliberately breaks the screen's own rule that an empty section keeps its header. ReleaseRowView now takes its identifier prefix as a parameter, so the same row view serves both sections and a journey counting today-release- rows cannot silently start counting catch-up ones. And the prune's visibility.works had to gain the catch-up ids, or the wide layout would clear the detail column the instant a reader opened a catch-up row. The working tree also removed the .notedFirst copy of the section, so the past-day rule now lives only in Core, with no view-level guard.
Asterism/Asterism/ViewModels/WorkDetailModel.swift
Why it matters. showsReleaseDaysNote: Bool became releaseDaysNote: String?, and the two wordings moved into ReleaseDaysText. The catch-up arm sits in front of the existing condition, which is load-bearing: catchingUp would satisfy the old condition for the wrong reason, and the old sentence would be false because the work is on Today, just in a different section. Check that the existing condition is otherwise byte-for-byte unchanged, since it still decides every other status.
Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift
Why it matters. The M4 seeder marks catchingUp on every base work whose index is a multiple of 10 and not of the schedule stride, 66 of 1,000, with one predicate read by both the seeder and the pinned count so they cannot drift. This is the change with the widest blast radius on the branch: WorkAuthoredContent.isBare counts those 66 as authored, so the duplicate scan, the reconcile arms, the settling pass and the backup arms all now see a different fixture, and only the todayPublication arm was re-measured. The today UI fixture separately gains Ember Road, finished and catching up with a backdated entry.
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift
Why it matters. catchingUpCarrierNeverOverwritesADoneSibling mutates a row inside afterScan to manufacture the scan-to-write race, then drives reconcileToFixedPoint() rather than a single pass, so the assertions describe where the library comes to rest: three rows converged and never collapsed, one still abandoned, two on catchingUp. Read it beside the scan tests, which pin the two classification outcomes the guard depends on. Without those, the guard's comment is an unverified claim.
finished or hiatus story the reader is behind on is exactly the case the section is for: it is about the reader's position in the story, not the author's.Releasing header stays true and update-schedule Req 5.5's plan-then-record ordering is kept. The prefix today-catch-up- is what lets a UI journey tell the two sections' rows apart; an identifier is never spoken, so a VoiceOver reader tells them apart by the header, a claim corrected at the phase 2 review.Development Mac counts.reading.catchingUp, the line reads Release days apply again once you're back to Reading. Keeping the existing wording would be false, because the work is on Today; removing the line would leave a toggled day with no signal that it is parked.GroupOrdering.isBare, so a group disagreeing between it and a done status is two variants and goes to review before the carry ever runs. The guard covers the scan-to-write race, where the carrier's read and the row's write are two moments.today UI fixture gains one finished work, Ember Road, catching up with an entry on the previous day. The arms and the UI suite should exercise the section rather than pass with it empty, and a finished work makes Q3 visible in the fixture.Catching up. ConstellationSegmentedControl is a ViewThatFits that stacks its segments full-width when the capsule does not fit, and four caption labels with their padding are near a phone card's width. A shorter label was the alternative and reads worse as a state name; the control itself is out of scope.catchingUp carrier writes only over reading, but a finished or abandoned carrier may still write over a catchingUp row. Closing the reverse direction means a carrier writes only over the default, which also stops finished over abandoned and is a convergence behaviour change outside this feature. The next scan classifies any such group as divergent and routes it to review, so the state is visible, not silent.setDraftWorkStatus's auto-revert and restore move a draft between reading and finished only. Q3 makes a finished work a valid catch-up case, so reverting or restoring a catch-up draft would contradict it. No new branch was needed.Last noted ... yesterday line reads.catchingUpCarrierNeverOverwritesADoneSibling now drives reconcileToFixedPoint() after the raced pass, so the assertions describe a resting state: the two authored statuses disagree, and every later pass calls the set divergent and hands it to the reader rather than picking a winner.WorkRow so it sits beside the row's other answers and can be pinned next to the sort that reads the same predicate. It stays abandoned-only: a catchingUp work sorts among the active works under every sort and draws at full strength. WorksSort.abandonedLast still spells the same rule inline, because a view model may not call into a Views type.make test-ui and one in make test-ui-ipad reproduce verbatim at 215c843 on main, in a worktree holding no branch code, while update-schedule's own run had those suites green the day before. Two of them compare frames inside the Status card, which this feature did change, so the exoneration rests on the reproduction rather than on an untouched surface. They want a bugfix spec of their own.todayPublication measured 0.8397 s median against a 2 s budget on a host at load average 9.02, inside update-schedule's band. The other arms were not re-measured, and the fixture they share did move: 66 base works are now on catchingUp, which isBare counts as authored, so their bands are unverified until the next quiet-host run.| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | CHANGELOG.md phase 2 entry | Claimed the four UI-test failures touch no surface this feature changes; the verification run says two sit on the Status card the fourth segment changed. | Reworded: two sit on a changed surface, all four are unexplained rather than attributed. |
| minor | TodayView.swift dayList | catchingUpSection was called in the .notedFirst arm, which is reachable only on a past day, where the plan already empties the list. | Call removed; a comment in the .notedFirst case says why there is no third section. |
| minor | TodayView.swift TodaySectionAccents | catchingUp was a computed property ignoring the type's fields and always returning violet, threaded through a parameter as if it varied. | Now a static constant read at the header; the accent parameter is gone; a test pins violet. |
| minor | TodayView.swift releasesSection / catchingUpSection | The ForEach over ReleaseRowView was duplicated verbatim between the two sections. | One releaseRows(_:identifierPrefix:selectedDay:) builder shared by both. |
| minor | TodayView.swift visibility | (day.releases + day.catchingUp).map(\.id) copied every ReleaseRow to read an id on each wide-layout body pass. | A Set of ids built from releases with catch-up ids inserted, only when something prunes. |
| minor | TodayDayPlan.swift TodaySchedule.build | Two full filter passes over the works with disjoint predicates, and the sort-and-map written twice, on the measured Today publication path. | One partitioning loop and a local collate closure. |
| minor | DuplicateReconciler.swift carry guard | The reading-status carry condition mixed comma-conjunction with a trailing disjunction and read poorly. | Hoisted into a named let; logic unchanged. |
| minor | WorksView.swift WorkRow | A local named isAbandoned returned isKnockedDown, and the helper's doc claimed to sit beside the sort that reads the same predicate, which lives in a view model. | Local renamed isKnockedDown; the doc now says the sort spells the same abandoned-only rule and the tests pin both. |
| minor | M4PerformanceFixture.swift seedM4ScheduleLayer | A second works.enumerated() walk marked the catch-up works after the release-day walk; the two conditions are disjoint. | Folded into one walk; the 66 pin and the 334 untouched works hold. |
| minor | TodayDayPlanTests.swift | MUST 5's second half, release days taking effect again on return to reading, had no test. | catchUpDaysReturnWithTheReadingStatus added. |
| minor | specs/OVERVIEW.md, CLAUDE.md | Neither recorded that the full M4 target has not been run since the fixture gained 66 authored works. | Open clause in the overview; one clause on the test-performance-m4 bullet in CLAUDE.md. |
| minor | docs/agent-notes/testing.md | The four UI cases failing at main's tip were recorded only in the feature's verification run, where the next branch would not look. | A testing-note section names the four cases and the 215c843 reproduction. |
| minor | TodayView.swift TodayVisibility.works doc | Doc comment still described the set as the releases rows only. | Now names the catch-up rows too. |
| minor | ReleaseRowView.identifierPrefix | A String where an enum of two kinds would make the disjointness test unnecessary. | Skipped: ShareCatchUpSection uses the same loose shape; consistency kept over an enum. |
| minor | M4ScaleFixtureTests.swift | The expected catch-up index set re-spells the predicate rather than calling isM4FixtureCatchUpIndex. | Skipped: the restatement is deliberately independent of the predicate under test. |
| minor | WorkDetailModelTests.swift, WorksListOptionsTests.swift | A double assertion of one value, and an inline work builder where a helper exists. | Skipped: test-only tidy-ups, not bugs; the review does not refactor tests. |
Source: local run at 2026-09-17T08:16:37+10:00 · snapshot acc05114481c3f1a64699c153b80b0b0cfa919c9
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: 1 file · Baseline: absent
Coverage scope: every test in the repository
Totals: 2977 passed · 0 failed · 48 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
Aggregate diff coverage: 97% (339 of 349 measurable added lines).
Head 93.7% (101453 of 108320 lines)
14 of 41 changed files matched coverage data.
Asterism/Asterism/ViewModels/AppLibraryModel.swift — no candidateAsterism/Asterism/ViewModels/WorkDetailModel.swift — no candidateAsterism/Asterism/Views/ReleaseDaysPresentation.swift — no candidateAsterism/Asterism/Views/TodayText.swift — no candidateAsterism/Asterism/Views/TodayView.swift — no candidateAsterism/Asterism/Views/WorkDetailView.swift — no candidateAsterism/Asterism/Views/WorkStatusPresentation.swift — no candidateAsterism/Asterism/Views/WorksView.swift — no candidateAsterism/AsterismTests/DuplicateSurfaceTests.swift — no candidateAsterism/AsterismTests/TodayDisplayPlanTests.swift — no candidateAsterism/AsterismTests/TodayFixtureSeedingTests.swift — no candidateAsterism/AsterismTests/WorkDetailModelTests.swift — no candidateAsterism/AsterismTests/WorkStatusPresentationTests.swift — no candidateAsterism/AsterismTests/WorksFilterPresentationTests.swift — no candidateAsterism/AsterismTests/WorksListOptionsTests.swift — no candidateAsterism/AsterismUITests/AccessibilityJourneyUITests.swift — no candidateAsterism/AsterismUITests/TodayUITests.swift — no candidateAsterism/AsterismUITests/WideLayoutUITests.swift — no candidateAsterism/AsterismUITests/WorkDetailStatusUITests.swift — no candidateCHANGELOG.md — no candidateCLAUDE.md — no candidatedocs/agent-notes/testing.md — no candidatespecs/OVERVIEW.md — no candidatespecs/catch-up-mode/decision_log.md — no candidatespecs/catch-up-mode/implementation.md — no candidatespecs/catch-up-mode/tasks.md — no candidatespecs/catch-up-mode/verification-run.md — no candidateFiles that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 3474c88.
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 2076f69f..ce459c34 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -51,7 +51,7 @@ public final class AppLibraryModel { /// reads it on every body pass, and `TodaySchedule.build` is O(works + /// entries) over the whole library. public private(set) var todaySchedule: TodaySchedule = TodaySchedule(- works: [], dueWeekdays: [])+ works: [], dueWeekdays: [], catchingUp: []) /// Every Work's display title by id, published with the snapshot it is /// derived from. ///@@ -2562,15 +2562,16 @@ public final class AppLibraryModel { /// `update-schedule`: the smallest library the Today screen has something /// to say about. ///- /// Three works on one site, all of them carrying release days, seeded in- /// this order — so the noted rows of the current day are in a known order- /// and the works list's title order is not the seeding order:+ /// Four works on one site, seeded in this order — so the noted rows of+ /// each day are in a known order and the works list's title order is not+ /// the seeding order: /// /// | Work | Work status | Reading status | Release days | Its entry | /// |---|---|---|---|---| /// | Kite Season | ongoing | reading | the current weekday | the current day | /// | Salt Harvest | ongoing | reading | the previous weekday | the current day | /// | Paper Lantern | hiatus | reading | both of those | **the previous day** |+ /// | Ember Road | finished | **catchingUp** | none | **the previous day**, a minute before Paper Lantern's | /// /// What that shape buys, in one scenario: ///@@ -2578,9 +2579,14 @@ public final class AppLibraryModel { /// noted rows, so Req 5.5's releases-first placement has two sections to /// order. Kite Season is in both, which is what gives a release row the /// `notedOnDay` line rather than the `lastNoted` one.- /// - The previous day holds a releases section (Salt Harvest) and one noted- /// row (Paper Lantern), so notes-first placement on a past day is a claim- /// about an order rather than about an empty half.+ /// - The previous day holds a releases section (Salt Harvest) and two noted+ /// rows (Paper Lantern and Ember Road), so notes-first placement on a past+ /// day is a claim about an order rather than about an empty half.+ /// - **Ember Road is finished, catching up and unscheduled**, so the+ /// `Catching up` section is non-empty on the current day and on every+ /// future one, absent on the previous day, and lights no release row and+ /// no strip dot anywhere (`catch-up-mode` Q3, Q5, Q8, Q12). Its entry a+ /// day back is what gives its row the `Last noted … · yesterday` line. /// - **Paper Lantern is on hiatus with days on both weekdays**, so it is /// scheduled by its days and excluded by its status on both days at once /// (Req 6.1). It is the only work either day's releases section can be@@ -2589,15 +2595,16 @@ public final class AppLibraryModel { /// days of the week hold neither a release nor a note, which is where /// both empty lines are reachable. ///- /// - `today.test` is **taught** once the three captures are in, so every+ /// - `today.test` is **taught** once the four captures are in, so every /// entry carries a chapter title and every release row has a chapter line /// to show (Req 5.2). See `teachTodaySite`. /// /// The days and the dates are computed from **the launch clock**, so the /// fixture means the same thing whatever day the suite runs on. Everything- /// is written through the repository's own paths, except the one date that- /// cannot be: `capture` dates every Entry now, so Paper Lantern's is moved- /// back afterwards through `backdateEntry` (`TodayFixture`).+ /// is written through the repository's own paths, except the two dates that+ /// cannot be: `capture` dates every Entry now, so Paper Lantern's and Ember+ /// Road's are moved back afterwards through `backdateEntry`+ /// (`TodayFixture`). /// /// **The previous day can fall in the previous displayed week** — on the /// calendar's first weekday it always does. The strip pages both ways with@@ -2633,11 +2640,30 @@ public final class AppLibraryModel { title: "Paper Lantern", slug: "lantern", chapter: 31, releaseDays: ReleaseDay.canonical([currentWeekday, previousWeekday]), workStatus: .hiatus, in: repo)+ // `catch-up-mode` Q12: one work the reader is behind on, so the section+ // has something in it. **Finished and unscheduled**, which is Q3 made+ // visible — the section is about the reader's position in the story and+ // not about the author's — and its entry is dated a day back, which+ // gives the current day the `Last noted … · yesterday` line the common+ // catch-up case reads.+ let emberEntry = try await seedTodayWork(+ title: "Ember Road", slug: "ember-road", chapter: 4,+ releaseDays: [], workStatus: .finished, readingStatus: .catchingUp, in: repo) try await teachTodaySite(in: repo, operation: operation) + // A minute behind Paper Lantern's, so the previous day's two noted rows+ // have a stated order: Recent orders by instant and breaks a tie on the+ // minted UUID, which is random per run. Floored at the day's start, so+ // a launch in the first minute after midnight cannot push the row into+ // the day before — it lands on the same civil day either way, which is+ // all the `Last noted … · yesterday` line reads.+ let emberDate = max(+ calendar.startOfDay(for: previousDate), previousDate.addingTimeInterval(-60))+ #if DEBUG || ASTERISM_PERFORMANCE_TESTING try await repo.backdateEntry(id: lanternEntry, to: previousDate)+ try await repo.backdateEntry(id: emberEntry, to: emberDate) #else throw LibraryRepositoryError.invalidInput( operation: operation, reason: "the today fixture requires a debug build")@@ -2697,6 +2723,7 @@ public final class AppLibraryModel { private func seedTodayWork( title: String, slug: String, chapter: Int, releaseDays: [ReleaseDay], workStatus: WorkStatus,+ readingStatus: ReadingStatus = .reading, in repo: LibraryRepository ) async throws -> UUID { let hostname = "today.test"@@ -2720,7 +2747,7 @@ public final class AppLibraryModel { displayTitle: title, typeAssignment: reloaded.typeDisplay.assignment, genreTags: reloaded.genreTags, genericNotes: "",- workStatus: workStatus, readingStatus: .reading, verdict: "",+ workStatus: workStatus, readingStatus: readingStatus, verdict: "", releaseDays: releaseDays, membership: reloaded.membership, thumbnail: .keep))
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 5bab2fb6..5abe2231 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -234,14 +234,23 @@ public final class WorkDetailModel { /// fact about the work — the draft is canonicalised on its way out (Req 7.5). public var draftReleaseDays: Set<ReleaseDay> = [] - /// Req 1.4's line under the toggles: a schedule the Today screen will not- /// list, said once, where the reader is setting it.+ /// Req 1.4's line under the toggles, or nil where there is none: a schedule+ /// the Today screen will not list, said once, where the reader is setting+ /// it. /// /// Read off the **drafts**, so a reader who pauses a work and toggles a day /// in the same edit is told before they save rather than after.- public var showsReleaseDaysNote: Bool {- !draftReleaseDays.isEmpty- && (draftWorkStatus != .ongoing || draftReadingStatus != .reading)+ ///+ /// A catching-up draft takes the other wording first (`catch-up-mode` Q9).+ /// It would satisfy the condition below — `catchingUp` is not `reading` —+ /// but the sentence that condition carries is false for it: the work is on+ /// Today, in its own section, and it is the days that are parked. Every+ /// other status keeps the existing line on its own unchanged condition.+ public var releaseDaysNote: String? {+ guard !draftReleaseDays.isEmpty else { return nil }+ if draftReadingStatus == .catchingUp { return ReleaseDaysText.catchingUpNote }+ guard draftWorkStatus != .ongoing || draftReadingStatus != .reading else { return nil }+ return ReleaseDaysText.note } // Confirmed Work URL state is independent from metadata submission so a
diff --git a/Asterism/Asterism/Views/ReleaseDaysPresentation.swift b/Asterism/Asterism/Views/ReleaseDaysPresentation.swiftindex 7ebc02f2..f344149e 100644--- a/Asterism/Asterism/Views/ReleaseDaysPresentation.swift+++ b/Asterism/Asterism/Views/ReleaseDaysPresentation.swift@@ -77,6 +77,18 @@ nonisolated enum ReleaseDaysText { return symbols[index] } + /// Req 1.4's line under the toggles: a schedule the Today screen will not+ /// list, said once, where the reader is setting it.+ static let note = "Shown on Today only while the work is ongoing and you are reading it"++ /// The same line for a reader who is catching up (`catch-up-mode` Q9).+ ///+ /// The other wording would be false here: the work *is* on Today, in the+ /// `Catching up` section, and it is the days that are parked. Saying+ /// nothing at all would leave a toggled day with no signal that Today is+ /// not using it.+ static let catchingUpNote = "Release days apply again once you're back to Reading"+ /// "Monday", "Monday and Thursday", "Monday, Thursday and Sunday". private static func sentence(_ names: [String]) -> String { guard let last = names.last else { return "" }@@ -192,7 +204,8 @@ struct ReleaseDayToggles: View { "work-detail-release-day-\(day.rawValue)" } - /// Req 1.4's line under the toggles.+ /// Req 1.4's line under the toggles. The wordings themselves are+ /// `ReleaseDaysText`'s, beside everything else a schedule is spelled with;+ /// which of them is shown is the model's answer. static let noteIdentifier = "work-detail-release-days-note"- static let note = "Shown on Today only while the work is ongoing and you are reading it" }
diff --git a/Asterism/Asterism/Views/TodayText.swift b/Asterism/Asterism/Views/TodayText.swiftindex 2e2c6a9a..72049b4f 100644--- a/Asterism/Asterism/Views/TodayText.swift+++ b/Asterism/Asterism/Views/TodayText.swift@@ -71,6 +71,13 @@ nonisolated enum TodayTitleText { /// Req 5.4's header, day-free since Q76. static let notedHeader = "Noted" + /// The catch-up section's header (`catch-up-mode` Q4).+ ///+ /// Its own section rather than more rows under `Releasing`, which would+ /// stop being true: nothing is releasing on a work the reader is simply+ /// behind on.+ static let catchingUpHeader = "Catching up"+ /// `Noted today` / `Noted {weekday}` — the **row** line's opening, not a /// header (Q76). ///
diff --git a/Asterism/Asterism/Views/TodayView.swift b/Asterism/Asterism/Views/TodayView.swiftindex 813c6b49..074d9baa 100644--- a/Asterism/Asterism/Views/TodayView.swift+++ b/Asterism/Asterism/Views/TodayView.swift@@ -116,7 +116,7 @@ struct TodayView: View { init( presentation: TodayPresentation,- schedule: TodaySchedule = TodaySchedule(works: [], dueWeekdays: []),+ schedule: TodaySchedule = TodaySchedule(works: [], dueWeekdays: [], catchingUp: []), // No defaults for these two, deliberately: a default would be this // screen reading a clock and a calendar of its own, which is exactly // what Q39 and Q44 put in `AppLibraryModel`. Both arrive from the cycle@@ -386,8 +386,11 @@ struct TodayView: View { switch plan.day.placement { case .releasesFirst: releasesSection(plan, accent: accents.releases)+ catchingUpSection(plan) notedSection(plan, accent: accents.noted) case .notedFirst:+ // No third section here (Q5): `.notedFirst` is a past day only,+ // and `TodayDayPlan.build` leaves `catchingUp` empty on one. notedSection(plan, accent: accents.noted) releasesSection(plan, accent: accents.releases) }@@ -413,15 +416,9 @@ struct TodayView: View { .constellationListRow() .accessibilityIdentifier("today-releases-empty") } else {- ForEach(plan.day.releases) { row in- ReleaseRowView(- row: row,- selectedDay: plan.day.day,- currentDay: currentDay,- calendar: calendar,- onSelect: onSelectWork)- .constellationListRow()- }+ releaseRows(+ plan.day.releases, identifierPrefix: ReleaseRowView.releasePrefix,+ selectedDay: plan.day.day) } } header: { ConstellationSectionHeader(@@ -432,6 +429,55 @@ struct TodayView: View { } } + /// The works the reader is behind on (`catch-up-mode` Q4, Q5), directly+ /// after the releases section on the current day and on every future one.+ ///+ /// **Absent entirely when there is nothing to list** — no header, no dim+ /// line — which is the one section that behaves that way (Q5, deviating+ /// from Req 5.6 deliberately). Catching up is a standing reminder rather+ /// than a weekly pattern, so most days hold none and an always-present+ /// empty block would be noise.+ ///+ /// The accent comes from `TodaySectionAccents` like the other two headers',+ /// so Req 5.7's rule stays in one place. It is the type's constant rather+ /// than one of its computed answers, because the releases header above this+ /// one is always drawn: this can never be the first header on the screen.+ @ViewBuilder+ private func catchingUpSection(_ plan: TodayDisplayPlan) -> some View {+ if !plan.day.catchingUp.isEmpty {+ Section {+ releaseRows(+ plan.day.catchingUp, identifierPrefix: ReleaseRowView.catchUpPrefix,+ selectedDay: plan.day.day)+ } header: {+ ConstellationSectionHeader(+ TodayTitleText.catchingUpHeader,+ accent: TodaySectionAccents.catchingUp+ )+ .accessibilityIdentifier("today-section-catching-up")+ }+ }+ }++ /// The rows both release-shaped sections draw. They are the same row and+ /// read the same second line (Q13); only the identifier prefix differs, so+ /// there is one `ReleaseRowView` call site rather than two that can drift.+ @ViewBuilder+ private func releaseRows(+ _ rows: [ReleaseRow], identifierPrefix: String, selectedDay: CivilDay+ ) -> some View {+ ForEach(rows) { row in+ ReleaseRowView(+ row: row,+ identifierPrefix: identifierPrefix,+ selectedDay: selectedDay,+ currentDay: currentDay,+ calendar: calendar,+ onSelect: onSelectWork)+ .constellationListRow()+ }+ }+ /// Req 5.4: the entries whose noted day is the selected day. Absent for a /// day after the current one, and replaced by the empty-library presentation /// where the library holds nothing at all (Req 5.8).@@ -847,8 +893,9 @@ nonisolated struct TodayVisibility: Equatable { /// The entries actually drawn — the spanning list's rows while a filter or a /// query is active, the selected day's noted rows otherwise. let entries: Set<UUID>- /// The works actually drawn, which is the releases section's rows while the- /// day chrome is shown and nothing at all while it is not.+ /// The works actually drawn, which is the releases section's rows and the+ /// catch-up section's rows while the day chrome is shown and nothing at+ /// all while it is not. let works: Set<UUID> init(entries: Set<UUID>, works: Set<UUID>) {@@ -1030,7 +1077,8 @@ struct TodayDisplayPlan { day: selectedDay, placement: selectedDay < currentDay ? .notedFirst : .releasesFirst, releases: [],- noted: nil)+ noted: nil,+ catchingUp: []) // **Both halves branch on `showsDayChrome`**, which is what decides // which list is drawn — `spansAllDays` differs from it exactly on the@@ -1041,6 +1089,17 @@ struct TodayDisplayPlan { // // Skipped entirely where nobody prunes: the sets are O(rows on screen) // and this initialiser runs on every body pass.++ // Both sections' rows: a work selected from a catch-up row is on screen+ // exactly as one selected from a release row is, and a prune that did+ // not know about it would clear the detail column the moment the reader+ // picked one (`catch-up-mode`). Inserted into one set rather than read+ // off `releases + catchingUp`, which copies every row to reach one field.+ var visibleWorks: Set<UUID> = []+ if needsVisibility && showsDayChrome {+ visibleWorks = Set(day.releases.map(\.id))+ for row in day.catchingUp { visibleWorks.insert(row.id) }+ } visibility = needsVisibility ? TodayVisibility(@@ -1048,7 +1107,7 @@ struct TodayDisplayPlan { showsDayChrome ? (day.noted?.rows ?? []).map(\.id) : spanning.flatMap(\.rows).map(\.id)),- works: Set(showsDayChrome ? day.releases.map(\.id) : []))+ works: visibleWorks) : TodayVisibility(entries: [], works: []) } }@@ -1074,6 +1133,14 @@ struct TodaySectionAccents: Equatable { var releases: ConstellationSectionHeader.Accent { releasesIsFirst ? .cyan : .violet } var noted: ConstellationSectionHeader.Accent { releasesIsFirst ? .violet : .cyan }++ /// The catch-up header (`catch-up-mode` Q4), always violet: it is drawn only+ /// under the releases section, whose header is always drawn above it, so it+ /// can never be the first header on the screen. A constant rather than one+ /// of the answers above, because it depends on nothing this value holds —+ /// stated here anyway so that every section header's accent is decided in+ /// this one place.+ static let catchingUp: ConstellationSectionHeader.Accent = .violet } /// What the duplicate banner counts, split into what Recent can show and what it@@ -1432,7 +1499,23 @@ struct ReleaseRowView: View { /// reader reads it. The row draws no site text of its own. @Environment(\.siteNames) private var siteNames + /// What the releases section's rows are addressed by.+ static let releasePrefix = "today-release-"+ /// What the catch-up section's rows are addressed by (`catch-up-mode` Q4).+ ///+ /// Two prefixes rather than one, because the two sections draw the same row+ /// for two different reasons and a **UI journey** has to be able to tell+ /// "due today" from "behind on it": a counted set of release rows must not+ /// quietly grow catch-up ones. An accessibility identifier is never spoken,+ /// so this does nothing for a VoiceOver reader — they tell the two apart by+ /// the section header they are under, which is what the header is for.+ static let catchUpPrefix = "today-catch-up-"+ let row: ReleaseRow+ /// Which section's row this is: `releasePrefix` or `catchUpPrefix`. The+ /// row's own identifier is the prefix and the work's id, and the line's is+ /// the prefix and `line`.+ let identifierPrefix: String /// The day the screen is showing, which is what the line is relative to. let selectedDay: CivilDay let currentDay: CivilDay@@ -1475,7 +1558,7 @@ struct ReleaseRowView: View { .foregroundStyle( isNotedOnDay ? AsterismColors.cyan : AsterismColors.secondaryText) .lineLimit(2)- .accessibilityIdentifier("today-release-line")+ .accessibilityIdentifier("\(identifierPrefix)line") } ) .frame(maxWidth: .infinity, alignment: .leading)@@ -1486,7 +1569,7 @@ struct ReleaseRowView: View { .padding(.horizontal, 12) .constellationCard() .accessibilityElement(children: .combine)- .accessibilityIdentifier("today-release-\(row.id.uuidString)")+ .accessibilityIdentifier("\(identifierPrefix)\(row.id.uuidString)") .accessibilityLabel( "\(row.title), \(line), from \(siteNames.label(for: row.primaryHostname))") }
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex f3141365..983852ea 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -1102,9 +1102,11 @@ struct WorkDetailView: View { .constellationCaptionedField("Release days") // Req 1.4: present exactly while there are days to explain- // and the statuses would keep them off Today.- if model.showsReleaseDaysNote {- Text(ReleaseDayToggles.note)+ // and the statuses would keep them off the releases section+ // — in the catching-up reader's own words (`catch-up-mode`+ // Q9), which the model picks.+ if let note = model.releaseDaysNote {+ Text(note) .font(.caption) .foregroundStyle(AsterismColors.secondaryText) .fixedSize(horizontal: false, vertical: true)
diff --git a/Asterism/Asterism/Views/WorkStatusPresentation.swift b/Asterism/Asterism/Views/WorkStatusPresentation.swiftindex 5514fc26..6980725b 100644--- a/Asterism/Asterism/Views/WorkStatusPresentation.swift+++ b/Asterism/Asterism/Views/WorkStatusPresentation.swift@@ -69,17 +69,25 @@ nonisolated enum ReadingStatusPresentation { static func name(_ status: ReadingStatus) -> String { switch status { case .reading: "Reading"+ case .catchingUp: "Catching up" case .finished: "Finished" case .abandoned: "Abandoned" } } - /// Marked exactly where the verdict exists — `isDone` — because those are- /// the two states the reader has left behind them. `reading` is the default- /// and draws nothing (Reqs 4.3, 5.1, 5.2).+ /// Marked everywhere but the default: `reading` is where every work starts+ /// and draws nothing, and each of the other three carries its own symbol+ /// (Reqs 4.3, 5.1, 5.2).+ ///+ /// The glyph used to mean "done reading" and no longer does: `catchingUp`+ /// is marked too (`catch-up-mode` Q11), because a reader who is behind is+ /// in a state worth seeing on the row, and `isDone` is false for it. The+ /// forward arrow is the one thing it says — the reader is moving through a+ /// backlog — and it is used nowhere else in the app. static func systemImage(_ status: ReadingStatus) -> String? { switch status { case .reading: nil+ case .catchingUp: "forward.fill" case .finished: "checkmark" case .abandoned: "book.closed" }@@ -103,10 +111,11 @@ nonisolated enum ReadingStatusPresentation { /// — a placeholder vanishes the moment the reader types. /// /// Nil exactly where the field is absent: a work still being read has no- /// verdict to write yet.+ /// verdict to write yet, and a reader catching up is still reading. static func verdictPrompt(_ status: ReadingStatus) -> String? { switch status { case .reading: nil+ case .catchingUp: nil case .finished: "How was it?" case .abandoned: "Why did you stop?" }
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 5715f0c9..7f1450c7 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -853,6 +853,20 @@ enum WorksRowPresentation { static func siteIdentifier(_ hostname: String) -> String { "work-row-site-\(hostname)" }++ /// `work-and-reading-status` Req 5.2: an abandoned work is one the reader+ /// put down, so its row steps back out of the active library rather than+ /// leaving it.+ ///+ /// Abandoned-only, and stays so now that there is a fourth status: a reader+ /// catching up is still reading, so the row draws at full strength+ /// (`catch-up-mode`). Spelled here rather than inside the view so the rule+ /// is testable on its own. `WorksSort.abandonedLast` in `WorksListOptions`+ /// spells the same abandoned-only rule inline — a view model may not call+ /// into a Views type — and `WorksListOptionsTests` pins both.+ static func isKnockedDown(_ work: WorkSnapshot) -> Bool {+ work.readingStatus == .abandoned+ } } /// A single row displaying a Work summary.@@ -877,10 +891,8 @@ struct WorkRow: View { /// one has to know what that is. @Environment(\.locale) private var locale - /// `work-and-reading-status` Req 5.2: an abandoned work is one the reader- /// put down, so its row steps back out of the active library rather than- /// leaving it.- private var isAbandoned: Bool { work.readingStatus == .abandoned }+ /// Req 5.2's knock-down, from the table beside the row's other answers.+ private var isKnockedDown: Bool { WorksRowPresentation.isKnockedDown(work) } var body: some View { WorkRowHeader(@@ -894,7 +906,7 @@ struct WorkRow: View { typeDisplay: work.typeDisplay, // Req 5.2: the whole row steps back, the way an ignored teach chip // does — the design language's one knock-down, not a second amount.- isKnockedDown: isAbandoned,+ isKnockedDown: isKnockedDown, // Req 4.1: the picker names the membership hostnames, not only // their colours — a novel and a webtoon of the same title must not // be merged by accident, and two glyphs are not a sentence a reader
diff --git a/Asterism/AsterismTests/DuplicateSurfaceTests.swift b/Asterism/AsterismTests/DuplicateSurfaceTests.swiftindex 7e1111b0..5da69448 100644--- a/Asterism/AsterismTests/DuplicateSurfaceTests.swift+++ b/Asterism/AsterismTests/DuplicateSurfaceTests.swift@@ -201,7 +201,10 @@ struct DuplicateSurfaceTests { ReadingStatus.allCases.map { DuplicateResolutionView.readingStatusLine( Self.workVariant(readingStatus: $0), differing: [.readingStatus])- } == ["Reading: Reading", "Reading: Finished", "Reading: Abandoned"])+ } == [+ "Reading: Reading", "Reading: Catching up", "Reading: Finished",+ "Reading: Abandoned",+ ]) } // MARK: - The Work variant's release-day arm (V15, `update-schedule` Req 7.2)
diff --git a/Asterism/AsterismTests/TodayDisplayPlanTests.swift b/Asterism/AsterismTests/TodayDisplayPlanTests.swiftindex 38d80ae4..175e826c 100644--- a/Asterism/AsterismTests/TodayDisplayPlanTests.swift+++ b/Asterism/AsterismTests/TodayDisplayPlanTests.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import Foundation import Testing @testable import Asterism@@ -70,13 +71,23 @@ struct TodayDisplayPlanTests { readingStatus: .reading, releaseDays: days) } + /// One work the reader is behind on: `catchingUp`, whatever its work status+ /// and whatever days it carries (`catch-up-mode` Q3, Q8).+ private func catchingUp(+ _ title: String, on days: [ReleaseDay] = [], workStatus: WorkStatus = .finished+ ) -> WorkSnapshot {+ TestFixtures.makeWork(+ displayTitle: title, workStatus: workStatus, readingStatus: .catchingUp,+ releaseDays: days)+ }+ private func schedule(_ works: [WorkSnapshot]) -> TodaySchedule { TodaySchedule.build(works: works, calendar: Self.calendar()) } private func makePlan( _ presentation: TodayPresentation,- schedule: TodaySchedule = TodaySchedule(works: [], dueWeekdays: []),+ schedule: TodaySchedule = TodaySchedule(works: [], dueWeekdays: [], catchingUp: []), selectedDay: CivilDay? = nil, currentDay: CivilDay? = nil, actionable: Bool = false,@@ -473,6 +484,89 @@ struct TodayDisplayPlanTests { #expect(unpruned.day.noted?.rows.map(\.id) == [today.id]) #expect(unpruned.day.releases.map(\.id) == [due.id]) }++ // MARK: - The catch-up section (`catch-up-mode` Q4, Q5)++ /// The current day with a work on each side: three sections, in the order+ /// releases, catching up, notes. The catch-up header is never the first one+ /// drawn — the releases header is always above it — so Req 5.7's rule needs+ /// nothing new and the section is plainly violet.+ @Test("The current day draws three headers, the catch-up one after the releases")+ func theCurrentDayDrawsThreeSections() {+ let due = scheduled("Marrow Lane", on: [.sunday])+ let behind = catchingUp("Ember Road")+ let plan = makePlan(+ presentation([group(day, [row("a", on: day)])]),+ schedule: schedule([due, behind]))++ #expect(plan.day.placement == .releasesFirst)+ #expect(plan.day.releases.map(\.id) == [due.id])+ #expect(plan.day.catchingUp.map(\.id) == [behind.id])+ #expect(plan.drawsNotedHeader)++ let accents = TodaySectionAccents(+ placement: plan.day.placement, drawsNotedHeader: plan.drawsNotedHeader)+ #expect(accents.releasesIsFirst, "the catch-up header is never the first one")+ #expect(TodaySectionAccents.catchingUp == .violet)+ }++ /// A future day has no noted section at all (Req 5.4), and the catch-up+ /// section is there whatever the work's own release days say (Q8).+ @Test("A future day keeps the catch-up section and still has no noted one")+ func aFutureDayKeepsTheCatchUpSection() {+ let behind = catchingUp("Ember Road", on: [.sunday])+ let plan = makePlan(+ presentation([group(day, [row("a", on: day)])]),+ schedule: schedule([behind]), selectedDay: nextDay, currentDay: day)++ #expect(plan.day.noted == nil)+ #expect(plan.day.releases.isEmpty, "Q8: a catch-up work lights no release row")+ #expect(plan.day.catchingUp.map(\.id) == [behind.id])+ }++ /// Req 5.8's exception covers this section too: a library with nothing+ /// captured still shows what the reader is behind on.+ @Test("The empty library keeps the catch-up section")+ func theEmptyLibraryKeepsTheCatchUpSection() {+ let behind = catchingUp("Ember Road")+ let plan = makePlan(presentation([]), schedule: schedule([behind]))++ #expect(plan.branch == .emptyLibrary)+ #expect(plan.day.catchingUp.map(\.id) == [behind.id])+ }++ /// Q5: no rows means no section — not an empty one with a header and a dim+ /// line, which is what Req 5.6 gives the other two. Most days hold nothing+ /// to catch up on, and the view draws the section only while these rows+ /// exist, so an empty list is the whole of "hidden".+ @Test("A library with nobody to catch up on has no catch-up rows at all")+ func theSectionIsEmptyWithNothingToCatchUpOn() {+ let due = scheduled("Marrow Lane", on: [.sunday])+ let plan = makePlan(+ presentation([group(day, [row("a", on: day)])]), schedule: schedule([due]))++ #expect(plan.day.catchingUp.isEmpty)+ // …and a past day hides it even while the library holds catch-up works.+ let past = makePlan(+ presentation([group(previousDay, [row("a", on: previousDay)])]),+ schedule: schedule([due, catchingUp("Ember Road")]),+ selectedDay: previousDay, currentDay: day)+ #expect(past.day.catchingUp.isEmpty)+ }++ /// Req 10.1's prune: a work opened from a catch-up row is on screen as+ /// surely as one opened from a release row, so both sets of ids are in+ /// `works`.+ @Test("Visibility holds the catch-up rows beside the releases")+ func visibilityHoldsTheCatchUpRows() {+ let due = scheduled("Marrow Lane", on: [.sunday])+ let behind = catchingUp("Ember Road")+ let plan = makePlan(+ presentation([group(day, [row("a", on: day)])]),+ schedule: schedule([due, behind]), needsVisibility: true)++ #expect(plan.visibility.works == [due.id, behind.id])+ } } // MARK: - The screen's words@@ -505,6 +599,17 @@ struct TodayTitleTextTests { func theHeadersNameNeitherTheDayNorThePastTense() { #expect(TodayTitleText.releasesHeader == "Releasing") #expect(TodayTitleText.notedHeader == "Noted")+ #expect(TodayTitleText.catchingUpHeader == "Catching up")+ }++ /// `catch-up-mode` Q4: the two sections draw the same row, so their+ /// identifiers are what tells them apart — for a journey and for the prune+ /// that counts one of the two sets.+ @Test("The two row prefixes are distinct and neither contains the other")+ @MainActor func theRowPrefixesTellTheSectionsApart() {+ #expect(ReleaseRowView.releasePrefix == "today-release-")+ #expect(ReleaseRowView.catchUpPrefix == "today-catch-up-")+ #expect(!ReleaseRowView.catchUpPrefix.hasPrefix(ReleaseRowView.releasePrefix)) } /// Q76's other half: the **row** line still names the day, because a row is
diff --git a/Asterism/AsterismTests/TodayFixtureSeedingTests.swift b/Asterism/AsterismTests/TodayFixtureSeedingTests.swiftindex f8548fc7..717a8121 100644--- a/Asterism/AsterismTests/TodayFixtureSeedingTests.swift+++ b/Asterism/AsterismTests/TodayFixtureSeedingTests.swift@@ -8,8 +8,8 @@ import Testing /// /// `seedTodayFixture` is reachable from a launch only — `ASTERISM_UI_TEST_SCENARIO /// = seeded-today` — and the UI suites that will drive it are task 26's. Until-/// they land, nothing had ever executed it: a fixture that throws, seeds two-/// works instead of three, or dates every entry on one day would have been+/// they land, nothing had ever executed it: a fixture that throws, seeds three+/// works instead of four, or dates every entry on one day would have been /// discovered by a journey rather than by a test. /// /// So this drives the same path a launch does — the debug initializer, one@@ -18,24 +18,26 @@ import Testing /// about the Today screen: the screen's derivation is /// `TodayDayPlanTests`' subject in Core, and the one assertion here that touches /// it (`TodaySchedule.build`) is there because the fixture's whole point is that-/// two of its three works are scheduled and the third is not.+/// two of its four works are scheduled, one is kept off by its status and one+/// by being caught up on. @Suite("The today UI fixture, seeded") struct TodayFixtureSeedingTests { - /// The three works, their statuses and their days — everything the seeder's+ /// The four works, their statuses and their days — everything the seeder's /// table states, read back off the publication a launch would have.- @Test("Seeding `today` publishes three works whose days follow the launch clock")+ @Test("Seeding `today` publishes four works whose days follow the launch clock") @MainActor func seedsTheDocumentedShape() async throws { let library = try await SeededTodayLibrary() defer { library.removeRoot() } let calendar = library.calendar let works = library.model.worksSnapshot.works- #expect(works.count == 3)+ #expect(works.count == 4) let kite = try #require(works.first { $0.displayTitle == "Kite Season" }) let salt = try #require(works.first { $0.displayTitle == "Salt Harvest" }) let lantern = try #require(works.first { $0.displayTitle == "Paper Lantern" })+ let ember = try #require(works.first { $0.displayTitle == "Ember Road" }) // Req 6.1's exclusion needs one work that days alone would schedule and // a status keeps off the day.@@ -43,19 +45,25 @@ struct TodayFixtureSeedingTests { #expect(salt.workStatus == .ongoing) #expect(lantern.workStatus == .hiatus) #expect([kite, salt, lantern].allSatisfy { $0.readingStatus == .reading })+ // `catch-up-mode` Q3 and Q12: the catch-up work is finished and+ // unscheduled, so only the three scheduled-shape works read as+ // `reading` and the fourth is the section's whole content.+ #expect(ember.workStatus == .finished)+ #expect(ember.readingStatus == .catchingUp)+ #expect(ember.releaseDays.isEmpty) // The two civil days the fixture spans, taken off the rows rather than // off a second reading of the clock, so a run that straddles midnight // still describes the library it actually seeded. let days = library.civilDaysOfEveryEntry- #expect(days.count == 3)+ #expect(days.count == 4) let ordered = Set(days).sorted() #expect(ordered.count == 2, "two civil days: the current one and the one before") let previousDay = ordered[0] let currentDay = ordered[1] #expect(previousDay.adding(days: 1, calendar: calendar) == currentDay) #expect(days.filter { $0 == currentDay }.count == 2)- #expect(days.filter { $0 == previousDay }.count == 1)+ #expect(days.filter { $0 == previousDay }.count == 2) // The later of the two *is* the launch day: the seeder is the only thing // that dated these rows, and it dated two of them at its own `now`. #expect(library.launchDayCandidates.contains(currentDay))@@ -80,6 +88,8 @@ struct TodayFixtureSeedingTests { let schedule = TodaySchedule.build(works: works, calendar: calendar) #expect(schedule.works.map(\.title) == ["Kite Season", "Salt Harvest"]) #expect(schedule.dueWeekdays == Set([currentWeekday, previousWeekday]))+ // Q8: the catch-up work is its own list and adds no dot.+ #expect(schedule.catchingUp.map(\.title) == ["Ember Road"]) } /// Req 5.2's two furnished forms — `Noted today · {chapter line}` and@@ -92,16 +102,20 @@ struct TodayFixtureSeedingTests { defer { library.removeRoot() } let entries = library.model.worksSnapshot.works.flatMap(\.entries)- #expect(entries.count == 3)+ #expect(entries.count == 4) for entry in entries { let line = entry.chapterTitle ?? entry.chapterSequence #expect(line != nil, "\(entry.captureTitle) parsed no chapter line") } // The taught rule takes the last `" - "` segment as the work, so the- // chapter is what is left in front of it.- #expect(- Set(entries.compactMap(\.chapterTitle))- == ["Chapter 12", "Chapter 4", "Chapter 31"])+ // chapter is what is left in front of it. Salt Harvest and Ember Road+ // are both on chapter 4, so four entries carry three distinct lines —+ // which is why the count is pinned beside the set. A set of three no+ // longer says how many entries produced it, so on its own it would+ // still hold if one of the four parsed no chapter title at all.+ let chapterTitles = entries.compactMap(\.chapterTitle)+ #expect(chapterTitles.count == 4)+ #expect(Set(chapterTitles) == ["Chapter 12", "Chapter 4", "Chapter 31"]) // Teaching reparses every entry on the hostname, so this is also the // check that it re-attributed nothing: each entry is still under the
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex 33352a7b..e3c60f08 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -921,6 +921,32 @@ struct WorkDetailModelTests { #expect(mock.updateWorkCallCount == 0) } + /// `catch-up-mode` Q3: catching up has no work-status precondition — a+ /// finished story the reader is behind on is exactly the case — so the+ /// segment moves the draft on every work status and asks nothing.+ ///+ /// Q16: the revert and the restore move a draft between `reading` and+ /// `finished` only, so a catch-up draft is left where it is when the work+ /// status changes under it.+ @MainActor @Test(+ "Catching up needs no work status, raises no prompt, and survives a work-status change",+ arguments: [WorkStatus.ongoing, .hiatus, .finished])+ func catchingUpIsChosenFreely(work workStatus: WorkStatus) async {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: workStatus, readingStatus: .reading))+ await model.load()+ model.beginEditing()++ model.setDraftReadingStatus(.catchingUp)++ #expect(model.draftReadingStatus == .catchingUp)+ #expect(model.finishedReadingPrompt == nil)+ #expect(mock.updateWorkCallCount == 0)++ model.setDraftWorkStatus(workStatus == .finished ? .ongoing : .finished)+ #expect(model.draftReadingStatus == .catchingUp)+ }+ /// Req 3.5: a stored pair that violates the rule is shown as stored. Tapping /// the segment it is already on is not a change, so it must not ask. @MainActor @Test("Re-selecting the reading status a stored violating pair already has is a no-op")@@ -2365,11 +2391,11 @@ struct WorkDetailReleaseDaysTests { /// condition can be dropped without a case going red. @MainActor @Test("The note appears for every status pair but ongoing-and-reading") func theNoteFollowsTheTwoStatuses() async {- let cases: [(WorkStatus, ReadingStatus, Bool)] = [- (.ongoing, .reading, false),- (.hiatus, .reading, true),- (.ongoing, .finished, true),- (.finished, .abandoned, true),+ let cases: [(WorkStatus, ReadingStatus, String?)] = [+ (.ongoing, .reading, nil),+ (.hiatus, .reading, ReleaseDaysText.note),+ (.ongoing, .finished, ReleaseDaysText.note),+ (.finished, .abandoned, ReleaseDaysText.note), ] for (workStatus, readingStatus, expected) in cases { let (model, _) = makeSUT(@@ -2378,7 +2404,27 @@ struct WorkDetailReleaseDaysTests { releaseDays: [.monday])) await model.load() - #expect(model.showsReleaseDaysNote == expected)+ #expect(model.releaseDaysNote == expected)+ }+ }++ /// `catch-up-mode` Q9: a catching-up reader is told the days are parked+ /// rather than that the work is off Today, which it is not — whatever the+ /// work status says, because a finished story the reader is behind on is+ /// exactly the case (Q3).+ @MainActor @Test("A catching-up draft gets its own wording under every work status")+ func theNoteSaysTheDaysAreParkedWhileCatchingUp() async {+ for workStatus in WorkStatus.allCases {+ let (model, _) = makeSUT(+ work: TestFixtures.makeWork(+ workStatus: workStatus, readingStatus: .catchingUp,+ releaseDays: [.monday]))+ await model.load()++ #expect(+ model.releaseDaysNote == "Release days apply again once you're back to Reading",+ "\(workStatus)")+ #expect(model.releaseDaysNote == ReleaseDaysText.catchingUpNote, "\(workStatus)") } } @@ -2389,7 +2435,7 @@ struct WorkDetailReleaseDaysTests { func theNoteIsAbsentWithNoDayToggled() async { let cases: [(WorkStatus, ReadingStatus)] = [ (.ongoing, .reading), (.hiatus, .reading), (.ongoing, .finished),- (.finished, .abandoned),+ (.finished, .abandoned), (.ongoing, .catchingUp), ] for (workStatus, readingStatus) in cases { let (model, _) = makeSUT(@@ -2397,7 +2443,7 @@ struct WorkDetailReleaseDaysTests { workStatus: workStatus, readingStatus: readingStatus)) await model.load() - #expect(!model.showsReleaseDaysNote)+ #expect(model.releaseDaysNote == nil) } } @@ -2408,13 +2454,17 @@ struct WorkDetailReleaseDaysTests { let (model, _) = makeSUT(work: TestFixtures.makeWork()) await model.load() model.beginEditing()- #expect(!model.showsReleaseDaysNote)+ #expect(model.releaseDaysNote == nil) model.draftReleaseDays = [.monday]- #expect(!model.showsReleaseDaysNote)+ #expect(model.releaseDaysNote == nil) model.setDraftWorkStatus(.hiatus)- #expect(model.showsReleaseDaysNote)+ #expect(model.releaseDaysNote == ReleaseDaysText.note)++ // And the wording follows the reading-status draft the same way.+ model.setDraftReadingStatus(.catchingUp)+ #expect(model.releaseDaysNote == ReleaseDaysText.catchingUpNote) } }
diff --git a/Asterism/AsterismTests/WorkStatusPresentationTests.swift b/Asterism/AsterismTests/WorkStatusPresentationTests.swiftindex be38c162..50f07ac6 100644--- a/Asterism/AsterismTests/WorkStatusPresentationTests.swift+++ b/Asterism/AsterismTests/WorkStatusPresentationTests.swift@@ -21,14 +21,17 @@ struct WorkStatusPresentationTests { == ["Ongoing", "Finished", "On hiatus"]) #expect( ReadingStatus.allCases.map(ReadingStatusPresentation.name)- == ["Reading", "Finished", "Abandoned"])+ == ["Reading", "Catching up", "Finished", "Abandoned"]) } // MARK: - Glyphs (Reqs 4.3, 5.1, 5.2, Q27) - /// The four symbols of Q27, and the two values that draw nothing: a work- /// still running and a reader still reading are the defaults, and a glyph- /// on every row would say nothing.+ /// The five symbols, and the two values that draw nothing: a work still+ /// running and a reader still reading are the defaults, and a glyph on+ /// every row would say nothing.+ ///+ /// A mark no longer means "done reading": `catchingUp` carries one and is+ /// not done (`catch-up-mode` Q11). @Test("Only the non-default values carry a glyph, and each carries its own") func glyphsAreDistinctAndDefaultsHaveNone() { #expect(WorkStatusPresentation.systemImage(.ongoing) == nil)@@ -36,6 +39,7 @@ struct WorkStatusPresentationTests { #expect(WorkStatusPresentation.systemImage(.hiatus) == "pause.circle") #expect(ReadingStatusPresentation.systemImage(.reading) == nil)+ #expect(ReadingStatusPresentation.systemImage(.catchingUp) == "forward.fill") #expect(ReadingStatusPresentation.systemImage(.finished) == "checkmark") #expect(ReadingStatusPresentation.systemImage(.abandoned) == "book.closed") @@ -44,7 +48,7 @@ struct WorkStatusPresentationTests { let symbols = WorkStatus.allCases.compactMap(WorkStatusPresentation.systemImage) + ReadingStatus.allCases.compactMap(ReadingStatusPresentation.systemImage)- #expect(Set(symbols).count == 4)+ #expect(Set(symbols).count == 5) } /// Q27: no third hue. Work glyphs take the type tag's violet, reading glyphs@@ -71,7 +75,10 @@ struct WorkStatusPresentationTests { == ["Work: Ongoing", "Work: Finished", "Work: On hiatus"]) #expect( ReadingStatus.allCases.map(ReadingStatusPresentation.accessibilityLabel)- == ["Reading: Reading", "Reading: Finished", "Reading: Abandoned"])+ == [+ "Reading: Reading", "Reading: Catching up", "Reading: Finished",+ "Reading: Abandoned",+ ]) } // MARK: - Verdict prompts (Req 2.3, Q16)@@ -82,6 +89,9 @@ struct WorkStatusPresentationTests { @Test("Each done-reading value has its own prompt, and reading has none") func verdictPromptsPerCase() { #expect(ReadingStatusPresentation.verdictPrompt(.reading) == nil)+ // A reader catching up is still reading, so there is nothing to write+ // a verdict about yet (`catch-up-mode`).+ #expect(ReadingStatusPresentation.verdictPrompt(.catchingUp) == nil) #expect(ReadingStatusPresentation.verdictPrompt(.finished) == "How was it?") #expect(ReadingStatusPresentation.verdictPrompt(.abandoned) == "Why did you stop?") // The prompt exists exactly where the field does (Req 2.3).
diff --git a/Asterism/AsterismTests/WorksFilterPresentationTests.swift b/Asterism/AsterismTests/WorksFilterPresentationTests.swiftindex 173b84f2..1e54b232 100644--- a/Asterism/AsterismTests/WorksFilterPresentationTests.swift+++ b/Asterism/AsterismTests/WorksFilterPresentationTests.swift@@ -164,6 +164,7 @@ struct WorksFilterPresentationTests { #expect( ReadingStatus.allCases.map(WorksFilterPresentation.readingStatusRowIdentifier) == [ "works-filter-reading-status-reading",+ "works-filter-reading-status-catchingUp", "works-filter-reading-status-finished", "works-filter-reading-status-abandoned", ])
diff --git a/Asterism/AsterismTests/WorksListOptionsTests.swift b/Asterism/AsterismTests/WorksListOptionsTests.swiftindex 051af0ba..e5c78b10 100644--- a/Asterism/AsterismTests/WorksListOptionsTests.swift+++ b/Asterism/AsterismTests/WorksListOptionsTests.swift@@ -192,6 +192,26 @@ struct WorksSortTests { let works = [finished, abandoned(2, "B"), work(3, "C")] #expect(WorksSort.newest.apply(to: works).map(\.id) == [id(1), id(3), id(2)]) }++ /// `catch-up-mode`: a reader who is behind is still reading, so the row+ /// stays where the sort put it and draws at full strength. Only `abandoned`+ /// steps back (Req 5.2), and only `abandoned` sorts last (Req 5.3).+ @Test("A catching-up work sorts and draws as an active one")+ @MainActor func catchingUpIsAnActiveWork() {+ let catchingUp = TestFixtures.makeWork(+ id: id(2), displayTitle: "B", entries: [TestFixtures.makeEntry(workID: id(2))],+ readingStatus: .catchingUp)+ let works = [work(1, "A"), catchingUp, abandoned(3, "C")]+ for sort in WorksSort.allCases {+ #expect(sort.apply(to: works).map(\.id).last == id(3), "\(sort)")+ #expect(sort.apply(to: works).map(\.id).contains(id(2)), "\(sort)")+ }+ // Order preserved among the two active works under the list's default.+ #expect(WorksSort.newest.apply(to: works).map(\.id) == [id(1), id(2), id(3)])+ // And the row's knock-down is the same abandoned-only predicate.+ #expect(WorksRowPresentation.isKnockedDown(catchingUp) == false)+ #expect(WorksRowPresentation.isKnockedDown(abandoned(3, "C")))+ } } @Suite("Works filter")
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 53ad863e..7d90475e 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -363,6 +363,9 @@ final class AccessibilityJourneyUITests: XCTestCase { for identifier in [ "work-detail-work-status-ongoing", "work-detail-work-status-finished", "work-detail-work-status-hiatus", "work-detail-reading-status-reading",+ // `catch-up-mode` Q14: the fourth segment, at the size where the+ // capsule is most likely to have stacked.+ "work-detail-reading-status-catchingUp", "work-detail-reading-status-abandoned", ] { let segment = app.buttons[identifier]
diff --git a/Asterism/AsterismUITests/TodayUITests.swift b/Asterism/AsterismUITests/TodayUITests.swiftindex 8f26c2ef..f298802e 100644--- a/Asterism/AsterismUITests/TodayUITests.swift+++ b/Asterism/AsterismUITests/TodayUITests.swift@@ -9,13 +9,15 @@ import XCTest /// still the untaught single-entry fixture they were written for. /// /// What the rename adds is the day itself (Reqs 3.1, 3.3, 3.5, 3.6, 4.1–4.5,-/// 5.3, 5.5, 5.6, 5.8, 10.3), over `seeded-today`: three works on `today.test`-/// carrying release days across the current weekday and the one before it —-/// **Kite Season** due today, **Salt Harvest** due the previous weekday, and-/// **Paper Lantern** on hiatus with both days, so it is scheduled by its days-/// and excluded by its status on both of them at once. Two entries are dated-/// the current day and one the previous day, and the site is taught, so every-/// row has a chapter line.+/// 5.3, 5.5, 5.6, 5.8, 10.3), over `seeded-today`: four works on `today.test`,+/// three of them carrying release days across the current weekday and the one+/// before it — **Kite Season** due today, **Salt Harvest** due the previous+/// weekday, and **Paper Lantern** on hiatus with both days, so it is scheduled+/// by its days and excluded by its status on both of them at once. The fourth,+/// **Ember Road**, is finished and being caught up on with no days at all+/// (`catch-up-mode`), which is what the `Catching up` section lists. Two+/// entries are dated the current day and two the previous day, and the site is+/// taught, so every row has a chapter line. /// /// **The launch clock is what the fixture is built from**, so every day this /// suite names is an offset from the clock rather than a literal date. The@@ -85,6 +87,12 @@ final class TodayUITests: XCTestCase { app.elements(withIdentifierPrefix: "today-release-") } + /// The other section's rows (`catch-up-mode` Q4). The two prefixes are+ /// disjoint, which is why the counts above are unmoved by a catch-up work.+ private var catchUpRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "today-catch-up-")+ }+ // MARK: - Days, and reaching them /// `yyyy-MM-dd`, which is what `CivilDay.description` spells and therefore@@ -632,6 +640,49 @@ final class TodayUITests: XCTestCase { XCTAssertTrue( recentRows.element(boundBy: 1).label.contains("Kite Season"), "…and the earlier one follows it")++ // `catch-up-mode`: the third section, after the releases and before the+ // notes, with the one work the reader is behind on.+ assertHeader(+ "today-section-catching-up", says: "Catching up",+ "The catch-up section is headed on the current day")+ XCTAssertLessThan(+ app.anyElement("today-section-releases").frame.minY,+ app.anyElement("today-section-catching-up").frame.minY,+ "Q4: it sits directly after the releases section")+ XCTAssertLessThan(+ app.anyElement("today-section-catching-up").frame.minY,+ app.anyElement("today-section-noted").frame.minY,+ "…and before the notes")++ XCTAssertEqual(catchUpRows.count, 1, "Only Ember Road is being caught up on")+ let catchUp = catchUpRows.firstMatch+ XCTAssertTrue(+ catchUp.label.hasPrefix("Ember Road, Last noted"),+ "Q13: the catch-up row reads with the release row's second line — "+ + "was \"\(catchUp.label)\"")+ XCTAssertTrue(+ catchUp.label.contains("yesterday"),+ "…measured against the day being shown — was \"\(catchUp.label)\"")+ waitFor(+ app.anyElement("today-catch-up-line"),+ "Q4: the line carries the section's own identifier prefix")+ XCTAssertEqual(+ releaseRows.count, 1,+ "Q8: a catch-up work lights no release row, and the prefixes are disjoint")+ }++ /// Q5's other half: the section is a standing reminder, so a past day — which+ /// has nothing to record about it — draws it not at all, header included.+ func testAPastDayDrawsNoCatchUpSection() {+ launchToday()+ selectDay(daysFromLaunch: -1, "The previous day is on the strip, or one chevron back")+ waitFor(app.anyElement("today-section-releases"), "The past day draws its own sections")++ XCTAssertFalse(+ app.anyElement("today-section-catching-up").exists,+ "Q5: no catch-up header on a past day")+ XCTAssertEqual(catchUpRows.count, 0, "…and no rows either") } /// Req 5.5's other placement: a past day reads as a record, so the notes come@@ -659,13 +710,18 @@ final class TodayUITests: XCTestCase { releaseRows.firstMatch.label.contains("Paper Lantern"), "Req 6.1: the work on hiatus is excluded on this day too") - let settled = expectation(for: NSPredicate(format: "count == 1"), evaluatedWith: recentRows)+ let settled = expectation(for: NSPredicate(format: "count == 2"), evaluatedWith: recentRows) XCTAssertEqual( XCTWaiter().wait(for: [settled], timeout: 15), .completed,- "Req 5.4: the previous day holds the one entry dated on it")+ "Req 5.4: the previous day holds the two entries dated on it")+ XCTAssertTrue(+ recentRows.element(boundBy: 0).label.contains("Paper Lantern"),+ "Recent's ordering: the later of the two leads (Q17) — "+ + "was \"\(recentRows.element(boundBy: 0).label)\"") XCTAssertTrue(- recentRows.firstMatch.label.contains("Paper Lantern"),- "…which is Paper Lantern's — was \"\(recentRows.firstMatch.label)\"")+ recentRows.element(boundBy: 1).label.contains("Ember Road"),+ "…and the catch-up work's note follows it — "+ + "was \"\(recentRows.element(boundBy: 1).label)\"") } /// Req 5.6: a shown section with no rows keeps its header and says so in one@@ -793,7 +849,7 @@ final class TodayUITests: XCTestCase { /// /// The tap is a **chapter** row rather than a related work, and the task /// asked for a related work. The `today` fixture seeds no `WorkLink` — its- /// three works are one capture and one work each — and a work with no links+ /// four works are one capture and one work each — and a work with no links /// draws no related-works section at all (`series-and-related-works`), so /// that row is unreachable from this launch. The chapter route is the same /// mechanism under the same claim: `AppScreens.route(_:on:)` captures@@ -841,6 +897,6 @@ final class TodayUITests: XCTestCase { let settled = expectation(for: NSPredicate(format: "count == 2"), evaluatedWith: recentRows) XCTAssertEqual( XCTWaiter().wait(for: [settled], timeout: 15), .completed,- "…and two of the three entries are dated the current day")+ "…and two of the four entries are dated the current day") } }
diff --git a/Asterism/AsterismUITests/WideLayoutUITests.swift b/Asterism/AsterismUITests/WideLayoutUITests.swiftindex ab6b6bde..3fefc96c 100644--- a/Asterism/AsterismUITests/WideLayoutUITests.swift+++ b/Asterism/AsterismUITests/WideLayoutUITests.swift@@ -974,6 +974,12 @@ final class WideLayoutUITests: XCTestCase { app.elements(withIdentifierPrefix: "today-release-") } + /// The catch-up section's rows (`catch-up-mode` Q4), whose prefix is+ /// deliberately not the releases one.+ private var catchUpRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "today-catch-up-")+ }+ private var stripCells: XCUIElementQuery { app.elements(withIdentifierPrefix: "today-strip-") }@@ -1067,6 +1073,41 @@ final class WideLayoutUITests: XCTestCase { "…and the column goes back to its placeholder") } + /// Req 10.1 over the other section's rows (`catch-up-mode`): a catch-up row+ /// opens its work into the detail column exactly as a release row does, and+ /// leaves the column when the row stops being listed.+ ///+ /// The day change is what removes it: the section is drawn on the current+ /// day and on future ones only (Q5), so stepping back three days takes the+ /// row off the screen by construction rather than by timing.+ func testACatchUpRowTakesTheDetailColumnAndADayChangeClearsIt() {+ launchToday()++ let catchUp = waitFor(catchUpRows.firstMatch, "The current day lists the catch-up work")+ catchUp.tap()++ let detailColumn = waitFor(+ app.anyElement("wide-detail-column"), "The detail column is laid out")+ let work = waitFor(+ app.anyElement("work-detail-pulse"),+ "Req 10.1: a catch-up row fills the column with its work", timeout: 20)+ assertInsideColumn(work, column: detailColumn, what: "The opened work (Req 10.1)")+ waitFor(app.collectionViews["recent-list"], "The day stays beside the column")++ let target = stripCell(daysFromLaunch: -3)+ if !target.exists {+ waitFor(app.anyElement("today-week-previous"), "The week row pages back").tap()+ }+ waitFor(target, "A past day, where the section is not drawn at all").tap()++ waitUntilGone(+ app.anyElement("work-detail-pulse"),+ "Req 10.1: the work leaves the column once its row is no longer listed", timeout: 20)+ waitFor(+ app.anyElement("wide-detail-placeholder"),+ "…and the column goes back to its placeholder")+ }+ /// The design's one Risk entry, measured: seven 36 pt cells and their gaps /// have to share the list column at every width the wide layouts give it. ///
diff --git a/Asterism/AsterismUITests/WorkDetailStatusUITests.swift b/Asterism/AsterismUITests/WorkDetailStatusUITests.swiftindex 3d20023e..c003458b 100644--- a/Asterism/AsterismUITests/WorkDetailStatusUITests.swift+++ b/Asterism/AsterismUITests/WorkDetailStatusUITests.swift@@ -413,6 +413,14 @@ final class WorkDetailStatusUITests: XCTestCase { /// Quill Harbour is the work on both defaults and carries no days, which is /// the state the line must stay away from: nothing to explain while no day /// is on, and nothing to explain while the work would be listed on Today.+ ///+ /// The middle of the journey is `catch-up-mode` Q9's third wording: with a+ /// day on and the line already showing, the reading status is taken to+ /// `Catching up` and the line must change words rather than disappear — the+ /// work *is* on Today, in its own section, and it is the days that are+ /// parked. Taking the status back to `Reading` brings the first wording+ /// back, which is what makes this a wording assertion and not just a+ /// presence one. func testTheReleaseDaysNoteFollowsTheDraftStatuses() { launch() openWork("Quill Harbour")@@ -438,6 +446,22 @@ final class WorkDetailStatusUITests: XCTestCase { "Req 1.4: a day on and a draft status that keeps the work off Today explains itself") XCTAssertEqual(shown.label, releaseDaysNote, "…in the words the requirement names") + // `catch-up-mode` Q9: the same day is still on, and a catching-up draft+ // takes the other wording in place of the line above.+ scrollUntilTappableAndTap(+ app.buttons["work-detail-reading-status-catchingUp"], in: app,+ "The reading-status control offers Catching up")+ assertNoteReads(+ catchingUpNote,+ "Q9: a catching-up draft says the days are parked, not that the work is off Today")++ scrollUntilTappableAndTap(+ app.buttons["work-detail-reading-status-reading"], in: app,+ "The control takes the reading status back to Reading")+ assertNoteReads(+ releaseDaysNote,+ "Q9: and the existing wording returns, the work being off Today again")+ scrollUntilTappableAndTap( app.buttons["work-detail-work-status-ongoing"], in: app, "The capsule takes the work back to ongoing")@@ -486,6 +510,28 @@ final class WorkDetailStatusUITests: XCTestCase { private let releaseDaysNote = "Shown on Today only while the work is ongoing and you are reading it" + /// `catch-up-mode` Q9's line, likewise spelled out rather than imported:+ /// the point of asserting it here is that the words the reader sees are the+ /// words the decision names, which a shared constant could not prove.+ private let catchingUpNote = "Release days apply again once you're back to Reading"++ /// The note exists and reads exactly `text`.+ ///+ /// Waited on rather than read once: the two wordings replace each other in+ /// place, so the element never goes away and there is no appearance to wait+ /// for — only a label that changes a frame or two after the tap.+ private func assertNoteReads(+ _ text: String, _ message: String, file: StaticString = #filePath, line: UInt = #line+ ) {+ let note = app.anyElement("work-detail-release-days-note")+ let reads = XCTNSPredicateExpectation(+ predicate: NSPredicate(format: "label == %@", text), object: note)+ XCTAssertEqual(+ XCTWaiter().wait(for: [reads], timeout: 10), .completed,+ "\(message) — the line reads “\(note.exists ? note.label : "nothing")”",+ file: file, line: line)+ }+ /// The stored spellings of the seven days — the toggles' identifiers /// (`ReleaseDayToggles.identifier`) beside the Foundation weekday number /// that indexes the **Sunday-first** symbol arrays.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 53366063..5126d393 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -125,6 +125,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Catch-up mode: a `Catching up` reading status and a `Catching up`+ section on Today (`specs/catch-up-mode/`).** A reader behind on a story+ has no reason to wait for its release day. The reading status control+ gains a fourth value, `Catching up`, `forward.fill` in the cyan+ reading hue, read out as `Reading: Catching up`, shown wherever the+ other statuses are: the editor, the detail meta line, the Works row+ and its filter menu and pill, the duplicate review card and the merge+ preview. It is not done reading, so the verdict stays hidden, choosing+ it needs no work-status precondition, and a catch-up row sorts and+ draws as an active row rather than an abandoned one; a finished work+ can be caught up on (Q3). Today draws every catch-up work in a+ `Catching up` section directly after the releases, on the current day+ and future days only, each row shaped as a release row with the same+ second line and its own `today-catch-up-` identifier prefix, and the+ section is not drawn at all when no work is catching up (Q4, Q5). A+ catch-up work is not a scheduled work: its release days add no release+ row and no strip dot until the status returns to `Reading` (Q8), and+ while any day is toggled the line under the toggles says so:+ `Release days apply again once you're back to Reading` (Q9). In the+ wide layouts a catch-up row selects its work into the detail column+ and a day change clears it. The `today` UI fixture gains Ember Road, a+ finished work being caught up on with an entry on the previous day+ (Q12, Q17), and the journeys prove the section on the current day, its+ absence on a past day, and the fourth segment at largest Dynamic Type.+ Core and the unit bundle are green; `make test-ui` and `make+ test-ui-ipad` carry four failures that reproduce verbatim on `main`'s+ tip from the day before. Two of them sit on the Status card this+ feature does change, so all four are unexplained rather than+ attributed (`verification-run.md`, phase 2), and none is fixed here.++- **Catch-up mode, phase 1: the `catchingUp` reading status in Core+ (`specs/catch-up-mode/`).** `ReadingStatus` gains a fourth case,+ `catchingUp`, ordered second after `reading` and not done (Q2, Q11),+ stored in the existing raw-string column so nothing in the schema or+ the 14/15 archive pair moves; a round trip through the archive proves+ the value survives export and import unchanged (Q6). The duplicate+ phase treats it as authored content: a group of one `catchingUp` row+ and one `reading` row resolves silently to `catchingUp`, a group+ disagreeing between it and a done status goes to review naming the+ reading status, and a merge keeps the target's status and lists a+ differing source `catchingUp` as discarded. The convergence carry+ lets a `catchingUp` carrier write only over a sibling still on+ `reading` (Q10; the reverse direction is left as it stands for the two+ done states, Q15). `TodaySchedule` and `TodayDayPlan` carry a+ `catchingUp` list beside the releases: every work on that status, in+ title order, on the current day and future days and never on a past+ day, shaped as a release row with the same second-line rule, while a+ catch-up work's release days add no release row and no strip dot+ (Q3, Q5, Q8). The M4 fixture marks 66 unscheduled base works as+ catching up, pinned, and the `todayPublication` arm asserts the count+ and measures 0.84 s against its 2 s budget on a loaded host, inside+ the `update-schedule` band; the other arms were not re-measured+ against the changed fixture (`verification-run.md`). At the end of+ this phase the app target did not compile, three exhaustive switches+ in `ReadingStatusPresentation` short; the phase 2 entry above closes+ them.+ - **Update schedule, verification: the suites, three performance runs, and two fixture bugs the run exposed (`specs/update-schedule/`).** `specs/update-schedule/verification-run.md` records the close: core at
diff --git a/CLAUDE.md b/CLAUDE.mdindex 4d0b2831..720ba6fa 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -68,7 +68,7 @@ invocations where a target exists. - `make test-quick` — unit-test bundle only (simulator), preceded by `build-mac`: a macOS compile failure fails it (Req 9.1). The Mac build is never installed or launched. `SKIP_MAC=1` drops that dependency loudly and owes a clean `make build-mac` before the push. - `make test` / `make test-ui` — full suites (simulator, iPhone); they skip the iPad-only suites by name - `make test-ui-ipad` — the wide-layout and wide-layout-accessibility suites on `IPAD_SIMULATOR` (simulator, safe)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **ten** since `work-thumbnails` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`, nine after `work-creators`), and **eleven on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12** → 0.158–0.164 s at V13 on a quieter host, the one on a path the reader waits on; V12's three new tables and V13's two are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s), V12 did too (0.0308 s) and so did V13 (0.0302 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0100–0.0112 s over 500 links — the low end is `place-extraction`'s run at 0.010032 s, 0.3% over the budget and the closest it has come to fitting, which would turn a quiet host into a *second* way to be red: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0591–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent eleventh is that spec's `creator-converge-noop`, in budget at 0.0081–0.0100 s against 10 ms across five samples and never more than 19% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). The tenth is `work-thumbnails` Q80's **`backup-export-thumbnails` peak**: the real exporter over the covered fixture writes a **51.1 MB** archive and peaks **330–462 MB** over its baseline, past the 400 MB Q61 accepted from an estimate, recorded rather than re-based, with a 600 MB regression ceiling asserted outside the block. It is deliberately **not** `isIntermittent`, which means a run whose peak lands *under* 400 MB fails the target with `Known issue was not recorded` — that fired for the first time on 2026-09-13, on a memory-pressured host at 330.2 MB, and a lower peak there is the allocator reclaiming sooner, not the export getting cheaper (`specs/work-thumbnails/verification-run.md` §2.2). Its sibling `backup-import-thumbnails` **meets** the 400 MB plainly at 63.5–159.2 MB, with a settled baseline of 103–144 MB over five runs on one host (`specs/work-thumbnails/verification-run.md` §4.1), and `thumbnail-bytes-read` (fifty on-demand cover reads, Req 7.2) is reported only, 0.032–0.231 s under the 3 s class ceiling. Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests` and `work-creators` adds `M4CreatorScalePerformanceTests`, so the suite count is 7 and the test count **45** since `update-schedule` added `todayPublication` — Req 10.5's Today composition arm, one more test in `M4ToleratedScalePerformanceTests` and no new suite — on top of the 44 `work-thumbnails` reached by adding three arms and no suite — `thumbnail-bytes-read`, `backup-export-thumbnails` and `backup-import-thumbnails`, all three in `M4DuplicateScalePerformanceTests` and all three reported rather than budgeted, together worth 2.5–4 minutes of the run. It was 41 at `place-extraction`, measured as one run at **1,070 s**; the three runs made on the `work-thumbnails` branch measured **1,896 s, 2,683 s and 5,037 s** on a machine that was never quiet, so they set no band and moved none — two of them disagree with each other by 1.7× on the same arms, and an arm that never opens a store doubled (`specs/work-thumbnails/verification-run.md` §2.3). **A quiet-host run of the covered fixture is still owed.** That arm is `place-ranking-200x50` at **0.0035 s** against a 10 ms budget and a 50 ms ceiling, recorded beside its sibling `character-ranking-200x50` at **0.0037 s** — the ranker is one generic implementation over `RecordRow` and the point of the arm is that the second conformance costs what the first does. The character arm's own number moved up from 0.0023–0.0025 s in the same change, which is the cost of `CharacterRanking` becoming `RecordRanking`; read 0.0037 s as its new resting place, not as a regression. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-thumbnails/verification-run.md` for the current numbers and for what a contended host does to all of them, `specs/place-extraction/verification-run.md` for the last quiet-host band, `specs/work-creators/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/series-and-related-works/verification-run.md` for the ones before those, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **ten** since `work-thumbnails` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`, nine after `work-creators`), and **eleven on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12** → 0.158–0.164 s at V13 on a quieter host, the one on a path the reader waits on; V12's three new tables and V13's two are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s), V12 did too (0.0308 s) and so did V13 (0.0302 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0100–0.0112 s over 500 links — the low end is `place-extraction`'s run at 0.010032 s, 0.3% over the budget and the closest it has come to fitting, which would turn a quiet host into a *second* way to be red: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0591–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent eleventh is that spec's `creator-converge-noop`, in budget at 0.0081–0.0100 s against 10 ms across five samples and never more than 19% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). The tenth is `work-thumbnails` Q80's **`backup-export-thumbnails` peak**: the real exporter over the covered fixture writes a **51.1 MB** archive and peaks **330–462 MB** over its baseline, past the 400 MB Q61 accepted from an estimate, recorded rather than re-based, with a 600 MB regression ceiling asserted outside the block. It is deliberately **not** `isIntermittent`, which means a run whose peak lands *under* 400 MB fails the target with `Known issue was not recorded` — that fired for the first time on 2026-09-13, on a memory-pressured host at 330.2 MB, and a lower peak there is the allocator reclaiming sooner, not the export getting cheaper (`specs/work-thumbnails/verification-run.md` §2.2). Its sibling `backup-import-thumbnails` **meets** the 400 MB plainly at 63.5–159.2 MB, with a settled baseline of 103–144 MB over five runs on one host (`specs/work-thumbnails/verification-run.md` §4.1), and `thumbnail-bytes-read` (fifty on-demand cover reads, Req 7.2) is reported only, 0.032–0.231 s under the 3 s class ceiling. Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests` and `work-creators` adds `M4CreatorScalePerformanceTests`, so the suite count is 7 and the test count **45** since `update-schedule` added `todayPublication` — Req 10.5's Today composition arm, one more test in `M4ToleratedScalePerformanceTests` and no new suite — on top of the 44 `work-thumbnails` reached by adding three arms and no suite — `thumbnail-bytes-read`, `backup-export-thumbnails` and `backup-import-thumbnails`, all three in `M4DuplicateScalePerformanceTests` and all three reported rather than budgeted, together worth 2.5–4 minutes of the run. It was 41 at `place-extraction`, measured as one run at **1,070 s**; the three runs made on the `work-thumbnails` branch measured **1,896 s, 2,683 s and 5,037 s** on a machine that was never quiet, so they set no band and moved none — two of them disagree with each other by 1.7× on the same arms, and an arm that never opens a store doubled (`specs/work-thumbnails/verification-run.md` §2.3). **A quiet-host run of the covered fixture is still owed**, and since `catch-up-mode` that fixture also holds 66 base works on `catchingUp`, which `WorkAuthoredContent.isBare` counts as authored, so every band below the `todayPublication` arm (0.84 s on a loaded host, `specs/catch-up-mode/verification-run.md`) is unverified against the fixture as it now stands. That arm is `place-ranking-200x50` at **0.0035 s** against a 10 ms budget and a 50 ms ceiling, recorded beside its sibling `character-ranking-200x50` at **0.0037 s** — the ranker is one generic implementation over `RecordRow` and the point of the arm is that the second conformance costs what the first does. The character arm's own number moved up from 0.0023–0.0025 s in the same change, which is the cost of `CharacterRanking` becoming `RecordRanking`; read 0.0037 s as its new resting place, not as a regression. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-thumbnails/verification-run.md` for the current numbers and for what a contended host does to all of them, `specs/place-extraction/verification-run.md` for the last quiet-host band, `specs/work-creators/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/series-and-related-works/verification-run.md` for the ones before those, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above**
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swiftindex 2463bfdf..d9a2f0f8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift@@ -51,16 +51,25 @@ public enum WorkStatus: String, CaseIterable, Codable, Sendable { /// guarantee: two devices can each make a valid edit that together violate it, /// so a stored violating pair is displayed as stored (Req 3.5). ///+/// `catchingUp` is the reader being behind on a work they are still reading:+/// every day is a day they can read it, so the work's release days are parked+/// while it holds and take effect again when the status returns to `reading`+/// (`catch-up-mode` Q2, Q8). It is a variant of `reading` rather than a fourth+/// terminal state, which is why it sits second (Q11) — the case order is+/// display order only, and the stored value is the raw string.+/// /// Same freezing rules as `WorkStatus`: the order is Req 6.1's, the raw values /// are `Work.readingStatusRaw`'s stored spellings. public enum ReadingStatus: String, CaseIterable, Codable, Sendable { case reading+ case catchingUp case finished case abandoned /// "Done reading" — the one derived predicate over this enum. Everything /// that shows or hides the verdict reads it, so the two-case test is- /// spelled once rather than at every surface.+ /// spelled once rather than at every surface. `catchingUp` is not done:+ /// the verdict stays hidden for it exactly as it does for `reading`. public var isDone: Bool { self == .finished || self == .abandoned } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex df278709..95f19739 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -1751,7 +1751,20 @@ enum DuplicateReconciler { row.workStatus = carriedWorkStatus changed = true }- if carriedReadingStatus != .reading, row.readingStatus != carriedReadingStatus {+ // `catch-up-mode` Q10 narrows the reading status by one step. A+ // carrier on `catchingUp` is authored content like any other, but it+ // is a variant of `reading` rather than a done state: a group+ // disagreeing between it and `finished` or `abandoned` is two+ // variants and went to the reader before this write was reached, so+ // the only sibling a `catchingUp` carrier may write over here is one+ // still on the default. Same shape as the guard above, and for the+ // same reason — the scan and the write read the rows at two+ // different moments.+ let carryMayOverwrite =+ carriedReadingStatus != .catchingUp || row.readingStatus == .reading+ if carriedReadingStatus != .reading, row.readingStatus != carriedReadingStatus,+ carryMayOverwrite+ { row.readingStatus = carriedReadingStatus changed = true }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swiftindex a72917b7..d0123951 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift@@ -170,9 +170,10 @@ extension LibraryRepository { try await seedM4ThumbnailLayer() } - /// Gives every third base Work release days and moves every tenth base- /// Entry's `lastSharedAt` forward a day, over an already-seeded M4- /// performance fixture (`update-schedule` Req 10.5).+ /// Gives every third base Work release days, marks 66 of the rest as caught+ /// up on, and moves every tenth base Entry's `lastSharedAt` forward a day,+ /// over an already-seeded M4 performance fixture (`update-schedule`+ /// Req 10.5, `catch-up-mode` Q12). /// /// **Written straight through `saveStrategy.save`**, as the cover and /// tolerated-state layers are: `updateWork` is the writer a reader goes@@ -211,11 +212,21 @@ extension LibraryRepository { """ ) }- for (index, work) in works.enumerated()- where index.isMultiple(of: Self.m4FixtureScheduleStride) {- let ordinal = index / Self.m4FixtureScheduleStride- work.releaseDays =- Self.m4FixtureReleaseDayTable[ordinal % Self.m4FixtureReleaseDayTable.count]+ // One walk, two layers. The second arm is `catch-up-mode` Q12: 66 of+ // the works the release-day arm leaves alone are caught up on, so the+ // `todayPublication` arm builds a Catching up section rather than+ // passing with an empty one. `else` is an economy only — the+ // predicate already excludes every scheduled index, which is what+ // makes the two layers disjoint by construction.+ for (index, work) in works.enumerated() {+ if index.isMultiple(of: Self.m4FixtureScheduleStride) {+ let ordinal = index / Self.m4FixtureScheduleStride+ work.releaseDays =+ Self.m4FixtureReleaseDayTable[+ ordinal % Self.m4FixtureReleaseDayTable.count]+ } else if Self.isM4FixtureCatchUpIndex(index) {+ work.readingStatus = .catchingUp+ } } let entries = try context.fetch(FetchDescriptor<Entry>())@@ -856,6 +867,26 @@ extension LibraryRepository { /// 500 of the 5,000, which is what `M4ScaleFixtureTests` pins. public static let m4FixtureShiftedEntryCount = m4FixtureEntryCount / m4FixtureNoteShiftStride+ /// One base-namespace Work in ten is a catch-up candidate, before the+ /// predicate below takes the scheduled ones back out (`catch-up-mode` Q12).+ public static let m4FixtureCatchUpStride = 10+ /// Whether the base Work at `index` in identifier order is caught up on.+ /// One predicate, read by the seeder and by the count below, so the two can+ /// never drift.+ ///+ /// The exclusion is what keeps the two layers disjoint by construction+ /// rather than by the order they run in: a catch-up work is not a scheduled+ /// work (Q8), so a work marked here must not be one the release-day walk+ /// just gave days to. Index 0 is a multiple of both strides and belongs to+ /// the schedule.+ static func isM4FixtureCatchUpIndex(_ index: Int) -> Bool {+ index.isMultiple(of: m4FixtureCatchUpStride)+ && !index.isMultiple(of: m4FixtureScheduleStride)+ }+ /// 66 of the 1,000, which is what `M4ScaleFixtureTests` pins: a hundred+ /// multiples of ten, less the thirty-four that are multiples of thirty.+ public static let m4FixtureCatchUpWorkCount =+ (0..<m4FixtureWorkCount).count { isM4FixtureCatchUpIndex($0) } /// One whole day **forward**, so `firstCapturedAt ≤ lastSharedAt` still holds /// on every moved row — a backward move would break the invariant on 500 of /// them (Q45). The base entries sit at epoch 0…4,999 s, so under the arm's
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swift b/Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swiftindex e8dae279..f2173c08 100644--- a/Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/TodayDayPlan.swift@@ -189,10 +189,23 @@ public struct TodaySchedule: Equatable, Sendable { /// ([4.2](../../../../specs/update-schedule/requirements.md#4.2)), which mark /// the weekly pattern on every displayed week, past weeks included (Q15). public let dueWeekdays: Set<ReleaseDay>+ /// The works the reader is behind on, in the same collation as `works`+ /// (`catch-up-mode`).+ ///+ /// Deliberately **not** a subset of `works`: a catch-up work is not a+ /// scheduled work (Q8). It fails the scheduled filter by its reading status,+ /// contributes nothing to `dueWeekdays`, and is listed whatever its work+ /// status and whatever release days it carries — a finished story the reader+ /// is behind on is exactly the case (Q3). Its days are kept on the row and+ /// take effect again the moment the status returns to `reading`.+ public let catchingUp: [ScheduledWork] - public init(works: [ScheduledWork], dueWeekdays: Set<ReleaseDay>) {+ public init(+ works: [ScheduledWork], dueWeekdays: Set<ReleaseDay>, catchingUp: [ScheduledWork]+ ) { self.works = works self.dueWeekdays = dueWeekdays+ self.catchingUp = catchingUp } /// The schedule the library's works amount to under `calendar`.@@ -200,17 +213,30 @@ public struct TodaySchedule: Equatable, Sendable { /// O(works + entries), and the calendar is consulted only to bucket each /// work's entries into civil days. public static func build(works: [WorkSnapshot], calendar: Calendar) -> TodaySchedule {- let scheduled =- works- .filter {- $0.workStatus == .ongoing && $0.readingStatus == .reading- && !$0.releaseDays.isEmpty+ // One walk, two buckets. The predicates are disjoint — a catch-up work+ // fails the scheduled filter by its reading status (Q8) — so the order+ // of the two tests does not matter, only that the list is read once.+ var scheduledSnapshots: [WorkSnapshot] = []+ var catchingUpSnapshots: [WorkSnapshot] = []+ for work in works {+ if work.workStatus == .ongoing && work.readingStatus == .reading+ && !work.releaseDays.isEmpty+ {+ scheduledSnapshots.append(work)+ } else if work.readingStatus == .catchingUp {+ catchingUpSnapshots.append(work) }- .sorted(by: WorkSnapshot.titleOrder)- .map { ScheduledWork($0, calendar: calendar) }+ }+ let collate = { (snapshots: [WorkSnapshot]) in+ snapshots+ .sorted(by: WorkSnapshot.titleOrder)+ .map { ScheduledWork($0, calendar: calendar) }+ }+ let scheduled = collate(scheduledSnapshots) return TodaySchedule( works: scheduled,- dueWeekdays: Set(scheduled.flatMap(\.releaseDays)))+ dueWeekdays: Set(scheduled.flatMap(\.releaseDays)),+ catchingUp: collate(catchingUpSnapshots)) } } @@ -320,15 +346,24 @@ public struct TodayDayPlan: Equatable, Sendable { /// shows one dim line /// ([5.6](../../../../specs/update-schedule/requirements.md#5.6)). public let noted: TodayPresentationGroup?+ /// The catch-up rows for this day (`catch-up-mode`): every work on+ /// `catchingUp`, whatever its release days, shaped as a release row and+ /// carrying the release row's own second line.+ ///+ /// **Empty on a day before `currentDay`.** Catching up is a standing+ /// reminder rather than a weekly pattern, so a past day has nothing to+ /// record (Q5); today and every future day carry the whole list.+ public let catchingUp: [ReleaseRow] public init( day: CivilDay, placement: Placement, releases: [ReleaseRow],- noted: TodayPresentationGroup?+ noted: TodayPresentationGroup?, catchingUp: [ReleaseRow] ) { self.day = day self.placement = placement self.releases = releases self.noted = noted+ self.catchingUp = catchingUp } /// The plan for `day`, given the library as the current publication saw it.@@ -352,11 +387,15 @@ public struct TodayDayPlan: Equatable, Sendable { let releases = schedule.works .filter { $0.releaseDays.contains(weekday) } .map { work in- ReleaseRow(- id: work.id, title: work.title, primaryHostname: work.primaryHostname,- typeDisplay: work.typeDisplay, thumbnail: work.thumbnail,- line: line(for: work, on: day, calendar: calendar))+ row(for: work, on: day, calendar: calendar) }+ // No weekday filter: a work the reader is behind on is listed every day+ // from the current one on, whatever days its author publishes on (Q5,+ // Q8).+ let catchingUp =+ day < currentDay+ ? []+ : schedule.catchingUp.map { row(for: $0, on: day, calendar: calendar) } return TodayDayPlan( day: day,@@ -366,7 +405,20 @@ public struct TodayDayPlan: Equatable, Sendable { releases: releases, noted: notedSection( for: day, currentDay: currentDay, presentation: presentation,- calendar: calendar))+ calendar: calendar),+ catchingUp: catchingUp)+ }++ /// One row, for a release and for a catch-up work alike: the two sections+ /// draw the same shape and read the same second line (Q13), and only the+ /// identifier prefix the app gives them differs.+ private static func row(+ for work: ScheduledWork, on day: CivilDay, calendar: Calendar+ ) -> ReleaseRow {+ ReleaseRow(+ id: work.id, title: work.title, primaryHostname: work.primaryHostname,+ typeDisplay: work.typeDisplay, thumbnail: work.thumbnail,+ line: line(for: work, on: day, calendar: calendar)) } /// [5.2](../../../../specs/update-schedule/requirements.md#5.2)'s three arms,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14ArchiveTests.swiftindex 09cb24d4..52cd0b9a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14ArchiveTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14ArchiveTests.swift@@ -3709,6 +3709,39 @@ struct BackupV14ImportTests { workID: BackupV14Fixtures.workID, digest: BackupV14Fixtures.coverDigest) == BackupV14Fixtures.coverBytes) }++ /// `catch-up-mode` Q6: the fourth reading status is vocabulary, not+ /// structure, so it travels under the **unchanged** 14/15 pair — the wire+ /// field is already the typed enum and no generation moves for it.+ ///+ /// Asserted across the same three seams the cover crosses, because a value+ /// the projection or the commit dropped would land back as the column's+ /// default and read as a reader who never fell behind: out of the store+ /// through the real projection, through the codec, and into a different+ /// library.+ @Test("A work on catchingUp exports and imports unchanged under 14/15")+ func catchingUpRoundTrips() async throws {+ let source = try await M5Fixture()+ try await source.repository.confirmImport(+ plan: BackupV14Fixtures.plan(+ BackupV14Fixtures.composedPayload(readingStatus: .catchingUp)))++ let payload = try await source.repository.backupV14Snapshot()+ #expect(+ payload.works.first { $0.id == BackupV14Fixtures.composedWorkID }?.readingStatus+ == .catchingUp)++ let encoded = try BackupV14Codec.encode(+ payload: payload, metadata: BackupV14Fixtures.metadata())+ #expect(try BackupV14Codec.decode(encoded).backupFormatVersion == 14)++ let target = try await M5Fixture()+ try await target.repository.confirmImport(plan: try BackupImporter.plan(from: encoded))++ #expect(+ try await target.repository.work(id: BackupV14Fixtures.composedWorkID).readingStatus+ == .catchingUp)+ } } // MARK: - Test Doubles
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14Fixtures.swiftindex 24447104..0e1df052 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14Fixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV14Fixtures.swift@@ -246,6 +246,7 @@ enum BackupV14Fixtures { static func composedPayload( dropNameContributor: Bool = false, releaseDays: [ReleaseDay] = [],+ readingStatus: ReadingStatus = .abandoned, workTypeID: UUID? = novelTypeID, typeName: String? = "novel", workTypes: [BackupV14WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]@@ -278,7 +279,7 @@ enum BackupV14Fixtures { let work = BackupV14Work( id: composedWorkID, displayTitle: workName, lastParsedTitle: workName, genericNotes: "", genreTags: [], titleProvenance: .parsed,- workStatus: .finished, readingStatus: .abandoned,+ workStatus: .finished, readingStatus: readingStatus, verdict: "Dropped it at the timeskip.", releaseDays: releaseDays, workTypeID: workTypeID, typeName: typeName,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swiftindex 79862878..4c75ee21 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift@@ -486,6 +486,47 @@ struct DuplicateReconcilerTests { #expect(kept.workStatus == .ongoing) } + /// `catch-up-mode` Q10, the one direction the fourth status adds. A carrier+ /// on `catchingUp` is authored content, so the arm above would carry it over+ /// anything — including a sibling the reader has since marked `abandoned` on+ /// another device. Within one pass the scan would have called that set+ /// divergent and never reached the write; across the scan-to-write gap it is+ /// the guard that keeps the done status.+ ///+ /// The sibling still on `reading` in the same group is the control: the+ /// guard is a direction, not a veto on `catchingUp` propagating at all.+ @Test("A catchingUp carrier writes over a sibling on reading and never over a done one")+ func catchingUpCarrierNeverOverwritesADoneSibling() throws {+ let store = try DuplicateStore()+ let site = store.addSite()+ let workID = DuplicateStore.rankedID(1)+ let carrier = store.addWork(+ id: workID, title: "The Serial", createdAt: 0, notes: "reader notes", site: site)+ carrier.readingStatus = .catchingUp+ store.addWork(id: workID, title: "The Serial", createdAt: 30, site: site)+ store.addWork(id: workID, title: "The Serial", createdAt: 60, site: site)+ try store.commit()++ try store.reconcile(afterScan: { context in+ // One of the two bare rows goes done after the scan classified the+ // set; the other is left where it was.+ let rows = try context.fetch(FetchDescriptor<Work>())+ .filter { $0.genericNotes.isEmpty }+ .sorted { $0.createdAt < $1.createdAt }+ rows.first?.readingStatus = .abandoned+ })+ // The blocked group is a *stable* state, not a one-pass reprieve: the+ // two authored statuses disagree, so every later pass calls the set+ // divergent and hands it to the reader rather than picking a winner.+ try store.reconcileToFixedPoint()++ let works = try store.workFacts()+ #expect(works.count == 3, "a split group's rows are converged, never collapsed")+ #expect(works.filter { $0.readingStatus == .abandoned }.count == 1)+ #expect(works.filter { $0.readingStatus == .catchingUp }.count == 2)+ #expect(works.allSatisfy { $0.genericNotes == "reader notes" })+ }+ // MARK: - V11: the series pair (Reqs 9.6, 11.3) /// Req 11.3, on the `genreTags` shape: a membership the carrier holds
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swiftindex b9899848..b8ac606b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift@@ -444,6 +444,35 @@ struct DuplicateResolutionTests { #expect(variants.map(\.verdict) == ["a fine ending", "put it down"]) } + /// `catch-up-mode` Q10, at the card: a set disagreeing between `catchingUp`+ /// and a done status is two variants, and the reading status is the one+ /// thing the reader has to choose between — so beside the notes, which the+ /// card always names, it is the only field listed. Everything else about the+ /// two copies is identical here, which is what makes the assertion about the+ /// status rather than about the seed.+ @Test("The Work sheet names a catchingUp-against-done set by its reading status")+ func catchingUpAgainstDoneNamesTheReadingStatus() async throws {+ let library = try ResolutionFixture()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ store.insertWork(+ title: "Serial", offset: 0, notes: "same notes", readingStatus: .catchingUp)+ store.insertWork(+ title: "Serial", offset: 40, notes: "same notes", readingStatus: .abandoned)+ }+ let repository = try await library.openForApp()+ let setKey = try Self.onlyWorkSetKey(library)++ let contract = try await repository.projectDuplicateResolution(setKey: setKey)++ guard case .work(_, let variants, let fields, _) = contract else {+ Issue.record("expected a Work contract")+ return+ }+ #expect(fields == [.genericNotes, .readingStatus])+ #expect(variants.map(\.readingStatus) == [.catchingUp, .abandoned])+ }+ // MARK: - V14: the cover (`work-thumbnails` Req 1.6) /// A cover is authored content, so two copies disagreeing about one are a
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swiftindex bb03044c..3055eba8 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift@@ -218,6 +218,45 @@ struct DuplicateScanTests { #expect(set.variants.first?.content.note == "one reading") } + /// `catch-up-mode` Q10: `catchingUp` is reader-authored content, exactly as+ /// `finished` and `abandoned` are — `WorkAuthoredContent.isBare` asks for+ /// `reading` and nothing else, so no classification code moved for it. What+ /// that buys is this: a member still on the default folds into the one the+ /// reader moved, and the reader is never asked about a decision they have+ /// already made on another device.+ @Test("A catchingUp member beside one on reading resolves silently to catchingUp")+ func catchingUpBesideReadingResolvesSilently() throws {+ let store = try ScanStore()+ store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+ let behind = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+ behind.readingStatus = .catchingUp+ try store.commit()++ let set = try #require(try store.scan().workSets.first)+ #expect(set.classification == .silentlyResolvable)+ #expect(set.variants.map(\.content.readingStatus) == [.catchingUp])+ }++ /// The other half of Q10, and the reason the carry guard exists rather than+ /// the classification doing the work: catching up and being done are two+ /// things the reader said, so the set is two variants and goes to the review+ /// card. `abandoned` makes the same set for the same reason.+ @Test("A catchingUp member against a done one is divergent", arguments: [+ ReadingStatus.finished, .abandoned,+ ])+ func catchingUpAgainstDoneIsDivergent(_ done: ReadingStatus) throws {+ let store = try ScanStore()+ let behind = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 0)+ behind.readingStatus = .catchingUp+ let read = store.addWork(title: "A Serial", parsedTitle: "A Serial", offset: 60)+ read.readingStatus = done+ try store.commit()++ let set = try #require(try store.scan().workSets.first)+ #expect(set.classification == .divergent)+ #expect(set.variants.map(\.content.readingStatus) == [.catchingUp, done])+ }+ /// Req 3.3: bare and agreeing members must not enlarge the decision. @Test("Bare members do not enlarge a divergent set's variant list") func bareMembersDoNotEnlargeTheDecision() throws {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex 7b5a9239..231f98e5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -358,10 +358,35 @@ struct M4ScaleFixtureTests { #expect( scheduled.allSatisfy { $0.workStatus == .ongoing && $0.readingStatus == .reading }) - // The duplicate-set Works stay unscheduled, as they stay uncovered.+ // `catch-up-mode` Q12: 66 of the works the release-day walk left alone+ // are caught up on — and which 66, not merely how many. The two sets are+ // disjoint by construction, which is the half worth asserting: a catch-up+ // work is not a scheduled work (Q8), so a marked row must never be one+ // of the 334.+ let caughtUp = plain.indices.filter { plain[$0].readingStatus == .catchingUp }+ #expect(caughtUp.count == LibraryRepository.m4FixtureCatchUpWorkCount)+ #expect(caughtUp.count == 66)+ #expect(+ caughtUp+ == (0..<plain.count).filter {+ $0.isMultiple(of: LibraryRepository.m4FixtureCatchUpStride)+ && !$0.isMultiple(of: LibraryRepository.m4FixtureScheduleStride)+ })+ #expect(+ caughtUp.allSatisfy { plain[$0].releaseDays.isEmpty },+ "a catch-up work must not be one of the scheduled third")+ // Everything else stays on the default, so the 334 above are untouched+ // and still reach `TodaySchedule.build`.+ #expect(+ scheduled.allSatisfy { $0.readingStatus == .reading })++ // The duplicate-set Works stay unscheduled, as they stay uncovered and+ // as they stay off `catchingUp`: those rows are deleted and re-seeded+ // once per settling sample, so anything written onto one would be gone+ // after the first. #expect( works.filter { LibraryRepository.isDuplicateFixtureWork($0) }- .allSatisfy { $0.releaseDays.isEmpty })+ .allSatisfy { $0.releaseDays.isEmpty && $0.readingStatus == .reading }) let entries = try seeded.fetch(FetchDescriptor<Entry>()) let base = entries@@ -408,9 +433,15 @@ struct M4ScaleFixtureTests { !LibraryRepository.isDuplicateFixtureWork($0) && !$0.releaseDays.isEmpty } .count == LibraryRepository.m4FixtureScheduledWorkCount)+ #expect(+ afterReseed.filter {+ !LibraryRepository.isDuplicateFixtureWork($0)+ && $0.readingStatus == .catchingUp+ }+ .count == LibraryRepository.m4FixtureCatchUpWorkCount) #expect( afterReseed.filter { LibraryRepository.isDuplicateFixtureWork($0) }- .allSatisfy { $0.releaseDays.isEmpty })+ .allSatisfy { $0.releaseDays.isEmpty && $0.readingStatus == .reading }) } /// The seven-pair table is a property of the fixture, not of the store: it
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swiftindex 50ff50e1..a33ded94 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift@@ -153,9 +153,9 @@ struct M4ToleratedScalePerformanceTests { // MARK: - `update-schedule` Req 10.5 — publishing a Today day /// Req 10.5: what the Today screen costs on top of the entry publication- /// Recent already made — the schedule, the day's releases section, each- /// release row's second line and the strip's dots — against the same 2 s- /// budget, over the same fixture.+ /// Recent already made — the schedule, the day's releases section, its+ /// Catching up section (`catch-up-mode`), each row's second line and the+ /// strip's dots — against the same 2 s budget, over the same fixture. /// /// **`works()` is read once, outside the timed closure** (Q41). It is a /// separately published value with a performance arm of its own@@ -204,6 +204,10 @@ struct M4ToleratedScalePerformanceTests { let schedule = TodaySchedule.build(works: snapshot.works, calendar: calendar) #expect(schedule.works.count == LibraryRepository.m4FixtureScheduledWorkCount) #expect(schedule.dueWeekdays == Set(ReleaseDay.allCases))+ // `catch-up-mode` Q12, for the same reason: the Catching up section is+ // inside the measured interval, and an empty one would cost nothing and+ // prove nothing.+ #expect(schedule.catchingUp.count == LibraryRepository.m4FixtureCatchUpWorkCount) let presentation = try await repository.todayPresentation(calendar: calendar) let current = TodayDayPlan.build( day: currentDay, currentDay: currentDay, schedule: schedule,@@ -213,6 +217,10 @@ struct M4ToleratedScalePerformanceTests { presentation: presentation, calendar: calendar) #expect(!current.releases.isEmpty) #expect(!past.releases.isEmpty)+ // The current day carries every catch-up row; the past day carries none+ // (Q5), which is the other half of what the rows cost.+ #expect(current.catchingUp.count == LibraryRepository.m4FixtureCatchUpWorkCount)+ #expect(past.catchingUp.isEmpty) #expect(current.noted?.rows.count == LibraryRepository.m4FixtureShiftedEntryCount) #expect( past.noted?.rows.count
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex 89b82e16..b2210165 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -52,7 +52,13 @@ struct ModelContractTests { // filter menus, so both are pinned here rather than derived at the two // call sites. #expect(WorkStatus.allCases.map(\.rawValue) == ["ongoing", "finished", "hiatus"])- #expect(ReadingStatus.allCases.map(\.rawValue) == ["reading", "finished", "abandoned"])+ // `catch-up-mode` Q2 and Q11 add the fourth value second: vocabulary+ // rather than structure, so the column, the snapshot and the archive+ // generation all stand still, and the case order is the display order+ // the picker and the filter menu iterate.+ #expect(+ ReadingStatus.allCases.map(\.rawValue)+ == ["reading", "catchingUp", "finished", "abandoned"]) // V15's release days. The raw values are the strings // `Work.releaseDaysRaw` holds, and the **case order** is the canonical // storage and wire order — fixed at Monday first so two rows given the@@ -66,6 +72,9 @@ struct ModelContractTests { // The one derived predicate: everything that shows or hides the verdict // reads it. #expect(ReadingStatus.reading.isDone == false)+ // A reader who is behind is still reading (`catch-up-mode`), so the+ // verdict stays hidden for them.+ #expect(ReadingStatus.catchingUp.isDone == false) #expect(ReadingStatus.finished.isDone) #expect(ReadingStatus.abandoned.isDone) #expect(FieldProvenanceKind.allCases.map(\.rawValue) == ["none", "pattern", "urlRule", "manual"])
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TodayDayPlanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TodayDayPlanTests.swiftindex d1595545..a61267eb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/TodayDayPlanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TodayDayPlanTests.swift@@ -470,6 +470,146 @@ struct TodayDayPlanTests { #expect(plan.releases.map(\.title) == ["Both", "Thursday work"]) } + // MARK: - Catching up (`catch-up-mode`)++ /// Q8, the whole of it in one case: a work the reader is behind on keeps its+ /// release days, and they do nothing while the status holds. It fails the+ /// scheduled filter by status, so it lights no strip dot and lists no+ /// release row **on its own release weekday** — the day where a missing+ /// filter would be invisible — and appears under `catchingUp` instead.+ @Test("A catch-up work with days on the selected weekday is listed only under catchingUp")+ func catchUpWorkIsNeverScheduled() {+ let calendar = Self.calendar()+ let schedule = TodaySchedule.build(+ works: [+ Self.work(id: Self.id(1), title: "Behind", releaseDays: [.thursday],+ readingStatus: .catchingUp)+ ],+ calendar: calendar)++ #expect(schedule.works.isEmpty)+ #expect(schedule.dueWeekdays.isEmpty)+ #expect(schedule.catchingUp.map(\.title) == ["Behind"])++ let plan = TodayDayPlan.build(+ day: Self.thursday, currentDay: Self.thursday, schedule: schedule,+ presentation: Self.presentation([], calendar: calendar), calendar: calendar)++ #expect(plan.releases.isEmpty)+ #expect(plan.catchingUp.map(\.title) == ["Behind"])+ }++ /// MUST 5's second half: the days are kept on the row while the status+ /// holds, so the moment it returns to `reading` the same work is scheduled+ /// again — a release row on its own weekday and a dot on the strip — and+ /// lists nothing under `catchingUp`. The mirror image of the case above,+ /// over the same work.+ @Test("The release days take effect again when the status returns to reading")+ func catchUpDaysReturnWithTheReadingStatus() {+ let calendar = Self.calendar()+ let schedule = TodaySchedule.build(+ works: [+ Self.work(id: Self.id(1), title: "Behind", releaseDays: [.thursday],+ readingStatus: .reading)+ ],+ calendar: calendar)++ #expect(schedule.works.map(\.title) == ["Behind"])+ #expect(schedule.dueWeekdays == [.thursday])+ #expect(schedule.catchingUp.isEmpty)++ let plan = TodayDayPlan.build(+ day: Self.thursday, currentDay: Self.thursday, schedule: schedule,+ presentation: Self.presentation([], calendar: calendar), calendar: calendar)++ #expect(plan.releases.map(\.title) == ["Behind"])+ #expect(plan.catchingUp.isEmpty)+ }++ /// Q3: there is no work-status gate. A finished story the reader is behind+ /// on is exactly the case the section is for, and so is a work with no+ /// release days at all — neither of which the scheduled filter would let+ /// through. The order is the Works list's collation, as the releases are.+ @Test("A catch-up work is listed whatever its work status and days")+ func catchUpIgnoresTheWorkStatus() {+ let schedule = TodaySchedule.build(+ works: [+ Self.work(id: Self.id(1), title: "Zephyr", releaseDays: [],+ workStatus: .finished, readingStatus: .catchingUp),+ Self.work(id: Self.id(2), title: "Anchor", releaseDays: [.monday],+ workStatus: .hiatus, readingStatus: .catchingUp),+ Self.work(id: Self.id(3), title: "Marrow", releaseDays: [],+ workStatus: .ongoing, readingStatus: .catchingUp),+ ],+ calendar: Self.calendar())++ #expect(schedule.catchingUp.map(\.title) == ["Anchor", "Marrow", "Zephyr"])+ }++ /// Q5: catching up is a standing reminder rather than a weekly pattern, so a+ /// past day has nothing to record. Today and every future day carry the+ /// whole list, whatever weekday they fall on.+ @Test("A past day carries no catch-up rows, and today and a future day carry them all")+ func pastDaysCarryNoCatchUpRows() {+ let calendar = Self.calendar()+ let schedule = TodaySchedule.build(+ works: [+ Self.work(id: Self.id(1), title: "Behind", releaseDays: [],+ readingStatus: .catchingUp)+ ],+ calendar: calendar)++ func plan(_ day: CivilDay) -> TodayDayPlan {+ TodayDayPlan.build(+ day: day, currentDay: Self.thursday, schedule: schedule,+ presentation: Self.presentation([], calendar: calendar), calendar: calendar)+ }++ #expect(plan(Self.thursday.adding(days: -1, calendar: calendar)).catchingUp.isEmpty)+ #expect(plan(Self.thursday).catchingUp.map(\.title) == ["Behind"])+ #expect(+ plan(Self.thursday.adding(days: 3, calendar: calendar)).catchingUp.map(\.title)+ == ["Behind"])+ }++ /// Q13: the row shape and the second-line rule are the release row's, not a+ /// parallel copy — so all three arms of Req 5.2 read on a catch-up row+ /// exactly as they do on a release, measured against the selected day.+ @Test("A catch-up row's second line reads as a release row's")+ func catchUpRowsTakeTheReleaseLine() {+ let calendar = Self.calendar()+ let schedule = TodaySchedule.build(+ works: [+ Self.work(+ id: Self.id(1), title: "Noted today", releaseDays: [],+ readingStatus: .catchingUp,+ entries: [+ Self.entry(sharedAt: Self.instant(2026, 3, 12), chapterTitle: "Ch. 41")+ ]),+ Self.work(+ id: Self.id(2), title: "Noted before", releaseDays: [],+ readingStatus: .catchingUp,+ entries: [+ Self.entry(sharedAt: Self.instant(2026, 3, 11), chapterTitle: "Ch. 40")+ ]),+ Self.work(+ id: Self.id(3), title: "Never noted", releaseDays: [],+ readingStatus: .catchingUp),+ ],+ calendar: calendar)++ let plan = TodayDayPlan.build(+ day: Self.thursday, currentDay: Self.thursday, schedule: schedule,+ presentation: Self.presentation([], calendar: calendar), calendar: calendar)++ #expect(plan.catchingUp.map(\.title) == ["Never noted", "Noted before", "Noted today"])+ #expect(plan.catchingUp.map(\.line) == [+ .notNotedYet,+ .lastNoted(chapterLine: "Ch. 40", when: .yesterday),+ .notedOnDay(chapterLine: "Ch. 41"),+ ])+ }+ // MARK: - Determinism and the calendar as a parameter /// Req 2.4's shape, applied to a derivation: the same inputs give the same
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swiftindex 7f7da5f2..7b06ac5b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift@@ -264,6 +264,32 @@ struct WorkMergePlannerTests { #expect(outcome.genericNotes == "Target notes") } + /// `catch-up-mode` Q10 at the merge preview. The fourth reading status is+ /// off the default like the other two, so it reaches the discarded list+ /// through the same arm and the merged Work keeps the **target's** status+ /// whichever way round the two sit. Both directions are asserted, because a+ /// gate written as "the source is done" rather than "the source is off its+ /// default" would pass one of them and drop the other.+ @Test("Merge keeps the target's status and lists a differing source catchingUp as discarded")+ func targetKeepsItsStatusAgainstCatchingUp() throws {+ let rule = try queryRule()++ let overDone = try WorkMergePlanner.project(WorkMergeBasis(+ source: work(id: 2, title: "Same", readingStatus: .catchingUp),+ target: work(id: 1, title: "Same", notes: "Target notes", readingStatus: .finished),+ currentRule: rule))+ #expect(overDone.readingStatus == .finished)+ #expect(overDone.discardedFields == [.sourceReadingStatus])+ #expect(overDone.retainedFields.contains(.targetReadingStatus))++ let underDone = try WorkMergePlanner.project(WorkMergeBasis(+ source: work(id: 3, title: "Same", readingStatus: .abandoned),+ target: work(id: 1, title: "Same", notes: "Target notes", readingStatus: .catchingUp),+ currentRule: rule))+ #expect(underDone.readingStatus == .catchingUp)+ #expect(underDone.discardedFields == [.sourceReadingStatus])+ }+ @Test("Audit formatter escapes the structured lines and appends repeated blocks in order") func canonicalAuditGolden() { let first = WorkMergeAuditFormatter.block(
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 7be6572c..2d4a059b 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -1390,6 +1390,32 @@ new parameterised case does not appear by name in the run output, do not assume the filter matched it — count the cases. A plain test with several expectations is the safe form when the argument is a tuple. +## Four UI cases fail at `main`'s tip `215c843`, and were green the day before++Recorded 2026-09-17 on the `catch-up-mode` branch, so the next branch does not+re-investigate. `make test-ui` and `make test-ui-ipad` carry four failures+that reproduce verbatim, one by one through `make test-only`, in an untouched+worktree at `215c843` (the `update-schedule` merge), while+`specs/update-schedule/verification-run.md` §10 had every one of these suites+green on 2026-09-16 on the same host:++- `WorkDetailStatusUITests.testTheStoredScheduleShowsOnTheMetaLineAndCommitsFromTheToggles`+ and `.testCancellingTheEditorDiscardsTheToggledReleaseDays` — both from+ `assertMetaLineNames`, `("12.0") is not greater than ("22.0")`: the schedule+ item's `Label` has collapsed to its glyph inside the `FlowLayout`. The+ comment on that assertion says this has happened before.+- `AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType`+ — `Recent lists a taught chapter`, over `seeded-composed`.+- `WideLayoutUITests.testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane`+ — `The name switches the window to the Works pane`, over `seeded-composed`.++The first two are deterministic (3 of 3, including on a freshly booted+simulator). Nothing in the branch explains them and nothing on `main` moved+between the two runs, so the host is the suspect: an OS or Xcode update, or+the simulator runtime. Details, including the runs, are in+`specs/catch-up-mode/verification-run.md`. They want a bugfix spec of their+own; until then, a red `make test-ui` with exactly these four is not news.+ ## Misc - `make test-only TEST=AsterismTests/SomeSuite` runs one suite; `TEST` also
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 194e3ee3..70b96e80 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -45,7 +45,7 @@ | [Work Thumbnails](#work-thumbnails) | 2026-09-11 | Done — all 40 tasks implemented; `make test-core`, `make test-quick`, `make build-mac` and `make build-ios` green with no new warnings, `WorkCoverUITests` green and the `WorkDetailCreditsUITests` case re-run green (`verification-run.md` §2, §3 and §4). The store-shape probe passed on the phone on 2026-09-12 (§1). Open: `make test-performance-m4` has no quiet-host band — the runs on this branch were made on a machine at load 3–412 and disagree with each other by 1.7× — the owner's device steps in `prerequisites.md` (the two-install sync check, the Mac checklist, the `PasteButton` check), and Q80's decision on the export's peak, which the review's archive work cut from ~464 MB to ~418 MB but did not bring under 400 MB | Full spec (T-2330). A reader-chosen cover image per work: bytes in an external-storage column beside a shape and a digest under schema V14 with markers `"13"` → `"14"` and the archive at 13/14; set from the work editor by Find cover — head sources, a page-body `<img>` arm with a Scan page action, and a query-less sibling for every candidate URL — or Photos, file or paste, cropped to portrait or square with the zoom floor at the fit scale and the gap stored out, saved with the work, shown in 56×84 row slots and a 160×240 header with a long-press viewer, carried by the archive. No on-device model ranks candidates. | | [Entry to Work Link](#entry-to-work-link) | 2026-09-14 | Done — all 5 tasks implemented 2026-09-14 to 2026-09-16; `make test-quick` green with no new warnings; every phone UI suite green on the branch across three partial `make test-ui` runs plus per-suite runs (the M4Scale trio excepted); `make test-ui-ipad` ran all 25 cases twice with the new case green both times and one different runner-crash failure each time, no single exit 0 (`verification-run.md`) | Smolspec. The work name on the entry detail screen becomes a control that opens the work's detail screen. One navigation-owned route: from a chapter on top of the Works path it drops the chapter and keeps what is beneath the work; from any other tab it shows that one work with nothing beneath it. Plain text stays for an unresolved or blank name and inside the Merge sheet. App-layer only; no Core, schema or repository change. | | [Update Schedule](#update-schedule) | 2026-09-15 | Done — all 30 tasks implemented 2026-09-16; `make test-core` (3,013 tests, zero known issues), `make test-quick`, `make test-ui-ipad` (27/27) green, `make test-performance-m4 RUNS=3` with the ten documented known issues and `todayPublication` at 0.80–0.88 s under a 2 s budget; the owner's device steps in `prerequisites.md` remain | Full spec. A reader-set list of release weekdays on every work under schema V15 with markers `"14"` → `"15"` and the archive at 14/15, set by seven toggles in the editor's Status card and shown on the meta line; the Recent tab becomes **Today**, a week strip over the day's releases and notes with its own typed route stack, inheriting Recent's banners, duplicate sections and search and superseding the 100-row cap; a Works filter finds works with no days. Manual only, no model. |-| [Catch-Up Mode](#catch-up-mode) | 2026-09-16 | Planned — smolspec and 9 tasks approved 2026-09-17 | Smolspec. A fourth reading status, `catchingUp`, for a work the reader is behind on: it shows in a `Catching up` section on Today on the current day and future days whatever its work status or release days, while its release days are parked; no schema bump and no archive generation bump. |+| [Catch-Up Mode](#catch-up-mode) | 2026-09-16 | Done — both phases landed 2026-09-17; four pre-existing UI-test failures recorded in its verification run. Open: the full `make test-performance-m4` has not been run against the fixture with its 66 catch-up works | Smolspec. A fourth reading status, `catchingUp`, for a work the reader is behind on: it shows in a `Catching up` section on Today on the current day and future days whatever its work status or release days, while its release days are parked; no schema bump and no archive generation bump. | --- @@ -764,10 +764,11 @@ Full spec. A **release day** is one of the seven weekdays a reader records on a ## Catch-Up Mode -**Created:** 2026-09-16 · **Status:** Planned — smolspec, decision log and 9 tasks approved 2026-09-17; Q1–Q14 in the log.+**Created:** 2026-09-16 · **Status:** Done — both phases landed 2026-09-17; core and the unit bundle green, the feature's own journeys green; `make test-ui` and `make test-ui-ipad` carry four failures that reproduce on `main` at `215c843` and are recorded, unattributed, in `verification-run.md`. Open: only the `todayPublication` arm was measured; the full `make test-performance-m4` has not been run since the M4 fixture gained 66 base works on `catchingUp`, which `isBare` counts as authored, so every other arm's band is unverified against that fixture. Q1–Q17 in the log. Smolspec. A reader behind on a story has no reason to wait for its release day, so a fourth reading status, **`catchingUp`**, sits beside `reading`, `finished` and `abandoned` (Q2, Q11), stored in the existing raw-string column with no schema bump. A catch-up work appears in a `Catching up` section on Today directly after the releases section, on the current day and future days only, hidden entirely when empty (Q4, Q5), whatever its work status (Q3); it is not a scheduled work, so its release days add no row and no strip dot until the status returns to `reading` (Q8), and the editor's note under the toggles says the days are parked (Q9). The value is authored content in the duplicate phase and rides the 14/15 archive without a generation bump, which a pre-feature build refuses (Q6, Q7, Q10). Rows carry their own identifier prefix; the `today` UI fixture and the M4 fixture each gain catch-up works (Q12). - [smolspec.md](catch-up-mode/smolspec.md) - [tasks.md](catch-up-mode/tasks.md) - [decision_log.md](catch-up-mode/decision_log.md)+- [verification-run.md](catch-up-mode/verification-run.md)
diff --git a/specs/catch-up-mode/decision_log.md b/specs/catch-up-mode/decision_log.mdindex 74cb77e3..04c60241 100644--- a/specs/catch-up-mode/decision_log.md+++ b/specs/catch-up-mode/decision_log.md@@ -7,7 +7,7 @@ | Q1 | 2026-09-16 | Smolspec, with the decisions settled before the spec was written | Owner's call at the scope assessment; the assessment had recommended a full spec because the shape, the work-status gate and the presentation were open, and the owner answered all three up front instead | | Q2 | 2026-09-16 | Catch-up is a fourth reading status, `catchingUp`, not a boolean column on `Work` | Owner's pick. Catching up is mutually exclusive with being done, which a status expresses and a flag cannot; the raw-string column with its tolerant read means no schema bump, and the picker and filter menu iterate the enum's cases already. A column would move V16, the frozen snapshot, the readiness marker and the archive generation together | | Q3 | 2026-09-16 | No work-status gate: a catch-up work is listed whatever its work status | Owner's pick; a finished or hiatus story the reader is behind on is exactly the catch-up case |-| Q4 | 2026-09-16 | An own `Catching up` section directly after the releases section, same row shape and second-line rule as a release row, with its own row identifier prefix | Owner's pick of the section; the `Releasing` header stays true, and the plan-then-record ordering of `update-schedule` Req 5.5 is kept. The prefix is what lets a UI journey and VoiceOver tell the two sections' rows apart |+| Q4 | 2026-09-16 | An own `Catching up` section directly after the releases section, same row shape and second-line rule as a release row, with its own row identifier prefix | Owner's pick of the section; the `Releasing` header stays true, and the plan-then-record ordering of `update-schedule` Req 5.5 is kept. The prefix is what lets a UI journey tell the two sections' rows apart; an identifier is never spoken, so VoiceOver tells them apart by the section header it reads on the way in (corrected at the phase 2 review) | | Q5 | 2026-09-16 | The section appears on the current day and future days only, never on a past day; and it is hidden entirely, header included, when no work is catching up | Owner's pick on both. Catch-up is a standing reminder, not a weekly pattern, so a past day has nothing to record; a future day is a plan (`update-schedule` Req 5.5's releases-first placement) and a work being caught up on is part of every plan ahead. Most days hold no catch-up work, so an always-present empty block would be noise. The second is a deliberate deviation from `update-schedule` Q9, for this section only | | Q6 | 2026-09-16 | The archive writes `catchingUp` under the current 14/15 pair with no generation bump | The wire field is already the typed enum, so a pre-feature build refuses the archive as malformed, which is the standing rule for any unknown value; the generation moves for structural changes, not vocabulary. The same rule means a pre-feature build refuses to *export* a library holding the value, background export included, until it is updated | | Q7 | 2026-09-16 | A pre-feature build sharing the iCloud account reads the value as `reading`, lists a catch-up work with days in its own releases section, and may write `reading` back on its next Save of the work; accepted | Last-writer-wins for a single-reader library, the stance `work-and-reading-status` and `update-schedule` take for their fields; the owner updates every device, and both configurations mirror |@@ -18,6 +18,9 @@ | Q12 | 2026-09-16 | The M4 fixture marks 66 unscheduled base works as catching up, count pinned; the `today` UI fixture gains one finished work, `Ember Road`, catching up with an entry on the previous day, and the pins that count its works, entries and days move with it | The Today publication arm and the UI suite should exercise the section rather than pass with it empty; a finished work makes Q3 visible in the fixture; a previous-day entry gives the current day the `Last noted … · yesterday` line, the common catch-up case | | Q13 | 2026-09-16 | `ScheduledWork` and `ReleaseRow` are reused for catch-up rows rather than a parallel pair of types | Same fields, same second-line rule; the name is a little wide but a duplicate type would drift | | Q14 | 2026-09-17 | Four segments in the reading status control: the label stays `Catching up` and the control's stacked fallback is accepted wherever the capsule does not fit | Owner's pick. `ConstellationSegmentedControl` is a `ViewThatFits` that stacks its segments full-width when the capsule does not fit, and four caption labels with their padding are near a phone card's width; a shorter label was the alternative and reads worse as a state name. The control itself is out of scope |+| Q15 | 2026-09-17 | The Q10 carry guard is one-directional: a `catchingUp` carrier writes only over `reading`, but a `finished` or `abandoned` carrier may still write over a `catchingUp` row, as it may over the other done state today | Raised at the phase 1 review. The reverse direction is the same scan-to-write race the existing guard already accepts between the two done states; closing it means a carrier writes only over a row on the default, which also stops `finished` over `abandoned` and is a convergence behaviour change outside this feature. The next scan classifies any such group as divergent and routes it to review, so the state is visible, not silent |+| Q16 | 2026-09-17 | `setDraftWorkStatus`'s auto-revert and restore move a draft between `reading` and `finished` only; a `catchingUp` draft is left where it is when the work status changes | Raised at the phase 1 review. Q3 makes a finished work a valid catch-up case, so reverting or restoring a catch-up draft would contradict it; no new branch is needed, as the smolspec says |+| Q17 | 2026-09-17 | Ember Road's entry is backdated to a minute before Paper Lantern's rather than to the same instant, floored at the start of that day | Both rows land on the previous day, and Recent breaks a tie on the minted UUID — which is random per run, so "Paper Lantern first" would have been a coin toss the journey asserts. The floor keeps a launch in the first minute after midnight on the same civil day, which is all the `Last noted … · yesterday` line reads | ## Requirements of earlier specs this amends
diff --git a/specs/catch-up-mode/implementation.md b/specs/catch-up-mode/implementation.mdnew file mode 100644index 00000000..bb0e2d3a--- /dev/null+++ b/specs/catch-up-mode/implementation.md@@ -0,0 +1,278 @@+# Catch-Up Mode: Implementation++This file explains what the `catch-up-mode` branch does, at three levels of+detail, and ends with an honest assessment of how much of the smolspec landed.+It covers the combined state: the sixteen commits from merge base `3474c88`+plus the uncommitted review fixes. Read it beside `smolspec.md`,+`decision_log.md` (Q1 to Q17) and `verification-run.md`.++## Beginner Level++### What Changed++Asterism tracks what you read. Every *work* (a book, a webcomic, a serial) has a+**reading status**: `Reading`, `Finished` or `Abandoned`. It can also carry+**release days**, the weekdays its author publishes on, which the **Today**+screen uses to show what is due.++This adds a fourth status, **`Catching up`**, for a story you are behind on. If+you are twenty chapters back, waiting for Thursday makes no sense. So a catch-up+work leaves the weekly schedule entirely (its release days are kept, just+parked) and is listed instead in its own `Catching up` section on Today, on the+current day and every future day.++- The editor's status picker gains a fourth option, a `forward.fill` arrow in+ the cyan the reading side of the app already uses, shown wherever the other+ three are: detail meta line, Works row, filter menu and pill, duplicate review+ card, merge preview.+- Today gains a `Catching up` section under `Releasing`, hidden completely when+ empty and never drawn on a past day.+- The grey line under the day toggles reads `Release days apply again once+ you're back to Reading` while the draft is catching up.+- Nothing about the stored data's shape changed. No new column, no migration.++### Why It Matters++Before this, a reader behind on a story could leave it on `Reading` and have+Today nag them on a day they were not waiting for, or clear the release days and+lose the author's schedule. Now the state is named, the schedule survives, and+the work is offered every day rather than once a week. It was also cheap: the+status is stored as a plain string with a tolerant reader.++### Key Concepts++- **Reading status** is how far the *reader* is; **work status** (`ongoing`,+ `hiatus`, `finished`) is about the *author*. Independent, which is why a+ finished story can still be caught up on.+- **Tolerant read**: an unrecognised stored value reads as the column's default+ instead of crashing. That is what makes adding a value safe, and also what+ makes an older build read `catchingUp` as `reading`.+- **The Today publication**: the pass that turns the library into the summary+ Today draws, budgeted at 2 seconds over a 5,000 entry test fixture.+- **Convergence** repairs two copies of one record without deleting anything;+ **collapse**, the other mode, deletes losers.+- **The archive** is the backup file, version pair `14/15`, unmoved here.++---++## Intermediate Level++### Changes Overview++In Core: `DomainEnums.swift` gains `case catchingUp` second, after `reading`,+with `isDone` untouched. `TodayDayPlan.swift` gives `TodaySchedule` a+`catchingUp: [ScheduledWork]` and `TodayDayPlan` a `catchingUp: [ReleaseRow]`,+both public inits taking the parameter with no default, and a private+`row(for:on:calendar:)` shared by the release and catch-up maps.+`DuplicateReconciler.swift` gains one condition on the convergence carry.+`M4PerformanceFixture.swift` gains `isM4FixtureCatchUpIndex` and+`m4FixtureCatchUpWorkCount` (66), read by both the seeder and the pin.++In the app: `WorkStatusPresentation.swift`'s `name`, `systemImage` and+`verdictPrompt` gain the case; these are the only exhaustive switches over+`ReadingStatus`, and the picker and filter menu iterate `allCases`.+`WorkDetailModel.showsReleaseDaysNote: Bool` becomes `releaseDaysNote: String?`,+wordings moved into `ReleaseDaysText`. `TodayView.swift` gains+`catchingUpSection`, a shared `releaseRows` builder,+`ReleaseRowView.identifierPrefix` (`today-release-` / `today-catch-up-`),+`TodaySectionAccents.catchingUp` and the catch-up ids in+`TodayDisplayPlan.visibility.works`. `WorksView.swift` moves the abandoned+knock-down to `WorksRowPresentation.isKnockedDown`, still abandoned-only.+`AppLibraryModel.seedTodayWork` takes a reading status and the `today` fixture+gains `Ember Road`, finished and catching up.++### Implementation Approach++The pivot is that a catch-up work is **not** a scheduled work (Q8). Rather than+threading a flag through the scheduled list, `TodaySchedule.build` partitions+the snapshots into two disjoint buckets in one walk: the scheduled filter is+unchanged (`ongoing`, `reading`, non-empty days) and anything else on+`catchingUp` falls into the second. `dueWeekdays` derives from the scheduled+bucket only, which is why no strip dot appears.++`TodayDayPlan.build` maps the catch-up bucket with no weekday filter and returns+an empty list when `day < currentDay`. The rows are the same `ReleaseRow` with+the same second-line rule (Q13), so `Noted today`, `Last noted ... yesterday`+and `Not noted yet` all work unchanged.++The view draws the section only when non-empty, a deliberate deviation from+`update-schedule` Req 5.6 (Q5). Because both sections draw the same row view,+the identifier prefix is the only thing distinguishing them for a UI journey, so+`ReleaseRowView` takes the prefix and builds both the row's and the line's+identifier from it.++### Trade-offs++- **A status value, not a boolean column** (Q2). A flag could coexist with+ `finished`, which is nonsense; a status cannot, and it stays inside the+ existing raw-string column so nothing structural moves.+- **Reusing `ScheduledWork` and `ReleaseRow`** (Q13): the names read wide for a+ work that is not scheduled, but a parallel pair would drift.+- **The section hides when empty** (Q5), against the screen's own rule+ everywhere else. Most days hold none, and an empty block would be noise.+- **Two identifier prefixes**, so a journey counting release rows cannot+ silently start counting catch-up rows.+- **Four segments** in the status control may drop into the stacked fallback+ (Q14), accepted rather than shortening the label.++---++## Expert Level++### Technical Deep Dive++**The carry guard (Q10, Q15).** In `DuplicateReconciler.apply(_:to:types:)`+around line 1754:++```swift+let carryMayOverwrite =+ carriedReadingStatus != .catchingUp || row.readingStatus == .reading+```++The ordinary case never reaches it: `WorkAuthoredContent.isBare` asks for+`reading` and nothing else, so `catchingUp` is authored content and a group+disagreeing between it and a done status is classified as two variants and+routed to review before any write. The guard covers only the scan-to-write race,+where classification and write read the rows at two different moments. It is+deliberately **one-directional** (Q15): a `finished` carrier may still write+over a `catchingUp` row. Closing that would reduce the carry to "writes only+over the default", which also changes `finished` over `abandoned`, a convergence+change outside this feature.+`DuplicateReconcilerTests.catchingUpCarrierNeverOverwritesADoneSibling` drives+`reconcileToFixedPoint()` after the blocked pass, so the assertions describe a+resting state: the group stays divergent and goes to the reader.++**The pre-feature build (Q6, Q7).** An older build on the same iCloud account+reads `catchingUp` as `reading`, so a catch-up work with days reappears in its+releases section there and a Save writes `reading` back. Worse, its **export+refuses the whole library by name** while any row holds the value, background+export included, because writes are strict even though reads are tolerant.+Archives from the new build are likewise refused as malformed. Accepted for a+single-reader library; the mitigation is "update every device", and a stale+`Development` Mac counts, since both configurations mirror.++**Q17, the midnight tie.** Ember Road's entry is backdated to+`max(startOfDay(previousDate), previousDate - 60)`. Recent orders by instant and+breaks ties on the minted UUID, which is random per run, so seeding both at the+same instant would have made "Paper Lantern first" a coin toss the journey+asserts. The floor stops a launch in the first minute after midnight pushing the+row into the day before, which is all the `Last noted ... yesterday` line reads.++**The M4 fixture.** `seedM4ScheduleLayer` marks `catchingUp` on every base work+whose index is a multiple of 10 and **not** of the schedule stride, 66 of 1,000,+in the same walk that assigns release days. The exclusion makes the two layers+disjoint by construction rather than by ordering (index 0 is a multiple of both+and belongs to the schedule). The consequence reaches past the one measured arm:+`isBare` counts those 66 as authored, so the duplicate scan, the reconcile arms,+the settling pass and the backup arms now see 66 more non-bare works than+`update-schedule` measured, and their bands are unverified.++### Architecture Impact++No schema, migration, readiness marker or archive generation moves; the live+schema stays V15 and the archive pair 14/15. `BackupV14Types.swift` types+`readingStatus` as the enum, so export and import needed no code, only a+round-trip test. Both public inits took the new parameter **without a default**,+forcing every call site to be looked at. The edit-on-Save rule is untouched: the+note is read off drafts and nothing new writes before Save.++### Potential Issues++- **A torn `{catchingUp, reading}` group** shows the representative's status+ until the next reconcile trigger, so a work can be absent from `Catching up`+ until launch or arrival. True of every authored field, visible here.+- **The one-directional guard** lets a `finished` carrier flatten a `catchingUp`+ row inside the race window. The next scan calls the group divergent, so it+ surfaces rather than disappearing. Watch whether reading-status review cards+ become common.+- **Q14's stacked fallback** is exercised at largest Dynamic Type only.+- **The unmeasured M4 arms** are the thing to monitor: the next full+ `make test-performance-m4` may move on the duplicate and backup arms purely+ from the fixture change, and that must not be read as another branch's+ regression.+- `WorksSort.abandonedLast` still spells the abandoned-only rule inline,+ duplicating `WorksRowPresentation.isKnockedDown`, because a view model may not+ call a Views type. Both are pinned; they can still drift.++---++## Completeness Assessment++### Fully implemented++- **MUST 1, the status everywhere.** `DomainEnums.swift`;+ `WorkStatusPresentation.swift` (name, `forward.fill`, cyan `hue`,+ `Reading: Catching up`). Picker (`WorkDetailView.swift:1067`), filter menu+ (`WorksView.swift:355`), pill, duplicate card and merge preview read the table+ or `allCases`. Pinned by `ModelContractTests`, `WorkStatusPresentationTests`,+ `WorksFilterPresentationTests`, `DuplicateSurfaceTests`.+- **MUST 2, not done reading.** `isDone` unchanged, `verdictPrompt` nil,+ `isKnockedDown` abandoned-only. `WorkDetailModelTests` pins the absent+ precondition and prompt and Q16; `WorksListOptionsTests` pins sort and draw.+- **MUST 3, the section.** `TodayDayPlan.swift:395`, `TodayView.swift:446`;+ collation by `WorkSnapshot.titleOrder`; second line by the shared+ `row(for:on:calendar:)`; prefix at `TodayView.swift:1512`. Pinned by+ `catchUpRowsTakeTheReleaseLine` and `theRowPrefixesTellTheSectionsApart`.+- **MUST 4, past day and empty.** `day < currentDay ? []` and+ `if !plan.day.catchingUp.isEmpty`, pinned by `pastDaysCarryNoCatchUpRows`,+ `theSectionIsEmptyWithNothingToCatchUpOn` and+ `testAPastDayDrawsNoCatchUpSection`.+- **MUST 5, not scheduled, days kept.** The disjoint partition in+ `TodaySchedule.build` and `dueWeekdays` from the scheduled bucket only. Both+ halves pinned, including the working tree's new+ `catchUpDaysReturnWithTheReadingStatus`.+- **MUST 6, the note.** `WorkDetailModel.releaseDaysNote` and+ `ReleaseDaysText.catchingUpNote`, pinned by `WorkDetailModelTests` and, after+ the phase 2 review fix, by `testTheReleaseDaysNoteFollowsTheDraftStatuses`.+- **MUST 7, filter, empty library, wide layout.** The section sits inside the+ `showsDayChrome` branch and the non-chrome plan passes `catchingUp: []`; the+ empty-library branch keeps it (`theEmptyLibraryKeepsTheCatchUpSection`); the+ prune fix at `TodayView.swift:1098` plus+ `testACatchUpRowTakesTheDetailColumnAndADayChangeClearsIt`.+- **MUST 8, duplicate phase and archive.** `DuplicateScanTests` pins the silent+ fold and the divergent pair, `WorkMergePlannerTests` target-keeps and+ source-discarded, `BackupV14ArchiveTests.catchingUpRoundTrips` the 14/15 round+ trip.++### Partially implemented++- **MUST 9, the publication budget.** `todayPublication` measured 0.8397 s+ median against 2 s, with 66 catch-up works asserted in the schedule and on the+ current day outside the timed closure. But it is **one run on a host at load+ average 9.02**, and this suite's history says one run is not a baseline. Met+ on the evidence available; the evidence is thin.++### Missing++Nothing from the MUST list. Everything the smolspec placed out of scope+(automatic detection, strip dots, a past-day section, a badge, Stats, markdown+export, share sheet, generation bump, schema bump, the segmented control) is+correctly absent.++### Flags++- **Verification state.** `make test-core` green, zero known issues;+ `make test-quick` green (1,650 tests, `build-mac` included). `make test-ui`+ exited 2 with 3 failures, `make test-ui-ipad` exited 2 with 1. All four+ reproduce verbatim at `215c843` on `main`, in a worktree holding no branch+ code, and are recorded as unexplained rather than attributed. Two of them+ (`WorkDetailStatusUITests`) compare frames inside the Status card, which this+ feature did change, so the exoneration rests on the reproduction, not on an+ untouched surface.+- **The full `make test-performance-m4` was never run against the changed+ fixture.** The one real gap: 66 base works moved from bare to authored, so+ every arm below `todayPublication` has an unverified band. `CLAUDE.md` and+ `specs/OVERVIEW.md` both now say so.+- **A small design divergence.** The smolspec says the section can only be+ non-empty under `.releasesFirst`; the committed code drew it in both placement+ arms anyway, and the working tree removed the `.notedFirst` copy for a+ comment. Behaviour-identical, but the past-day rule now lives only in+ `TodayDayPlan.build`, with no view-level guard.+- **`TodaySectionAccents.catchingUp` became a `static let`** in the working+ tree, no longer reachable through an instance. Req 5.7's rule is still in one+ place, but a reader looking beside `releases` and `noted` finds a constant+ rather than a computed answer.+- **The working tree's refactors** (`releaseRows`, the `visibleWorks` set, the+ single-walk `TodaySchedule.build` and M4 seeder) are review-driven and carry+ no behaviour change. They are the largest and most skimmable part of the+ uncommitted diff.
diff --git a/specs/catch-up-mode/tasks.md b/specs/catch-up-mode/tasks.mdindex ac9bee8f..8a7ccc22 100644--- a/specs/catch-up-mode/tasks.md+++ b/specs/catch-up-mode/tasks.md@@ -7,54 +7,54 @@ references: ## Core -- [ ] 1. The catchingUp reading status exists and survives the archive <!-- id:4yhab01 -->+- [x] 1. The catchingUp reading status exists and survives the archive <!-- id:4yhab01 --> - Add the case to ReadingStatus in DomainEnums.swift, second after reading, raw value catchingUp; isDone stays false for it. ModelContractTests pins the four-value order and isDone. A BackupV14 test proves a work on catchingUp exports and imports unchanged under the 14/15 pair. - Verify: make test-core is green with no new warnings. -- [ ] 2. The duplicate phase treats catchingUp as authored content <!-- id:4yhab02 -->+- [x] 2. The duplicate phase treats catchingUp as authored content <!-- id:4yhab02 --> - DuplicateScan tests pin that a group of one catchingUp row and one reading row resolves silently to catchingUp, and that catchingUp against finished or abandoned is divergent and goes to review with readingStatus as the differing field. - In DuplicateReconciler.apply the carry guard lets a catchingUp carrier write only over a row on reading; a DuplicateReconcilerTests case drives it through reconcile afterScan as defaultCarrierNeverOverwritesASiblingStatus does. - Merge keeps the target status and lists a differing source catchingUp as discarded, pinned in the existing WorkVariantUnion or WorkMergePlanner tests. - Verify: make test-core. - Blocked-by: 4yhab01 (The catchingUp reading status exists and survives the archive) -- [ ] 3. The Today schedule and day plan carry catch-up rows <!-- id:4yhab03 -->+- [x] 3. The Today schedule and day plan carry catch-up rows <!-- id:4yhab03 --> - TodaySchedule gains catchingUp, the works on that status in titleOrder, with the scheduled filter and dueWeekdays unchanged. TodayDayPlan gains catchingUp rows built through the same second-line rule, empty for a day before currentDay. Both public inits take the new parameter without a default. - TodayDayPlanTests cover: a catchingUp work with release days on the selected weekday appears under catchingUp only and adds no dot; a finished-status catch-up work is listed; a past day has none; the second line reads notedOnDay, lastNoted and notNotedYet as for a release row. - Verify: make test-core. - Blocked-by: 4yhab01 (The catchingUp reading status exists and survives the archive) -- [ ] 4. The M4 fixture holds catch-up works and the Today publication arm measures them <!-- id:4yhab04 -->+- [x] 4. The M4 fixture holds catch-up works and the Today publication arm measures them <!-- id:4yhab04 --> - seedM4ScheduleLayer marks catchingUp every base work whose index is a multiple of 10 and not of the schedule stride, 66 works, as m4FixtureCatchUpWorkCount. M4ScaleFixtureTests pins the count, the 334 scheduled works untouched, and that duplicate-fixture rows are never marked. The todayPublication arm asserts schedule.catchingUp.count equals the pin and the current-day plan has catch-up rows. - Verify: run that arm alone in release with -Xswiftc -DASTERISM_PERFORMANCE_TESTING and --no-parallel and record the number against the last update-schedule band in specs/catch-up-mode/verification-run.md; the full make test-performance-m4 is not required for this task. - Blocked-by: 4yhab03 (The Today schedule and day plan carry catch-up rows) ## App and fixtures -- [ ] 5. Every status surface shows Catching up <!-- id:4yhab05 -->+- [x] 5. Every status surface shows Catching up <!-- id:4yhab05 --> - ReadingStatusPresentation gains name Catching up, glyph forward.fill, verdict prompt nil, and its systemImage doc no longer claims glyph means done. The editor reading status control, the detail meta line, the Works row glyph and label, the filter menu row and its pill, the duplicate review status line and the merge preview all show the value through the existing tables. - abandonedLast and the abandoned knock-down stay abandoned-only, pinned by a WorksListOptions test that a catchingUp work sorts and draws as active. - Verify: make test-quick. - Blocked-by: 4yhab01 (The catchingUp reading status exists and survives the archive) -- [ ] 6. The release-day note tells a catching-up reader the days are parked <!-- id:4yhab06 -->+- [x] 6. The release-day note tells a catching-up reader the days are parked <!-- id:4yhab06 --> - While at least one release day is toggled and the draft reading status is catchingUp, the line under the toggles reads the Q9 wording from decision_log.md; the existing line keeps its own condition for every other status. WorkDetailModel tests cover both wordings and the absent case. - Verify: make test-quick. - Blocked-by: 4yhab05 (Every status surface shows Catching up) -- [ ] 7. The Today screen draws the Catching up section <!-- id:4yhab07 -->+- [x] 7. The Today screen draws the Catching up section <!-- id:4yhab07 --> - TodayText gains the Catching up header. ReleaseRowView takes an identifier prefix, today-release- for release rows and today-catch-up- for catch-up rows, the line identifier following it. TodayView draws the section after the releases section with the violet accent and identifier today-section-catching-up, nothing at all when empty. TodayDisplayPlan.visibility.works includes catch-up rows and the non-chrome branch passes an empty list. - TodayDisplayPlanTests pin the three-header current day, a future day with no noted section, the empty-library branch, and hidden-when-empty. - Verify: make test-quick. - Blocked-by: 4yhab03 (The Today schedule and day plan carry catch-up rows), 4yhab05 (Every status surface shows Catching up) -- [ ] 8. The today UI fixture carries a catch-up work and the journeys prove the section <!-- id:4yhab08 -->+- [x] 8. The today UI fixture carries a catch-up work and the journeys prove the section <!-- id:4yhab08 --> - seedTodayWork takes a readingStatus and seedTodayFixture adds Ember Road, slug ember-road, chapter 4, work status finished, catchingUp, no release days, its entry backdated to the previous day. TodayFixtureSeedingTests pins four works, entries and days with only the three scheduled-shape works on reading. - TodayUITests: the current day shows the Catching up section with Ember Road reading Last noted and yesterday, the previous day shows no such section and its noted rows are two with Paper Lantern first, releaseRows counts unchanged. AccessibilityJourneyUITests adds work-detail-reading-status-catchingUp to the 44 pt segment list. WideLayoutUITests: a catch-up row selects its work into the detail column and a day change clears it. - Verify: make test-ui and make test-ui-ipad. - Blocked-by: 4yhab07 (The Today screen draws the Catching up section) -- [ ] 9. Changelog entry and a clean pass of every suite <!-- id:4yhab09 -->+- [x] 9. Changelog entry and a clean pass of every suite <!-- id:4yhab09 --> - Add an Added entry under Unreleased in CHANGELOG.md naming the status and the Today section. - Verify: make test-core, make test-quick, make test-ui and make test-ui-ipad all exit 0, with a clean make build-mac if SKIP_MAC was used anywhere. - Blocked-by: 4yhab02 (The duplicate phase treats catchingUp as authored content), 4yhab04 (The M4 fixture holds catch-up works and the Today publication arm measures them), 4yhab06 (The release-day note tells a catching-up reader the days are parked), 4yhab08 (The today UI fixture carries a catch-up work and the journeys prove the section)
diff --git a/specs/catch-up-mode/verification-run.md b/specs/catch-up-mode/verification-run.mdnew file mode 100644index 00000000..08a15789--- /dev/null+++ b/specs/catch-up-mode/verification-run.md@@ -0,0 +1,190 @@+# Verification Run: Catch-Up Mode++The measurement evidence for task 4, recorded here rather than in `tasks.md`,+which `rune` owns.++**Date**: 2026-09-17+**Commit**: `a442e7c` (`feature/catch-up-mode`), worktree+`.claude/worktrees/catch-up-mode`, with task 4's fixture and arm changes in the+working tree.+**Host**: the project machine — `Mac13,1` (Apple M1 Max), macOS 26.5.1.+**Host only.** No device target was run: `make test-performance-m4-recent`,+`make install`, `make run` and every `xcrun devicectl` invocation touch the+owner's phone, and the Mac app is a device target too. Nothing here opened,+launched or installed it (`CLAUDE.md`).++**One arm, not the target.** The full `make test-performance-m4` is ~21 minutes+and was not run; task 4 asks for the one arm this feature changes. The other+arms were **not re-measured**, and the fixture they share did move: 66 base+works are now on `catchingUp`, which `WorkAuthoredContent.isBare` counts as+authored, so the duplicate scan, the reconcile arms, the settling pass and the+backup arms all see a fixture with 66 more non-bare works than `update-schedule`+measured. Their bands are unverified against that fixture until the next full+quiet-host run, which is owed and not part of this feature's tasks.++**One run is not a baseline.** `CLAUDE.md` says so and this suite's history says+so — `percentile(of:)` returns the second-slowest of twenty samples, so one+scheduling hiccup sets the recorded value. What follows is one run, read against+`update-schedule`'s three.++## The arm++```+swift test --package-path Packages/AsterismCore --no-parallel -c release \+ -Xswiftc -DASTERISM_PERFORMANCE_TESTING --filter 'todayPublication'+```++with `ASTERISM_RUN_PHYSICAL_PERFORMANCE=1`, which is what the Makefile's+`test-performance-m4` target sets. `--no-parallel` is load-bearing+(`docs/agent-notes/testing.md`).++Build 451 s, test 30.6 s, `EXIT=0`, no issues.++**The host was not quiet.** One-minute load average 9.02 at the end of the run,+the release build of the test bundle having just finished. That makes the number+below a *ceiling* on what the arm costs on this host rather than a floor, which+is the safe direction for a reading that has to stay under a budget.++## What it measured++`ASTERISM-PERF todayPublication median=0.839720s p95=0.865084s min=0.815428s+max=0.866726s spread=1.06x n=20`++| Arm | This run | `update-schedule` Run 1 | Run 2 | Run 3 | Budget | Verdict |+|---|---|---|---|---|---|---|+| `todayPublication` | **0.839720 s** | 0.879084 s | 0.803231 s | 0.860354 s | 2 s (`todayPublishBudget`) | **in budget**, at 42% of it |++p95 was 0.865084 s against `update-schedule`'s 0.964044 s / 0.884 s / 0.929 s,+so the `CONTROLLED=1` reading would have passed too had it been asked for.++**The band did not move.** 0.839720 s sits inside `update-schedule`'s+0.803231–0.879084 s, 4.5% below the high end and 4.5% above the low one — which+is less than the 9.4% those three runs of unchanged code spread across between+themselves. The spread within this run's own twenty samples was 1.06×.++**And the catch-up rows are inside the interval.** The arm now builds a schedule+carrying 66 catch-up works beside the 334 scheduled ones, and the current day's+plan carries 66 catch-up rows — both asserted outside the timed closure, for the+reason the scheduled count is (a section that is empty costs nothing and proves+nothing). The measured cost of adding them is nothing this host's own variance+can distinguish from zero, which is what `update-schedule` Q28 predicts: the+schedule and the day plans are pure derivations over DTOs already in memory, and+they sit behind a store read that dominates them.++No budget was moved, and none needed to be.++## What was not run++- `make test-performance-m4` in full — not required by task 4, ~21 minutes.+- Every UI and simulator target — phase 2's, under tasks 5 to 9.+- `make build-mac` is clean only once task 5 lands: the three exhaustive+ switches over `ReadingStatus` in `WorkStatusPresentation.swift` are that+ task's, and the app target does not compile until they carry the fourth case.+ Confirmed by patching the three arms in locally, building clean, and reverting+ — so nothing else in the app target is waiting on the Core phase.++---++# Phase 2: the suites (task 9)++**Date**: 2026-09-17+**Commit**: `7b09d8c` (`feature/catch-up-mode`), the same worktree.+**Host**: the project machine — `Mac13,1` (Apple M1 Max), macOS 26.5.1.+**Simulator and host only.** No device target was run, nothing `Personal` was+built, installed or launched.++| Target | Exit | Result |+|---|---|---|+| `make test-core` | **0** | Whole package green, **zero known issues** — the state `CLAUDE.md` expects with no schema window open |+| `make test-quick` | **0** | 1,650 tests, including `build-mac`; no new warnings in any file this phase touched |+| `make test-ui` | 2 | **183 executed, 2 skipped, 3 failures**, 5,795 s — all three **pre-existing**, see below |+| `make test-ui-ipad` | 2 | **29 executed, 1 failure**, 560 s — **pre-existing**, see below |++`make test-quick` was run twice: the first attempt failed+`AppLifecycleTests.localeChangeIsATemporalChange`, a 700 ms debounce assertion,+while a second Xcode build had the machine at load. It passed alone, and the+suite is in the load-flaky family `docs/agent-notes/testing.md` describes.+`ComposedTeachingViewModelTests.effectiveRuleFollowsSuggestionOnTaughtSide`+failed once earlier the same way and is named in that section by name.++## The four failures are older than this branch++Every one of them reproduces at **`215c843`** — the `update-schedule` merge on+`main`, this branch's ancestor — measured today, on this host, in a separate+worktree, with no branch code in it:++| Case | On the branch | At `215c843` |+|---|---|---|+| `WorkDetailStatusUITests.testTheStoredScheduleShowsOnTheMetaLineAndCommitsFromTheToggles` | `XCTAssertGreaterThan failed: ("12.0") is not greater than ("22.0")` | the same, verbatim |+| `WorkDetailStatusUITests.testCancellingTheEditorDiscardsTheToggledReleaseDays` | the same assertion | the same, verbatim — run there on its own, `XCTAssertGreaterThan failed: ("12.0") is not greater than ("22.0") - Req 2.1: the item draws its abbreviations beside the glyph, not the glyph alone`, 27.3 s |+| `AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType` | `XCTAssertTrue failed - Recent lists a taught chapter` | the same, verbatim |+| `WideLayoutUITests.testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane` | `XCTAssertTrue failed - The name switches the window to the Works pane` | the same, verbatim |++The first two are deterministic — 3 of 3 on the branch, including on a freshly+booted simulator — and **what clears the branch is the reproduction at+`215c843`, not an untouched surface.** The `WorkDetailStatusUITests` pair is on+a surface this feature *did* change: both cases compare frames inside the Status+card (`monday.frame.minY > verdictField.frame.maxY - 1`), which is exactly where+the fourth reading-status segment lands and where Q14's stacked fallback can+change the card's height. The failing assertion is a different one — the meta+line's `Label` in view mode, over the untouched `seeded-works-options` fixture —+and the other two walk `seeded-composed`, which this feature does not seed into;+but those are arguments, and the runs at `215c843` are measurements. All four+fail the same way in a worktree with no branch code in it. **They are+unexplained, not attributed**:+`specs/update-schedule/verification-run.md` §10 records all of these suites green+on 2026-09-16, so something between that run and this one moved — the host is+the only difference the branch does not account for. Worth its own bugfix spec;+it is not this feature's.++The branch's **phase-1 tip (`b607504`) does not compile**, so it offers no+comparison point: task 5's three switches are what make the app target build+again. That is by design and is recorded above.++## What this feature's own journeys did++All green, first time, in the runs above:++- `TodayUITests` **21/21** — `testTheCurrentDayLeadsWithItsReleases` (the+ `Catching up` header between the releases and the notes, one row reading+ `Ember Road, Last noted … yesterday`, release rows still 1),+ `testAPastDayDrawsNoCatchUpSection`, and `testAPastDayLeadsWithItsNotes` with+ its two noted rows.+- `WideLayoutUITests.testACatchUpRowTakesTheDetailColumnAndADayChangeClearsIt`+ — 17.8 s, green.+- `AccessibilityJourneyUITests.testTheStatusControlsAndDialogStayOperableAtLargestDynamicType`+ — green with the fourth reading-status segment in its list (Q14's stacked+ fallback did not cost the segment its 44 pt target).+- `WorkDetailStatusUITests.testTheReleaseDaysNoteFollowsTheDraftStatuses` —+ green. It did **not** cover Q9's wording in the run above: it only moved the+ *work* status, so it asserted the existing line and never reached the+ catching-up arm, and this section originally claimed otherwise. The journey+ now takes the reading status to `Catching up` with a day on, asserts the line+ reads `Release days apply again once you're back to Reading`, and takes it+ back to `Reading` to see the first wording return. Re-run on its own after+ that change: **green, 33.3 s, `EXIT=0`** (same host, same simulator). Q9's+ wording is covered by a journey from here on; before this it was the model+ test only.++## Re-run after the phase 2 review++The review's code fixes touched the catch-up journey, the catch-up header's+accent, one comment and one unit-test pin. Re-run on the same host and+simulator, on this branch:++| Target | Exit | Result |+|---|---|---|+| `make test-quick` | **0** | green, including `build-mac` |+| `make test-only TEST=AsterismUITests/WorkDetailStatusUITests/testTheReleaseDaysNoteFollowsTheDraftStatuses` | **0** | 1 executed, 0 failures, 33.3 s — the journey with Q9's wording in it |+| `make test-only TEST=AsterismUITests/TodayUITests/testTheCurrentDayLeadsWithItsReleases` | **0** | 1 executed, 0 failures, 16.4 s — the catch-up header still draws after the accent moved into `TodaySectionAccents` |++The accent is unchanged in what it renders — violet either way — so the second+run is a check that routing it through the type did not change the header, not+a new assertion.++## What was not run++- `make test-performance-m4` — phase 1 measured the one arm this feature moves+ ([above](#the-arm)); the full target is ~21 minutes and task 9 does not ask+ for it.+- Anything on a device, of either configuration.
All four reproduce at 215c843 in an untouched worktree, one by one through make test-only. Two compare frames inside the Status card, where the fourth segment lands, so the exoneration rests on that reproduction alone. See specs/catch-up-mode/verification-run.md, phase 2, and the new section in docs/agent-notes/testing.md.
66 base works are now on catchingUp, which WorkAuthoredContent.isBare counts as authored. Only todayPublication was re-measured (0.84 s against 2 s, on a host at load 9). The duplicate scan, reconcile, settling and backup arms have not been run since.
A finished or abandoned carrier may still write over a catchingUp row in the scan-to-write race, as it may over the other done state today. Q15 records why the reverse was left; the next scan routes such a group to review.
A device on an older build reads catchingUp as reading, may write it back on Save, and refuses to export the library by name (Q6, Q7). Both configurations mirror, so a stale Development Mac counts.