From 06d53488a2bb2d5fc4bcc0e56d835fb5d68fa357 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:51:01 +0000 Subject: [PATCH 1/4] Make the outstanding-issues snapshot merge-safe (v2): date the ledger revision, drop the sha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of #Y090R5. The `pending` half was fixed already — the committed artefact carries an empty `pending` and only `prebuild` asks for the live list. `ledger_revision` is what was left, and it is the only field in this file measured changing as a side effect of unrelated work: PR #2690 ("calculators: remove directive clinical copy"), which touches no ledger file, rewrote exactly those two lines and nothing else here. The churn is structural, not occasional. A reconciliation regenerates the snapshot and then becomes the ledger's newest commit, so the value it just wrote is stale the instant it lands — which is why the gate excludes `ledger_revision` from comparison in the first place. Every later branch that regenerates (pre-commit doc sync, docs:update, next build) rewrites the field with whichever ledger commit ITS base carries. Two branches cut either side of a reconciliation write different values into adjacent lines at the top of the file. Excluding a field from the gate never stopped it conflicting in git, because the bytes still shipped. Proven by simulating that exact sequence — a ledger commit, one branch cut before it and one after, each regenerating during its own unrelated commit: BEFORE (v1: sha + timestamp): CONFLICT (content) in data/outstanding-issues-snapshot.json AFTER (v2: date only): CLEAN MERGE The sha goes because nothing reads it: `resolveFreshness` uses `committed_at` alone. The timestamp coarsens to a day, so two branches regenerating on the same day write identical bytes — and same-day is the measured case, the two commits behind #2690's rewrite being 35 minutes apart. Branches a day apart still differ, which is deliberate residue: freshness is the one value here a reader cannot recompute. `counts` is deliberately NOT removed, unlike its repo-awareness sibling. It is derived from the canonical ledger alone, and ledger edits are serial by policy (one reconciliation branch), so it is not a contended surface — 9 commits in 60 days, all but one of them reconciliations. Removing it would be a larger change than the evidence asks for. Same device and reasoning as `captured_revision` in repo-awareness-snapshot-v3, which closed the identical defect in the sibling file. Version bumped to outstanding-issues-snapshot-v2 so a stale v1 snapshot fails `loadLedgerSnapshot` loudly rather than rendering a shape the reader no longer expects. `readCommittedRevision` normalises a v1 revision it finds on disk, because the preserve-instead-of-read path is taken by the production image (no `.git`) and would otherwise write the conflicting v1 shape straight back into a v2 file — pinned by a new test. No test deleted, skipped or quarantined. Four fixtures moved to the v2 shape; one test added for the normalisation above. Verification: - npm run test: 1269 files, 18067 passed, exit 0 - npx tsc -p tsconfig.json --noEmit: exit 0 - eslint clean on every changed path; npm run format committed - node scripts/check-outstanding-issues-snapshot.mjs: in step (121 open, 0 pending) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmcnnSgi8sxy7dQipsYCWG --- data/outstanding-issues-snapshot.json | 5 +- .../generate-outstanding-issues-snapshot.mjs | 51 ++++++++++++++++--- src/lib/developer-area/ledger-snapshot.ts | 7 ++- ...developer-clinical-answer-failures.test.ts | 2 +- tests/developer-ledger-snapshot.test.ts | 4 +- .../outstanding-issues-snapshot-gate.test.ts | 22 ++++---- tests/outstanding-issues-snapshot.test.ts | 27 +++++++++- 7 files changed, 94 insertions(+), 24 deletions(-) diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json index 7cd433474c..6f2b7020e6 100644 --- a/data/outstanding-issues-snapshot.json +++ b/data/outstanding-issues-snapshot.json @@ -1,8 +1,7 @@ { - "version": "outstanding-issues-snapshot-v1", + "version": "outstanding-issues-snapshot-v2", "ledger_revision": { - "sha": "4fe1131ebb326209fd5d7d379253de9ffb061ae2", - "committed_at": "2026-09-07T04:17:48+00:00" + "committed_at": "2026-09-07" }, "counts": { "open": 121, diff --git a/scripts/generate-outstanding-issues-snapshot.mjs b/scripts/generate-outstanding-issues-snapshot.mjs index e70f1dadb9..916f0c3e9f 100644 --- a/scripts/generate-outstanding-issues-snapshot.mjs +++ b/scripts/generate-outstanding-issues-snapshot.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url"; const LEDGER_PATH = "docs/outstanding-issues.md"; const INBOX_DIR = "docs/outstanding-issues-inbox"; const OUTPUT_PATH = "data/outstanding-issues-snapshot.json"; -export const SNAPSHOT_VERSION = "outstanding-issues-snapshot-v1"; +export const SNAPSHOT_VERSION = "outstanding-issues-snapshot-v2"; // Reuse the repo's escape-aware splitter. The ledger contains 8 escaped pipes // (`\|`); a naive `line.split("|")` turns each into a column boundary and the @@ -167,12 +167,45 @@ export function readInboxRecords(dir = INBOX_DIR) { .map((entry) => JSON.parse(readFileSync(join(dir, entry.name), "utf8"))); } +/** + * A DATE, and no sha. This field is the only part of this snapshot measured + * changing as a side effect of another branch's work: PR #2690 ("calculators: + * remove directive clinical copy"), which touches no ledger file, rewrote + * exactly these two lines and nothing else here. + * + * The churn is structural rather than occasional. A reconciliation commit + * regenerates the snapshot and then becomes the ledger's newest commit, so the + * value it just wrote is stale the instant it lands — as the gate's own comment + * explains, which is why `ledger_revision` is excluded from comparison. Every + * later branch that regenerates (pre-commit doc sync, `docs:update`, `next + * build`) therefore rewrites it, stamping whichever ledger commit ITS base + * carries. Two branches cut either side of a reconciliation write different + * values into adjacent lines at the top of the file: a merge conflict in a file + * neither branch was editing. Excluding a field from the gate never stopped it + * conflicting in git, because the bytes still shipped. + * + * The sha is dropped because nothing reads it: `resolveFreshness` uses + * `committed_at` alone. Coarsening to a day makes two branches regenerating on + * the same day write identical bytes — and same-day is the measured case, the + * two commits behind #2690's conflict being 35 minutes apart. Branches a day + * apart still differ, which is deliberate residue: freshness is the one value + * here a reader cannot recompute. + * + * Same device and same reasoning as `captured_revision` in + * `repo-awareness-snapshot-v3`, which closed the identical defect in the + * sibling file. + */ +function toRevisionDate(committedAt) { + const match = /^(\d{4}-\d{2}-\d{2})/u.exec(committedAt); + return match ? match[1] : null; +} + export function readLedgerRevision(path = LEDGER_PATH) { try { - const output = execFileSync("git", ["log", "-1", "--format=%H%x09%cI", "--", path], { encoding: "utf8" }).trim(); + const output = execFileSync("git", ["log", "-1", "--format=%cI", "--", path], { encoding: "utf8" }).trim(); if (!output) return null; - const [sha, committed_at] = output.split("\t"); - return { sha, committed_at }; + const committed_at = toRevisionDate(output); + return committed_at ? { committed_at } : null; } catch { return null; } @@ -188,8 +221,14 @@ export function readLedgerRevision(path = LEDGER_PATH) { export function readCommittedRevision(path = OUTPUT_PATH) { try { const revision = JSON.parse(readFileSync(path, "utf8")).ledger_revision; - if (typeof revision?.sha !== "string" || typeof revision?.committed_at !== "string") return null; - return { sha: revision.sha, committed_at: revision.committed_at }; + if (typeof revision?.committed_at !== "string") return null; + // Normalised rather than taken verbatim: a v1 file on disk carries a full + // timestamp, and preserving that shape would write v1 bytes back into a v2 + // file on the one path that preserves instead of reading git — the + // production image, which has no `.git`. Truncating makes the preserved + // value the same shape as a fresh read. + const committed_at = toRevisionDate(revision.committed_at); + return committed_at ? { committed_at } : null; } catch { return null; } diff --git a/src/lib/developer-area/ledger-snapshot.ts b/src/lib/developer-area/ledger-snapshot.ts index 4cd3f49601..6f07291782 100644 --- a/src/lib/developer-area/ledger-snapshot.ts +++ b/src/lib/developer-area/ledger-snapshot.ts @@ -2,7 +2,7 @@ import snapshotJson from "../../../data/outstanding-issues-snapshot.json"; import { resolveFreshnessFrom, type Freshness } from "./freshness"; -export const LEDGER_SNAPSHOT_VERSION = "outstanding-issues-snapshot-v1"; +export const LEDGER_SNAPSHOT_VERSION = "outstanding-issues-snapshot-v2"; export type LedgerPriority = "P1" | "P2" | "P3"; @@ -36,7 +36,10 @@ export type LedgerPendingRequest = { export type LedgerSnapshot = { version: string; - ledger_revision: { sha: string; committed_at: string } | null; + // A date (`2026-09-07`), not a timestamp, and no sha — `readLedgerRevision` in + // `scripts/generate-outstanding-issues-snapshot.mjs` carries the reasoning. + // Only `committed_at` was ever read, by `resolveFreshness` below. + ledger_revision: { committed_at: string } | null; counts: { open: number; p1: number; p2: number; p3: number; queued: number; pending: number; resolved: number }; queue: LedgerQueueEntry[]; open: LedgerOpenItem[]; diff --git a/tests/developer-clinical-answer-failures.test.ts b/tests/developer-clinical-answer-failures.test.ts index 40b973093e..3854db96aa 100644 --- a/tests/developer-clinical-answer-failures.test.ts +++ b/tests/developer-clinical-answer-failures.test.ts @@ -28,7 +28,7 @@ function item(overrides: Partial = {}): LedgerOpenItem { function snapshotOf(open: LedgerOpenItem[]): LedgerSnapshot { return { - version: "outstanding-issues-snapshot-v1", + version: "outstanding-issues-snapshot-v2", ledger_revision: null, counts: { open: open.length, p1: 0, p2: 0, p3: 0, queued: 0, pending: 0, resolved: 0 }, queue: [], diff --git a/tests/developer-ledger-snapshot.test.ts b/tests/developer-ledger-snapshot.test.ts index 99885f136b..bc87ffd02f 100644 --- a/tests/developer-ledger-snapshot.test.ts +++ b/tests/developer-ledger-snapshot.test.ts @@ -4,7 +4,7 @@ import { loadLedgerSnapshot, openItemsByPriority, resolveFreshness } from "@/lib describe("ledger snapshot", () => { it("loads the generated snapshot and validates its version", () => { const snapshot = loadLedgerSnapshot(); - expect(snapshot.version).toBe("outstanding-issues-snapshot-v1"); + expect(snapshot.version).toBe("outstanding-issues-snapshot-v2"); expect(snapshot.counts.open).toBeGreaterThan(0); }); @@ -17,7 +17,7 @@ describe("ledger snapshot", () => { it("reports a gap between ledger content and build", () => { const snapshot = { ...loadLedgerSnapshot(), - ledger_revision: { sha: "a".repeat(40), committed_at: "2026-08-20T00:00:00Z" }, + ledger_revision: { committed_at: "2026-08-20" }, }; const freshness = resolveFreshness(snapshot, new Date("2026-08-21T00:00:00Z")); expect(freshness.ageHours).toBe(24); diff --git a/tests/outstanding-issues-snapshot-gate.test.ts b/tests/outstanding-issues-snapshot-gate.test.ts index f810bbcdb2..41c7f9d7cd 100644 --- a/tests/outstanding-issues-snapshot-gate.test.ts +++ b/tests/outstanding-issues-snapshot-gate.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { compareSnapshots } from "../scripts/check-outstanding-issues-snapshot.mjs"; const BASE = { - version: "outstanding-issues-snapshot-v1", - ledger_revision: { sha: "a".repeat(40), committed_at: "2026-08-20T00:00:00Z" }, + version: "outstanding-issues-snapshot-v2", + ledger_revision: { committed_at: "2026-08-20" }, counts: { open: 2, p1: 1, pending: 0 } as Record, queue: [{ order: 1, ids: ["#1"] }], open: [{ id: "#1" }, { id: "#2" }], @@ -46,14 +46,18 @@ describe("compareSnapshots", () => { expect(compareSnapshots(old, BASE).join(" ")).toMatch(/version/); }); - // The regression this test exists for: `ledger_revision` is the sha of the - // commit that last touched the ledger, so committing a ledger edit changes it - // as a side effect. Comparing it made the gate fail on every ledger change - // with nothing stale, which would turn `main` red after each squash merge. + // The regression this test exists for: `ledger_revision` dates the commit + // that last touched the ledger, so committing a ledger edit changes it as a + // side effect. Comparing it made the gate fail on every ledger change with + // nothing stale, which would turn `main` red after each squash merge. + // + // v2 narrowed the field to a date, which is what stops two branches + // conflicting on it in git — but the gate still must not compare it, because + // branches a day apart legitimately differ here. it("ignores a differing ledger_revision, which changes as a side effect of committing", () => { - const differentSha = structuredClone(BASE); - differentSha.ledger_revision = { sha: "b".repeat(40), committed_at: "2026-08-21T00:00:00Z" }; - expect(compareSnapshots(differentSha, BASE)).toEqual([]); + const differentDate = structuredClone(BASE); + differentDate.ledger_revision = { committed_at: "2026-08-21" }; + expect(compareSnapshots(differentDate, BASE)).toEqual([]); }); it("still detects drift in queue, not just open", () => { diff --git a/tests/outstanding-issues-snapshot.test.ts b/tests/outstanding-issues-snapshot.test.ts index 808f3e2463..c23653736e 100644 --- a/tests/outstanding-issues-snapshot.test.ts +++ b/tests/outstanding-issues-snapshot.test.ts @@ -45,7 +45,8 @@ const INBOX = [ }, ]; -const REVISION = { sha: "a".repeat(40), committed_at: "2026-08-20T09:14:00Z" }; +// v2 shape: a date, no sha. `readLedgerRevision` explains why the sha went. +const REVISION = { committed_at: "2026-08-20" }; describe("buildSnapshot", () => { it("parses both ID schemes and never drops alphanumeric ids", () => { @@ -295,6 +296,30 @@ describe("ledger revision when git cannot be read", () => { expect(snapshot.ledger_revision).toEqual(REVISION); }); + it("normalises a v1 committed revision to a date, so the preserve path cannot reintroduce the sha", () => { + // The one path that PRESERVES rather than re-reads git is the production + // image, which has no `.git`. A v1 file on disk there still carries a sha + // and a full timestamp, and carrying that through verbatim would write the + // conflicting v1 shape straight back into a v2 file — silently, in the only + // environment that takes this branch. + writeFileSync( + snapshotPath, + JSON.stringify({ + version: SNAPSHOT_VERSION, + ledger_revision: { sha: "a".repeat(40), committed_at: "2026-08-20T09:14:00Z" }, + counts: {}, + queue: [], + open: [], + pending: [], + }), + "utf8", + ); + + const snapshot = generate({ ledgerPath, inboxDir, snapshotPath }); + expect(snapshot.ledger_revision).toEqual({ committed_at: "2026-08-20" }); + expect(snapshot.ledger_revision).not.toHaveProperty("sha"); + }); + it("records null when there is no committed snapshot to preserve from", () => { // Fail-safe in the honest direction: no revision anywhere means the page // says it does not know, which is true. From 274f2ac96ec3848802c646f874dc0fc21c59591a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:51:57 +0000 Subject: [PATCH 2/4] issues: record the measured assessment of the Ward Flow and flaky-spec rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows filed on 2026-09-06 were re-assessed against origin/main 0177bed rather than fixed, because measurement showed neither needs the fix its row implies. #KHTTW4 (Ward Flow): the eight failures are gone — 71 passed, 3 skipped, 0 failed. The path-scoped blind spot the row exists for is designed out, the lane now gating on ui_changed rather than ward paths, but it is held inert behind vars.WARD_JOURNEYS_BLOCKING until somebody has a green run in hand. That precondition is now met and the run is recorded, so the one remaining action is a repository-settings toggle no PR can perform. #RA0QAH (flaky Production UI specs): the standing worry its own text raised — that the Next 16 prefetch-header trap might sit elsewhere — is closed by a repository-wide grep returning only the already-fixed site. The other three failures stay unexplained, and deliberately unfixed: the row never named the specs, the CI runs are past useful retention, and a fix without a reproduction is a guess that risks quarantining something real. Queued as merge-safe inbox requests; reconcile after this lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmcnnSgi8sxy7dQipsYCWG --- .../52d30987-38d0-4831-803d-6cb4c785aab7.json | 12 ++++++++++++ .../9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 docs/outstanding-issues-inbox/52d30987-38d0-4831-803d-6cb4c785aab7.json create mode 100644 docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json diff --git a/docs/outstanding-issues-inbox/52d30987-38d0-4831-803d-6cb4c785aab7.json b/docs/outstanding-issues-inbox/52d30987-38d0-4831-803d-6cb4c785aab7.json new file mode 100644 index 0000000000..2c9cf65a04 --- /dev/null +++ b/docs/outstanding-issues-inbox/52d30987-38d0-4831-803d-6cb4c785aab7.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "52d30987-38d0-4831-803d-6cb4c785aab7", + "createdOn": "2026-09-07", + "action": "update", + "payload": { + "id": "#RA0QAH", + "detail": "GENERIC HALF CLOSED BY MEASUREMENT 2026-09-07; the row stays open only for three unexplained specs. This row's fixed member left a standing worry in its own text: that the Next 16 prefetch-header trap is generic to any Playwright assertion trying to tell a prefetch from a navigation, and was worth grepping for. GREPPED, on origin/main 0177bed: grep -rn 'next-router-prefetch' over tests/, src/ and scripts/ returns two hits, both inside the already-fixed tests/ui-smoke.spec.ts:1354 block (its explanatory comment and the corrected guard). There is no second occurrence anywhere in the repository, so that specific trap is not lurking elsewhere and no further code fix follows from it. WHAT REMAINS UNEXPLAINED: the other three single-spec Production UI failures of 2026-09-02. This row never named them, and the only pointers are CI runs 33610490607 and 33613433031, which are past the useful retention window for reading logs. With no spec identities and no reproduction, any fix would be a guess, and guessing at flakiness is how a real failure gets quarantined by accident. RECOMMENDATION: leave open at P3 as a watch item, not a work item. If a fourth single-spec Production UI failure appears, capture the spec identity and the run URL AT THAT MOMENT - that is the missing input - then reproduce with --repeat-each on the same SHA before touching anything. Do not quarantine; tests/flake-ledger.json still holds one entry and it is not one of these.", + "source": "Assessment session 2026-09-07: repository-wide grep on origin/main 0177bed; no provider access", + "baseRowFingerprint": "11306c58889703ac51bed66d1499c6228814fc4e21c4905cd4f2b9bd437d2062" + } +} diff --git a/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json b/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json new file mode 100644 index 0000000000..87b78023eb --- /dev/null +++ b/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "id": "9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27", + "createdOn": "2026-09-07", + "action": "update", + "payload": { + "id": "#KHTTW4", + "detail": "ASSESSED AND LARGELY OVERTAKEN 2026-09-07, on a fresh worktree at origin/main 0177bed. FIRST HALF IS FIXED, NOT BY THIS ROW: npm run test:e2e:ward-journeys now reports 71 passed, 3 skipped, 0 failed. The eight failures this row recorded are gone. Two of the three skips are the ui-ward-morning pair, and their skip is a documented owner-approved decision rather than a silencing: MERGE 02 (2026-09-05) folded the morning board into CapacityScreen, /mockups/ward-flow/morning is now a redirect stub, MorningPage is unmounted, and morning-page.tsx's own doc comment forbids retargeting the spec at CapacityScreen or re-mounting the component pending the owner's ruling on spec D9. Component-level coverage continues in tests/ward-morning-page.dom.test.tsx (20 cases) and tests/ward-morning-print.test.ts. SECOND HALF IS BUILT BUT INERT, AND THAT IS THE ONLY REMAINING ACTION. The blocking lane ui-ward-journeys in .github/workflows/ci.yml no longer scopes on ward paths - it now gates on needs.changes.outputs.ui_changed - so the path-scoped blind spot this row named is designed out. But the job also requires vars.WARD_JOURNEYS_BLOCKING == 'true', which is unset by default, so it is skipped and pr-required records it skipped-and-fine. That inertness is deliberate and documented in the workflow: a lane that is already red, made blocking, stops every UI pull request in the repository, so the author required one deliberate act in repository settings at the moment somebody has a green run of the journeys in front of them. THAT PRECONDITION IS NOW MET and the green run is quoted above. NEXT ACTION, owner only: set repository variable WARD_JOURNEYS_BLOCKING to true. It is a GitHub settings change, not a code change, so no PR can do it. Close this row once it is set and one PR has shown the lane reporting.", + "source": "Assessment session 2026-09-07: fresh origin/main worktree, local chromium-mockups run, and a read of ci.yml plus ui-ward-morning.spec.ts", + "baseRowFingerprint": "119605ea523b039ea176fbbc0860e20492b68c77669a199d3c341b9c7046dc3f" + } +} From 001f4c18e60a454627ae773acb362e4dd051744e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:14:36 +0000 Subject: [PATCH 3/4] State a day-granularity revision as a day, instead of a fabricated clock time and a false hour count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review finding on this PR (P2), confirmed by reading the render path rather than taken on trust — and it is worse than reported: the same defect is already live on main for the repo-awareness snapshot, whose `captured_revision` has been a date since v3. One fix in FreshnessStamp covers both, because line 82 was the only snapshot-mode render of ageHours. Two false claims, both from parsing a date as midnight UTC: - `new Date("2026-09-07")` formatted with `timeStyle` in Australia/Perth renders "7 Sept 2026, 08:00 AWST". Nothing recorded 08:00. This component exists to stop confident-looking stamps carrying no information, so manufacturing one is squarely the failure it guards against. - `ageHours` measured from that midnight is wrong by however far into the day the commit actually was, up to a full day, so a ledger update can read as "24 hours old" minutes after landing. A date-only value now renders as a date, with no clock time and no zone, and ages in whole days. A full timestamp keeps hour precision, pinned by a test so this cannot quietly coarsen the live ingestion stamp. The day arithmetic is UTC on both sides, and correcting that was a genuine bug in my own first attempt, caught by the 23:59 test rather than by reading. The stored date comes from a commit's `%cI`, so it is a UTC calendar date; comparing it against the reader's Perth date mixes zones and adds a further day of error on top of the day already lost to rounding — 23:59 UTC is 07:59 next morning in Perth, which called a just-landed commit "1 day old". Rendering stays pinned to Perth like the rest of the component, and midnight UTC falls on the same calendar date there anyway. Four tests added: no fabricated clock time, the reported 23:59 case reading "same day", singular "1 day old", and hour precision retained for a real timestamp. No test deleted, skipped or quarantined. Verification: - npm run test: 1269 files, 18071 passed, exit 0 - npx tsc -p tsconfig.json --noEmit: exit 0 - eslint clean on both changed paths; npm run format committed Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KmcnnSgi8sxy7dQipsYCWG --- .../developer-area/hub/freshness-stamp.tsx | 61 ++++++++++++++++++- tests/developer-hub-components.dom.test.tsx | 49 +++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/components/developer-area/hub/freshness-stamp.tsx b/src/components/developer-area/hub/freshness-stamp.tsx index 2971314b9f..48489d3132 100644 --- a/src/components/developer-area/hub/freshness-stamp.tsx +++ b/src/components/developer-area/hub/freshness-stamp.tsx @@ -22,6 +22,14 @@ import type { Freshness } from "@/lib/developer-area/freshness"; function formatDate(iso: string): string | null { const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) return null; + // A day-granularity value has no clock time to state. Formatting one with + // `timeStyle` renders `2026-09-07` as "7 Sept 2026, 08:00 AWST" — midnight UTC + // dressed as a Perth wall-clock reading, which is a fabricated instant. This + // component exists to stop confident-looking stamps carrying no information, + // so it must not manufacture one itself. + if (isDateOnly(iso)) { + return parsed.toLocaleDateString("en-AU", { dateStyle: "medium", timeZone: "Australia/Perth" }); + } const formatted = parsed.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short", @@ -30,6 +38,56 @@ function formatDate(iso: string): string | null { return `${formatted} AWST`; } +/** + * Both committed snapshots now date their revision to the day rather than the + * second — `captured_revision` in `repo-awareness-snapshot-v3` and + * `ledger_revision` in `outstanding-issues-snapshot-v2` — because a full + * timestamp is a per-branch value that makes two branches conflict on a file + * neither is editing. + * + * That precision is genuinely gone, and the stamp has to say so rather than + * imply otherwise. `resolveFreshnessFrom` parses `2026-09-07` as midnight UTC, + * so an hour count taken from it is wrong by however far into that day the + * commit actually was — up to a full day for a late-evening UTC commit, always + * in the direction of reporting the content as older than it is. + */ +function isDateOnly(iso: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/u.test(iso); +} + +/** + * Whole days between a day-granularity content date and the moment of viewing, + * compared as calendar days rather than elapsed milliseconds, because a day is + * the only claim the stored value supports. + * + * Both sides are UTC days, and that pairing is the point. The generators derive + * the stored date from a git commit's own `%cI`, so it is a UTC calendar date; + * comparing it against the reader's PERTH date mixes two zones and adds up to a + * further day of error on top of the day already lost to rounding. A commit at + * 23:59 UTC is 07:59 the next morning in Perth, so a Perth-day comparison calls + * it a day old the instant it lands. Rendering stays pinned to Perth like the + * rest of this component; only the arithmetic is UTC, and midnight UTC falls on + * the same calendar date in Perth either way. + */ +function ageInDays(contentDay: string, viewedAtIso: string): number | null { + const viewed = new Date(viewedAtIso); + if (Number.isNaN(viewed.getTime())) return null; + const contentMs = Date.parse(`${contentDay}T00:00:00Z`); + const viewedMs = Date.parse(`${viewed.toISOString().slice(0, 10)}T00:00:00Z`); + if (!Number.isFinite(contentMs) || !Number.isFinite(viewedMs)) return null; + return Math.max(0, Math.round((viewedMs - contentMs) / 86_400_000)); +} + +function describeAge(freshness: Freshness): string { + if (freshness.contentAt !== null && isDateOnly(freshness.contentAt)) { + const days = ageInDays(freshness.contentAt, freshness.viewedAt); + if (days === null) return "age unknown"; + if (days === 0) return "same day"; + return days === 1 ? "1 day old" : `${days} days old`; + } + return `${freshness.ageHours} ${freshness.ageHours === 1 ? "hour" : "hours"} old`; +} + /** * Unconditional by design. There is no "fresh" short-circuit that could * suppress it — a page that can hide its own age is the `#338` defect. @@ -79,8 +137,7 @@ export function FreshnessStamp({ * whole job is stating age unambiguously. */} {label} content as of {contentAt} - {viewedAt ? ` · viewed ${viewedAt}` : ""} · {freshness.ageHours} {freshness.ageHours === 1 ? "hour" : "hours"}{" "} - old + {viewedAt ? ` · viewed ${viewedAt}` : ""} · {describeAge(freshness)} ) : isLive ? ( diff --git a/tests/developer-hub-components.dom.test.tsx b/tests/developer-hub-components.dom.test.tsx index edd1060e3a..1a33a78a60 100644 --- a/tests/developer-hub-components.dom.test.tsx +++ b/tests/developer-hub-components.dom.test.tsx @@ -87,6 +87,55 @@ describe("FreshnessStamp", () => { expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/\b1 hour old\b/); }); + /** + * Both committed snapshots now date their revision to the day rather than the + * second, because a full timestamp is a per-branch value that made two + * branches conflict on a file neither was editing + * (`repo-awareness-snapshot-v3`, `outstanding-issues-snapshot-v2`). + * + * The stamp must not dress that day up as an instant. `new Date("2026-08-20")` + * is midnight UTC, which a Perth formatter renders as "08:00 AWST" — a clock + * reading nothing recorded — and an hour count taken from it is wrong by + * however far into the day the commit actually was. + */ + it("states a day-granularity revision as a day, with no fabricated clock time", () => { + render(); + const stamp = screen.getByTestId("developer-hub-freshness"); + expect(stamp).toHaveTextContent(mediumDate("2026-08-20T00:00:00Z")); + // The content date carries no time and no zone. `viewedAt` is a real + // instant and keeps both, so AWST still appears — scope the assertion to + // the content half rather than the whole stamp. + expect(stamp.textContent).toMatch(/content as of [^·]*\d{4}\s*·/); + expect(stamp.textContent).not.toMatch(/content as of [^·]*\d{1,2}:\d{2}/); + }); + + it("ages a day-granularity revision in days, not in hours it cannot support", () => { + // The reported case, and the one a Perth-day comparison also gets wrong: a + // ledger commit late in a UTC day is stored as that day, and an hour count + // read from midnight then calls it a day old within minutes of landing. + // 23:59 UTC is 07:59 next morning in Perth, so the arithmetic must stay in + // UTC to match the zone the stored date came from. + render(); + const stamp = screen.getByTestId("developer-hub-freshness"); + expect(stamp).toHaveTextContent(/same day/); + expect(stamp).not.toHaveTextContent(/hours old/); + }); + + it("says '1 day old', not '1 days old'", () => { + // One UTC calendar day after the recorded content day. + render(); + expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/\b1 day old\b/); + }); + + it("keeps hour precision for a full timestamp, which still carries it", () => { + render( + , + ); + expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/24 hours old/); + }); + it("renders live status when freshness status is live", () => { render( Date: Sat, 12 Sep 2026 10:00:37 +0000 Subject: [PATCH 4/4] issues: supersede #KHTTW4 next-action after #2710 enables ward journeys Drop the instruction to set WARD_JOURNEYS_BLOCKING; #2710 enables ui-ward-journeys in ci.yml and removes that repo-var gate. --- .../9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json b/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json index 87b78023eb..37850d445a 100644 --- a/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json +++ b/docs/outstanding-issues-inbox/9e8f3e9a-0542-4c9b-b4ef-0263e73f5d27.json @@ -5,8 +5,8 @@ "action": "update", "payload": { "id": "#KHTTW4", - "detail": "ASSESSED AND LARGELY OVERTAKEN 2026-09-07, on a fresh worktree at origin/main 0177bed. FIRST HALF IS FIXED, NOT BY THIS ROW: npm run test:e2e:ward-journeys now reports 71 passed, 3 skipped, 0 failed. The eight failures this row recorded are gone. Two of the three skips are the ui-ward-morning pair, and their skip is a documented owner-approved decision rather than a silencing: MERGE 02 (2026-09-05) folded the morning board into CapacityScreen, /mockups/ward-flow/morning is now a redirect stub, MorningPage is unmounted, and morning-page.tsx's own doc comment forbids retargeting the spec at CapacityScreen or re-mounting the component pending the owner's ruling on spec D9. Component-level coverage continues in tests/ward-morning-page.dom.test.tsx (20 cases) and tests/ward-morning-print.test.ts. SECOND HALF IS BUILT BUT INERT, AND THAT IS THE ONLY REMAINING ACTION. The blocking lane ui-ward-journeys in .github/workflows/ci.yml no longer scopes on ward paths - it now gates on needs.changes.outputs.ui_changed - so the path-scoped blind spot this row named is designed out. But the job also requires vars.WARD_JOURNEYS_BLOCKING == 'true', which is unset by default, so it is skipped and pr-required records it skipped-and-fine. That inertness is deliberate and documented in the workflow: a lane that is already red, made blocking, stops every UI pull request in the repository, so the author required one deliberate act in repository settings at the moment somebody has a green run of the journeys in front of them. THAT PRECONDITION IS NOW MET and the green run is quoted above. NEXT ACTION, owner only: set repository variable WARD_JOURNEYS_BLOCKING to true. It is a GitHub settings change, not a code change, so no PR can do it. Close this row once it is set and one PR has shown the lane reporting.", - "source": "Assessment session 2026-09-07: fresh origin/main worktree, local chromium-mockups run, and a read of ci.yml plus ui-ward-morning.spec.ts", + "detail": "ASSESSED AND LARGELY OVERTAKEN 2026-09-07, on a fresh worktree at origin/main 0177bed; NEXT-ACTION SUPERSEDED 2026-09-12 after #2710. FIRST HALF IS FIXED, NOT BY THIS ROW: npm run test:e2e:ward-journeys now reports 71 passed, 3 skipped, 0 failed. The eight failures this row recorded are gone. Two of the three skips are the ui-ward-morning pair, and their skip is a documented owner-approved decision rather than a silencing: MERGE 02 (2026-09-05) folded the morning board into CapacityScreen, /mockups/ward-flow/morning is now a redirect stub, MorningPage is unmounted, and morning-page.tsx's own doc comment forbids retargeting the spec at CapacityScreen or re-mounting the component pending the owner's ruling on spec D9. Component-level coverage continues in tests/ward-morning-page.dom.test.tsx (20 cases) and tests/ward-morning-print.test.ts. SECOND HALF WAS 'BUILT BUT INERT' UNDER vars.WARD_JOURNEYS_BLOCKING; THAT GATE IS GONE IN CODE. The blocking lane ui-ward-journeys in .github/workflows/ci.yml no longer scopes on ward paths \u2014 it gates on needs.changes.outputs.ui_changed \u2014 so the path-scoped blind spot this row named is designed out. Concurrent #2710 (ci(ward-flow): require journey coverage) enables that lane in ci.yml and drops the vars.WARD_JOURNEYS_BLOCKING term from both the job if: and the matching pr-required require_success condition; the lane runs on pull_request|merge_group when ui_changed and not draft. No repository variable flip is required or useful after #2710. Do not set WARD_JOURNEYS_BLOCKING \u2014 the variable is obsolete once #2710 lands. NEXT ACTION: once #2710 is on main, confirm one non-draft UI PR shows ui-ward-journeys / ward-flow-journeys reporting (not skipped-and-fine solely because of a missing repo var), then close this row. Until then keep open as the ledger record that the enablement landed in code rather than settings.", + "source": "Assessment session 2026-09-07: fresh origin/main worktree, local chromium-mockups run, and a read of ci.yml plus ui-ward-morning.spec.ts; next-action refreshed 2026-09-12 against #2710 (ui-ward-journeys enabled in ci.yml; WARD_JOURNEYS_BLOCKING gate removed)", "baseRowFingerprint": "119605ea523b039ea176fbbc0860e20492b68c77669a199d3c341b9c7046dc3f" } }