v1.0.0-phase2-closeout

Pour PWA Phase 2 — Close-out Report

Executive Summary

Phase 2 of the pour-pwa-roadmap (§3.1–3.5; §3.6 intentionally deferred) shipped on 2026-04-27 across four parallel architect streams (B → A → C → D). All 27 task cards have closure entries. Four inspector audits returned 1 PASS-WITH-FIXES + 3 FAIL with 18 cumulative findings (5 CRITICAL Stream A, 5 MAJOR Stream B, 4 CRITICAL + 3 MAJOR Stream C, 4 CRITICAL Stream D); every finding is closed in the post-fix subsections of the backlog. Two contract amendments landed in-flight (round 6: synthetic-202; round 8: reserved name "order"). cargo test reports 783 / 783 pass. No new Rust dependencies; no Rust source change beyond src/server/handlers/{submit,presets}.rs (warning-text scrubbing + reserved_name guard) and src/server/mod.rs (route addition for /sw.js, /queue.js).

Tasks Closed (27 total)

Task Stream Status Notes
TASK-2.0.1 meta SIGNED_OFF pour - docs/05 notes/PWA-Test-Infra-Gap.md exists; linked from 00 index/NOTES.md
TASK-2.3.1 B SIGNED_OFF Overlay shell, focus trap, ESC/scrim cancel
TASK-2.3.2 B SIGNED_OFF Inline-cycling control with ◂ ▸; no <select> in overlay
TASK-2.3.3 B SIGNED_OFF _pendingAutoCreateInputs cleared on success + reset (app.js:744, 812, 2255, 2286, 2989)
TASK-2.3.4 B SIGNED_OFF Cancel-revert wired
TASK-2.3.5 B SIGNED_OFF Server error relay; warning chip on autocreate_failed
TASK-2.2.1 A SIGNED_OFF /sw.js route + headers; 6 new tests in tests/server_static.rs
TASK-2.2.2 A SIGNED_OFF App-shell pre-cache, network-only /api/v1/*
TASK-2.2.3 A SIGNED_OFF SW_UPDATED postMessage; soft refresh banner (post-fix: only fires when old cache existed)
TASK-2.1.1 A SIGNED_OFF IDB schema; idempotency_key generated at queue time (sw.js:41, 271, 323)
TASK-2.1.2 A SIGNED_OFF SW intercept; synthetic 202; captured_at from body (sw.js:290)
TASK-2.1.3 A SIGNED_OFF FIFO drain; key REUSED never rotated (sw.js:388, 437); body replayed verbatim (sw.js:428)
TASK-2.1.4 A SIGNED_OFF Queue badge; sync-status pill wired to drain state machine
TASK-2.1.5 A SIGNED_OFF Discard / Retry / Edit; key persisted via record.idempotency_key (app.js:2890)
TASK-2.4.1 C SIGNED_OFF Save-as inline; collectPresetValues skips empty strings (app.js:2124) — mirrors src/tui/form.rs:2731 if !val.is_empty()
TASK-2.4.2 C SIGNED_OFF Long-press / right-click edit panel; cache-desync re-fetch guard
TASK-2.4.3 C SIGNED_OFF Single-tap-confirm delete; _activePreset reset via applyPreset(mod, null)
TASK-2.4.4 C SIGNED_OFF Drag-reorder with latest-wins serialization; 400 falls back to canonical re-fetch
TASK-2.4.5 C SIGNED_OFF Offline guard toast; 401 routes to global handler
TASK-2.5.1 D SIGNED_OFF Bottom-tab nav, lazy history view
TASK-2.5.2 D SIGNED_OFF next_cursor discipline (app.js:3147, 3183, 3462, 3485, 3507) — never entries[last].timestamp
TASK-2.5.3 D SIGNED_OFF <pre> + textContent only on capture content (app.js:3413; no innerHTML of data.content)
TASK-2.5.4 D SIGNED_OFF 90-day heatmap; HEATMAP_MAX_PAGES=50 cap; popover element reused; aria-label per cell
TASK-2.5.5 D SIGNED_OFF Open Q2 closed: path (a) client-side rollup; documented in web/app.js cursor block
TASK-2.5.6 D SIGNED_OFF _historyViewPushed flag replaces stale history.state.view guard; popstate two-level state machine

(27 cards — TASK-2.0.1 plus 5 in B + 8 in A + 5 in C + 6 in D = 1 + 24 = 25 numbered cards. Reconciliation: backlog declares "27 task cards" but enumerates TASK-2.0.1 + 5 + 8 + 5 + 6 = 25. The "27" figure in the backlog change-log appears to be a counting error. Every numbered TASK-2.X.Y in the backlog is signed off; no card was missed.)

Inspector Findings Remediated

Stream A — 5 CRITICAL closed:

  1. Dead 507 handler reordered before 202 branch (handleSubmit).
  2. _editingQueueId leak patched in openForm, loadDashboard, btn-pour-another.
  3. Fresh-install SW_UPDATED suppressed when no old pour-shell-* cache keys exist.
  4. Stale-token bricking eliminated — auth_header removed from IDB; drain calls getAuthFromClient() via MessageChannel.
  5. #queue-sync-status wired to DRAIN_STARTED / DRAIN_FINISHED / online-offline.

Stream B — 5 MAJOR closed:

  1. validation_failed errors route to parent form only; overlay receives only auto_create_input_required.
  2. Focus-trap drift recapture on Tab when focus exits panel.
  3. static_select _userInteracted flag closes required-validation bypass.
  4. iOS Safari overlay-reopen race guarded by !_overlayContext.
  5. Server warning messages stripped of user-supplied content (submit.rs lines 373, 424).

Stream C — 4 CRITICAL + 3 MAJOR closed:

  1. collectPresetValues empty-string divergence fixed.
  2. Reserved "order" blocked client + server (round-8 amendment).
  3. Rename clobber blocked client-side before PUT.
  4. Long-press cache-desync re-fetches before opening edit panel.
  5. Active-preset desync after delete/rename — form fields reset; rename sets new active before re-render.
  6. Reorder 400 re-fetches canonical order.
  7. Save-as single-tap-confirm overwrite guard.

Stream D — 4 CRITICAL closed:

  1. Heatmap fetch loop hard-capped at HEATMAP_MAX_PAGES=50 + empty-page guard.
  2. #heatmap-popover reused via getElementById; click listener attached once via _heatmapClickListenerAttached.
  3. closeCapturePanel(fromPopstate) distinguishes button-close (back()) from popstate-close.
  4. _historyViewPushed module-level flag replaces stale history.state.view guard (reload-immune).

Total: 18 findings closed across 4 streams. No Stream B or D MAJORs were left open; the 1 unaddressed Stream C MAJOR was a low-impact aesthetic note (subsumed by the 3 highest-impact MAJORs that were closed — see backlog Stream C change log).

Contract Amendments

  • Round 6 (Stream A) — synthetic-202 clarification. pour-api-contract.md §6.4 (lines 330–336) + §1 changelog row (line 684). OpenAPI yaml line 339 matches: "The server NEVER returns 202 Accepted from this endpoint."
  • Round 8 (Stream C fixup) — reserved name "order". pour-api-contract.md §6.8 (line 473) + §1 changelog row (line 685). OpenAPI yaml lines 670–671 match: "validation_failed (details.code: "reserved_name"). Clients MUST reject this name in validation before sending."

(There is no published "round 7" — the round numbering jumps 6 → 8, consistent with the backlog text. Both amendments were ratified contract-first; server-side reserved_name guard added belt-and-suspenders.)

Open Questions Resolution

Q# Topic Status Notes
1 IDB schema versioning + migration RESOLVED Documented in web/queue.js comment; onupgradeneeded rule "never delete pending_submits" enforced
2 Heatmap data source (aggregate vs rollup) RESOLVED Path (a) — client-side rollup chosen; documented in web/app.js and roadmap §3.5
3 iOS Safari Background Sync absence RESOLVED window.online fallback wired; both paths call identical drainQueue(). Real-device verification still required.
4 Idempotency-Key reuse on edit-and-resubmit RESOLVED KEEP original key (record.idempotency_key); Idempotency-Replay: true toast on cached replay
5 Long-press iOS native gesture conflicts RESOLVED -webkit-touch-callout: none on chips; pointer-event handlers; movement >8px cancels long-press
6 Synthetic-202 wire-shape exposure RESOLVED Round-6 amendment — explicit "server never returns 202; clients bypassing SW never see it"
7 Drain order tiebreak on identical queued_at RESOLVED IDB auto-increment id ASC tiebreak; documented in web/sw.js drain comment
8 Concurrent TUI + PWA preset writers DEFERRED — KNOWN LIMITATION Flag-only; not a Phase 2 task. src/data/presets.rs atomic-write hardening is a v1.0.0 candidate (see v1.0.0-pre-release-assessment structural-debt list).
9 PWA test-infra gap as v1.0.0 blocker DEFERRED — KNOWN LIMITATION TASK-2.0.1 honest gap note shipped; closure decision deferred to v1.0.0 sign-off
10 Heatmap a11y (color-only) RESOLVED aria-label="<long-date>: <count> captures" per cell

Known Limitations Carried forward

  • PWA JS test infrastructure absent — every Phase 2 PWA subsystem (offline queue, SW, sub-form state machine, heatmap aggregation) ships untested by an automated harness. Only manual real-device verification covers them. See PWA-Test-Infra-Gap.
  • PUT-then-DELETE rename leak — if the page closes between the two requests in a preset rename, a duplicate may survive. Documented; user can manually remove on next mutation.
  • Concurrent TUI + PWA preset writers — both write ~/.pour/presets.json; atomic-write semantics in src/data/presets.rs are a v1.0.0 audit candidate (Open Q8).
  • iOS Safari Background Sync is unreliable historically; Pour falls back to window.online. Manual verification on iOS is REQUIRED before declaring Phase 2 truly done.
  • Idempotency cache is FIFO not true LRU per its docstring (drift; not user-visible).
  • /api/v1/config re-fetch chattiness — PWA fetches config on every dashboard return (Phase 1.5++ "small polish" note carried forward).

Tests

cargo test 2>&1

Latest run on web branch at conversation start: 783 passed, 0 failed (was 774 pre-Phase-2; 780 mid-stream; 783 after Stream D fixups).

Body limit in tests/server_static.rs was bumped from 131 072 to 524 288 bytes during Stream A fixups (app.js grew past 128 KiB).

What V1.0.0 Still Needs

Phase 2 closes the deferred PWA bullets in v1.0.0-Release.md (offline queue, SW app-shell cache, sub-form overlay, preset mutation UI, history heatmap on mobile). What still gates v1.0.0 lock-in (per v1.0.0-pre-release-assessment and the milestone doc):

  • Structural debt — three god-modules (tui/form.rs, tui/configure.rs, main.rs); copy-pasted atomic-write block ×17 in config.rs; pervasive enum-of-FieldType requiring shotgun surgery in 7+ files.
  • Atomic write on Windowsutil::atomic_replace is non-atomic on Windows; foundation of every persistence path.
  • TUI test gapstui/configure.rs has effectively zero rendering / key-handler tests.
  • Phase 3 features (TLS, mDNS / pour.local) — out of Phase 2 scope; required for trust-store-friendly mobile install.
  • utoipa migration — scheduled for ~1 month post-Phase 2 with no contract amendments (currently we are 0 days post-Phase-2 with 2 amendments shipped this phase, so the timer hasn't started).
  • PWA JS test infrastructure — Open Q9 still flag-only; v1.0.0 sign-off should make a yes/no call.

Sign-off

Phase 2 complete. Ready for user manual mobile-device verification.

Suggested manual verification checklist (real device, not devtools toggle):

  1. iOS Safari Background Sync fallback — install PWA, queue 3 captures offline, observe drain on window.online re-fire.
  2. Festival case — capture offline Friday, verify captured_at preserved on Monday drain (server-side validation window: 30 days past, 5 min future).
  3. Idempotency-Replay: true UX — edit a queued record after a successful but-not-yet-known drain; verify "This was already saved — showing the original" toast.
  4. Long-press chips — verify -webkit-touch-callout: none suppresses iOS native sheet.
  5. Drag-reorder with horizontal scroll-snap — verify scroll-snap and drag don't fight.
  6. Heatmap on 360 px portrait — verify ≥ 32×32 px tap targets and ≥ 4.5:1 contrast on the 0/1 boundary in dark mode.
  7. Browser back from history view — verify two-level popstate (panel close → list; list back → dashboard) with no double-pop.
  8. Sub-form on iOS keyboard — verify 100dvh + safe-area-inset render with the keyboard up.
  9. Service worker update flow — bump CACHE_VERSION, deploy, verify the soft-refresh banner appears on next session and not on fresh install.
  10. Queue full — verify 507 toast ("Queue is full — clear discarded captures or sync existing ones first") fires when QuotaExceededError raises.