phase: 153_document_viewer_context
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s

That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.

## Phase 153 — final verification pass report

**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched

**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.

**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.

**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.

**Next pending phase:** `154_sync_status_freshness`.
This commit is contained in:
2026-10-04 09:23:54 -04:00
parent 12fa69b7ea
commit 966c4b0540
59 changed files with 4304 additions and 34 deletions
@@ -0,0 +1,44 @@
# Phase 154 — sync + upload stamps say WHEN (date-qualified freshness)
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "RAG 'Synced 16:06' badge is time-only — ambiguous the next day" + "Sources 'Uploaded — press Sync sources to import it.' has no timestamp — it's server-persisted and re-rendered on every mount; after that archive is imported by a sync, the line can sit there indefinitely and mislead."
**Story:** n/a (audit-derived phase)
**Context:** `sources.js::fmtSyncTime(iso)` (L814–819) — `HH:MM` only; its consumer `applySyncSuccess` (L1195–1196) settles the RAG sync button as `Synced ${time}` (or bare "Synced" when the ISO is unparseable — the app-restart case: the sync status is in-memory, A12). The label parts writer (L821 onward) carries the same text in the button `title` + the running `aria-label` — the qualified stamp rides all three (the phase-128 parts-writer idiom: write once, three surfaces). `git-sources.js::fmtUploadResult(detail, name)` (L1128–1133) — the no-count contract (phase 90): `Uploaded <name> — press Sync sources to import it.` (fresh 202 settle, the page-local `lastUploadName`) or the nameless `Uploaded — press Sync sources to import it.` (the boot re-render, `initUploadStatus` L1279–1304, where the name is unknown after a reload). `GET /api/git-sources/upload/status` (git_sources.py L704–729) ALREADY exposes `started_at` / `finished_at` (ISO) + `detail` — the server change is ZERO; this phase is frontend-only. The per-page duplication idiom is established (history.js L301's copyText, sources.js vs app.js) — the date-qualifier helper is duplicated into the two modules, not shared.
## Objective
Both freshness stamps tell the owner *when*: a same-day stamp stays `HH:MM` (byte-identical to today's behavior); a non-same-day stamp carries the date (`M/D, h:MM AM/PM` — the table-date family); the Sources upload line carries its completion stamp on every render path (fresh settle AND boot re-render), so a stale "Uploaded" line is self-dating.
## Dependencies
- `150_drop_sync_result` (complete) — the settled sync button is the sole "Synced" surface (the phase-149 box is the running label; the button settles it).
- `64_sync_upload_progress` (complete) — the upload 202/poll/re-attach state machine this phase's line change rides on.
- `90_upload_no_scan` (complete) — the no-count result-line contract (phase 90, A2) this phase EXTENDS (a stamp is added; no counts return).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The date-qualifier rule (ASSUMPTION A1, owner-locked):** the stamp is computed from the ISO's LOCAL date (the page's clock — the table dates already use local time). Same local year as now AND same calendar day → `HH:MM` (byte-identical to the current `fmtSyncTime` output — a same-day badge is unchanged). Otherwise, same year → `M/D, h:MM AM/PM` (e.g. `10/2, 3:14 PM`); different year → `M/D/YYYY, h:MM AM/PM` (e.g. `1/3/2025, 9:12 PM`). An unparseable/missing ISO → the existing fallback (sync: bare "Synced"; upload: no stamp — the pre-phase strings). `h:MM` uses the locale 12-hour form WITHOUT leading zero on the hour (matching the table dates' `5:34:06 PM` family — drop the seconds), month/day without leading zero (the tables' `M/D/YYYY` shape).
- **RAG sync badge (task 01):** `fmtSyncTime` becomes the date-qualifier (keep the name — its contract widens; or add `fmtStamp` and delegate — the executor's choice, the pins fix the output). `applySyncSuccess` is unchanged in shape (`settleSyncButton(time ? \`Synced ${time}\` : "Synced")` — the qualifier lives inside the formatter). The parts writer's comment block (L821 onward) names the qualified stamp in the label parts where the `HH:MM` contract is cited (cite, don't restate).
- **Sources upload line (task 02):** `fmtUploadResult(detail, name, when)` — `when` is the precomputed stamp string (the caller computes it from `status.finished_at`, falling back to `status.started_at`; the success state always has a `finished_at` on the server, the fallback is the defensive guard for a legacy/partial payload). Shapes (the phase-90 contract extended, name present/absent): `Uploaded <name> (<stamp>) — press Sync sources to import it.` / `Uploaded <stamp> — press Sync sources to import it.`; `when` empty (running state, legacy payload) → BYTE-IDENTICAL to the current strings (the no-stamp fallback). Call sites: the poll settle (L1239–1244, the page-local `lastUploadName`) and `initUploadStatus`'s success branch (L1279–1304, nameless) — both read `status.finished_at` from the same `/upload/status` payload. The L78–96 and L1125–1133 comment blocks name the stamp in the contract (cite, don't restate).
- **A11y:** the stamps are text in existing `role="status"` / button-label surfaces — no new nodes, no color-alone state (B5).
## Tasks
1. `01_rag_sync_stamp.md` — the date-qualifier in `sources.js` (`fmtSyncTime`'s contract widens; same-day byte-identical).
2. `02_upload_line_stamp.md` — the `when` param on `fmtUploadResult` + both call sites reading `finished_at`/`started_at`.
3. `03_unit_pins.md` — the qualifier's boundary pins + both stamp shapes (with/without name, with/without stamp).
4. `04_e2e_status_freshness.md` — the dedicated Playwright suite.
## Testing & Quality
- Unit: source-level pins — the qualifier (same-day `HH:MM` byte-identical; yesterday `M/D, h:MM AM/PM`; last-year `M/D/YYYY, h:MM AM/PM`; garbage/missing ISO → the pre-phase fallbacks — a fixed clock in the test, not wall-clock math on `new Date()`); `fmtUploadResult`'s four shapes (name×stamp, name×no-stamp byte-identical, no-name×stamp, no-name×no-stamp byte-identical); the `applySyncSuccess` settle string for a non-same-day `finished_at`.
- Integration: ZERO server change — the `/upload/status` payload is untouched (regression guard: the full-dict pin, if one exists, stays byte-identical).
- Coverage: **>90%** on the modified JS (source-level pins + E2E).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the stamp format is a visible UI change — name it where the sync/upload surfaces are described).
## Completion Criteria
- [ ] a same-day sync settles as `Synced HH:MM` — byte-identical to pre-phase (the pin + the E2E)
- [ ] a non-same-day `finished_at` (the unit pins; the E2E asserts the same-day shape on a real mock sync — the date branch is unit-pinned with a fixed clock, the E2E never depends on the wall clock)
- [ ] the upload line carries the stamp on BOTH render paths (the fresh 202 settle with the name; the boot re-render without it) — the E2E seeds a prior upload via the API and reloads the page
- [ ] a running/legacy upload payload renders the pre-phase strings byte-identically (no partial-state regression)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,23 @@
# Task 01 — the RAG sync badge is date-qualified
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
The settled RAG sync button reads `Synced HH:MM` when the run finished today and `Synced M/D, h:MM AM/PM` (or `M/D/YYYY, …` across years) otherwise — a badge that no longer lies about its age.
## Work
1. `frontend/assets/sources.js`:
- `fmtSyncTime(iso)` (L814–819): the contract widens to the date-qualifier (the phase design's A1 rule). Keep the function name if the delegation is clean; otherwise add `fmtStamp(iso)` and let `fmtSyncTime` delegate — the pins fix the OUTPUT, the executor's choice is internal. The parse guard (`Number.isNaN(d.getTime()) → ""`) is UNCHANGED — an unparseable ISO still yields the bare "Synced" settle.
- the qualifier compares LOCAL calendar dates (the page's clock — the tables' `Updated` columns already render local dates; the idiom is there in `history.js`'s updated-cell formatter — mirror its `toLocaleDateString`/manual shape, the `M/D(/YYYY), h:MM AM/PM` family with no leading zeros).
- `applySyncSuccess` (L1195–1196) is UNCHANGED in shape — the qualifier lives inside the formatter; the settle string for a same-day run is byte-identical to pre-phase.
- the parts-writer comment block (L821 onward) names the qualified stamp where the `HH:MM` label contract is cited (cite, don't restate — the block already lists the label parts; the badge is one of them).
2. No CSS change (the label part ellipsizes; the longer stamp is just more text in the same part).
## Testing & Quality
- Unit: the source-level pins gain the qualifier's boundary set with a FIXED clock (the test's fake clock — never `new Date()` in the test body): same-day → `HH:MM` (byte-identical to the pre-phase pin); yesterday (same year) → `M/D, h:MM AM/PM`; last-year same M/D → `M/D/YYYY, h:MM AM/PM`; hour formatting (`3:14 PM` — no leading zero on the hour; `12:05 AM` edge); garbage ISO → `""` (unchanged).
- Coverage: **>90%** on the modified JS.
## Completion Criteria
- [ ] same-day settle is byte-identical to pre-phase (`Synced HH:MM`)
- [ ] the boundary pins pass on a fixed clock; the wall clock never appears in a test assertion
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,28 @@
# Task 02 — the Sources upload line carries its completion stamp on every render path
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
`Uploaded — press Sync sources to import it.` becomes self-dating — `Uploaded 21:14 — …` (or `Uploaded 10/2, 3:14 PM — …`) on BOTH render paths (the fresh 202 settle with the page-local name, the boot re-render without it) — so a line that outlives its upload can no longer masquerade as fresh.
## Work
1. `frontend/assets/git-sources.js`:
- `fmtUploadResult(detail, name, when)` (L1128–1133): the third param is the PRECOMPUTED stamp string (the phase design's A1 format, computed by a page-local `fmtUploadStamp(status)` helper — the per-page duplication idiom; it reads `status.finished_at` first, falls back to `status.started_at`, and yields `""` when both are missing/absent). Shapes:
- `detail.message === "uploaded"` + `name` + `when`: `Uploaded ${name} (${when}) — press Sync sources to import it.`
- `detail.message === "uploaded"` + no `name` + `when`: `Uploaded ${when} — press Sync sources to import it.`
- `when === ""` (any name state): BYTE-IDENTICAL to the current strings (the legacy/partial-payload guard — the phase-90 contract for a payload without timestamps).
- the non-`uploaded` branch (`"The upload finished."`) is unchanged.
- the poll settle (L1239–1244): `fmtUploadResult(detail, lastUploadName, fmtUploadStamp(status))` — the poll tick already holds the full status object.
- `initUploadStatus`'s success branch (L1279–1304): the same third arg from the boot-fetched status (the name stays null — the page-local `lastUploadName` is null after a reload; the stamp comes from the SERVER payload, so the nameless line is still self-dating after a reload — that is the point).
- the L78–96 and L1125–1133 comment blocks name the stamp in the no-count contract (cite, don't restate — the blocks already document the nameless re-render; add the stamp to the described string shape).
2. NO server change: `GET /api/git-sources/upload/status` already exposes `started_at` / `finished_at` (git_sources.py L704–729) — do not touch `app/` in this task.
## Testing & Quality
- Unit: the source-level pins gain the four `fmtUploadResult` shapes (name×stamp, name×no-stamp → byte-identical to the pre-phase string, no-name×stamp, no-name×no-stamp → byte-identical) + `fmtUploadStamp` (finished wins over started; started when finished is missing; `""` when both absent; a garbage ISO → `""`).
- Coverage: **>90%** on the modified JS.
## Completion Criteria
- [ ] the four shape pins pass; the two no-stamp shapes are byte-identical to pre-phase
- [ ] `initUploadStatus`'s success branch passes the stamp (a boot re-render of a prior upload shows the stamp even with the name unknown)
- [ ] `app/` is untouched in this task (a `git diff --name-only` scoped check)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,22 @@
# Task 03 — unit pins for the qualifier and the four stamp shapes
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
The date-qualifier rule (A1) and both stamp line shapes are pinned at the source level on a fixed clock — a later refactor cannot silently revert to time-only or break the byte-identity fallbacks.
## Work
1. `tests/unit/` — locate the existing `sources.js` / `git-sources.js` source-level pin suite (the phase-64/90/150/152 suites are the precedent — read `test_frontend_sync_upload.py` and its siblings for the idiom) and extend it:
- the qualifier (task 01's boundary set — fixed clock; the byte-identity of the same-day output against the PRE-PHASE pinned string — pin the old string verbatim in the test so the byte-identity is assertable, not assumed);
- `fmtUploadResult`'s four shapes (task 02 — including the two byte-identity fallbacks pinned against the pre-phase strings);
- `fmtUploadStamp` (finished/started/missing/garbage).
2. The `GET /api/git-sources/upload/status` payload pin (if the suite pins the full dict — the phase-64 precedent): UNCHANGED — this phase adds no server keys; the pin stays byte-identical (a regression guard that task 02 really touched nothing in `app/`).
## Testing & Quality
- Unit: the pins above; every pre-existing pin stays green (additions only — no pin deleted or loosened).
- Coverage: the pins exercise the modified branches.
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the two byte-identity fallback pins assert against the verbatim pre-phase strings
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,22 @@
# Task 04 — the dedicated Playwright suite: date-qualified stamps
**Phase:** `154_sync_status_freshness` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_sync_status_freshness.py`)
## Objective
An isolated E2E proves the two visible contracts on the live UI: a fresh mock sync settles the RAG button as `Synced HH:MM` (same-day shape), and the Sources upload line carries its stamp on BOTH render paths — the fresh settle (with the file name) and the boot re-render after a reload (nameless, still stamped from the server payload).
## Work
1. `tests/e2e/test_sync_status_freshness.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the neighboring sync suites — `test_sync_image_progress.py` (phase 152) and the phase-90/64 suites are the precedents; read one and mirror its boot, do not invent a new fixture):
- **test_fresh_sync_settles_same_day_shape:** a mock sync run (the suite's sync fixture — a small fixture source; the mock-LLM makes the run fast) → the RAG sync button settles; assert the label matches `Synced \d{2}:\d{2}` (the same-day shape — the test runs "now", so the qualifier must yield `HH:MM`; a regex pin, NOT a wall-clock comparison — the suite never asserts on the actual time value).
- **test_upload_line_fresh_settle_carrys_name_and_stamp:** a real small fixture archive is uploaded through the UI (the phase-90 suite's upload idiom — a generated zip in `tmp_path`) → the 202 toast → the poll settles → the ready-for-sync line reads `Uploaded <name> (<stamp>) — press Sync sources to import it.` (the name is the fixture's safe name; the stamp matches the `(\d{2}:\d{2}|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M)` shape pin — same-day in practice, the shape pin covers both).
- **test_upload_line_boot_rerender_stamped_nameless:** continue from the previous state (or re-seed): RELOAD the Sources view/page → the boot re-render (`initUploadStatus`'s success branch) shows the NAMELESS line WITH the stamp: `Uploaded <stamp> — press Sync sources to import it.` (the name is gone after the reload — the phase-90 contract — but the stamp survives because it comes from the server payload: THIS is the regression the phase fixes).
- **test_running_upload_line_unchanged (regression guard):** a running upload (the poll's processing state) never renders the ready-for-sync line (the phase-90 contract — the line appears only on settle; assert the bare "Processing…" label during the run with the suite's slow-LLM/delay idiom if a deterministic in-flight observation is needed, otherwise the settle-only assertion).
2. The suite is DETERMINISTIC: mock-LLM only, no `E2E_REAL_LLM` dependency; time assertions are SHAPE pins (regex), never value comparisons against the wall clock.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` green in isolation
- [ ] the boot-re-render test proves the nameless line carries the stamp (the phase's actual fix)
- [ ] the suite asserts shapes, not wall-clock values; no real-LLM dependency