feat(statistics-deck): deck shell — zero-scroll frame, top bar, picker, profiles, keyboard, data-quality marker (#133) #144

Merged
gabogg merged 4 commits from feat/statistics-deck-shell into master 2026-09-26 22:37:17 +00:00
Owner

Closes #133.

Problem

#content-statistics was a placeholder. The period views (#134 Day, #135 Week, #136 Month) need the shared deck shell from RFC #80 before they can be built: the zero-scroll frame, the 38 px top bar, the period picker, layout profiles, language, numpad control, and the data-quality marker frame.

Approach

The shell lives in its own modules under app/static/js/src/ui/statistics_deck/, not in app.js:

Module Responsibility
statistics_deck.js Controller. Owns state (tab, per-tab period, profile, language, open panel), top bar, picker / quality / help panels, and the registerView(granularity, render) hook the period views plug into. Until a view is registered, each profile shows its placeholder grid.
periods.js Period labels formatted on the client from the structured fields, in both languages (WEEK MON 14 – SUN 20 SEP 2026 W38, including cross-month and cross-year weeks). Also the latest-complete default, -/+ stepping, and #statistics/<granularity>/<start> deep links.
quality.js Classifies a day as OK / Estimate / Unreliable, mirroring day_quality_state(). Also produces the badge text (spelled out in Room and Desk, glyphs and counts in Laptop) and the plain-language detail lines, which contain no internal codes.
keyboard.js Numpad map matched by KeyboardEvent.code, so it works with NumLock on or off.
charts.js Chart.js defaults (fill the box, no animation, autoSkip, no label rotation) and a plugin that draws the Estimate dashed amber outline and the Unreliable grey hatching, for bars and for gap columns on line graphs. There are no confidence bands.
translate.js The deck's own translator, for its own language switch. All copy goes through the new statisticsDeck.* keys in i18n.js.

Frame. statistics-deck.css makes the deck a fixed 100dvh, overflow: hidden frame:

  • While shown, it hides the app header, HUD ribbon, deck selector and bottom status strip.
  • Rows use minmax(0, Nfr): Room 12/55/33, Desk 17/50/33, Laptop 26/74.
  • Below 760 px it stacks into one column and scrolls.
  • It uses only design-system tokens, with zero radius and amber for the quality marker.

Behaviour:

  • Each tab keeps its own period and opens on the latest complete period.
  • Opening a deep link restores the tab and period; a reload (PerformanceNavigationTiming.type === 'reload') returns to the latest period.
  • The profile (default Room) and language (default: the app's language) are stored per browser, in try/catch-wrapped storage.
  • If the browser refuses the fullscreen request, the deck ignores it quietly.
  • The * detail panel fetches the period's daily rows lazily. Views reuse them through ctx.loadDays().

Navigation:

  • A viewer lands on the deck (already wired by #121). Their top bar has no back-to-ops control; a LOG OUT control takes its place, because the deck hides the header that held their only logout.
  • Operators and admins open the deck with F8. Opening an app URL with a #statistics/... hash takes them straight to the deck.

Scope notes

  • Admin tab swap is not in this PR. #133 says to do the analytics → deck swap together with #130, which moves the calibration tools first so no admin tool becomes unreachable. Admins keep the analytics tab (F5) and get the deck on F8 until then.
  • The period views (#134–#136) plug in through registerView; the shell renders placeholder panels in each profile's grid until they land.

Verification

  • Frontend tests: 27 new tests in tests/frontend/test_statistics_deck.test.js, run by pytest:
    • day classification and precedence
    • badge per profile and tier
    • detail lines (no internal codes)
    • period labels in ES/EN, including cross-month and cross-year weeks
    • default and stepping
    • deep-link parsing
    • the full numpad map, including NumLock off (same code, different key)
    • graph options and quality-mark geometry
    • controller behaviour against a fake DOM: deep link vs reload, per-tab choice, persistence surviving a reload, storage that throws, the badge that can't be hidden, viewer controls, and hide() restoring the chrome
  • Full suite: pytest 416 passed, including the i18n parity and key-resolution tests; ruff clean; check_docs.py clean.
  • Real browser: Chromium via Playwright against the app running from this worktree on a copy of the dev DB, logged in as operator:
    • No page scroll, no deck-root scroll, no overflowing cell, a 38 px top bar and no top-bar overflow, in all 9 combinations of Room/Desk/Laptop × Day/Week/Month, at 1920×1080, 1920×960 and 760×600.
    • At 700 px the deck stacks.
    • Screenshots of the picker (calendar and list), the quality detail and the help were checked by eye.
    • No page errors.
  • Found and fixed in that browser pass:
    • The daily route's end is inclusive, while periods carry an exclusive end, so the detail panel now requests end − 1. A test covers it.
    • The status strip was overlapping the deck.
    • The fullscreen glyph is missing from the font; it's now a text label.

🤖 Generated with Claude Code

Closes #133. ## Problem `#content-statistics` was a placeholder. The period views (#134 Day, #135 Week, #136 Month) need the shared deck shell from RFC #80 before they can be built: the zero-scroll frame, the 38 px top bar, the period picker, layout profiles, language, numpad control, and the data-quality marker frame. ## Approach The shell lives in its own modules under `app/static/js/src/ui/statistics_deck/`, not in `app.js`: | Module | Responsibility | |---|---| | `statistics_deck.js` | Controller. Owns state (tab, per-tab period, profile, language, open panel), top bar, picker / quality / help panels, and the `registerView(granularity, render)` hook the period views plug into. Until a view is registered, each profile shows its placeholder grid. | | `periods.js` | Period labels formatted on the client from the structured fields, in both languages (`WEEK MON 14 – SUN 20 SEP 2026 W38`, including cross-month and cross-year weeks). Also the latest-complete default, `-`/`+` stepping, and `#statistics/<granularity>/<start>` deep links. | | `quality.js` | Classifies a day as OK / Estimate / Unreliable, mirroring `day_quality_state()`. Also produces the badge text (spelled out in Room and Desk, glyphs and counts in Laptop) and the plain-language detail lines, which contain no internal codes. | | `keyboard.js` | Numpad map matched by `KeyboardEvent.code`, so it works with NumLock on or off. | | `charts.js` | Chart.js defaults (fill the box, no animation, `autoSkip`, no label rotation) and a plugin that draws the Estimate dashed amber outline and the Unreliable grey hatching, for bars and for gap columns on line graphs. There are no confidence bands. | | `translate.js` | The deck's own translator, for its own language switch. All copy goes through the new `statisticsDeck.*` keys in `i18n.js`. | **Frame.** `statistics-deck.css` makes the deck a fixed `100dvh`, `overflow: hidden` frame: - While shown, it hides the app header, HUD ribbon, deck selector and bottom status strip. - Rows use `minmax(0, Nfr)`: Room 12/55/33, Desk 17/50/33, Laptop 26/74. - Below 760 px it stacks into one column and scrolls. - It uses only design-system tokens, with zero radius and amber for the quality marker. **Behaviour:** - Each tab keeps its own period and opens on the latest complete period. - Opening a deep link restores the tab and period; a reload (`PerformanceNavigationTiming.type === 'reload'`) returns to the latest period. - The profile (default Room) and language (default: the app's language) are stored per browser, in try/catch-wrapped storage. - If the browser refuses the fullscreen request, the deck ignores it quietly. - The `*` detail panel fetches the period's daily rows lazily. Views reuse them through `ctx.loadDays()`. **Navigation:** - A viewer lands on the deck (already wired by #121). Their top bar has no back-to-ops control; a **LOG OUT** control takes its place, because the deck hides the header that held their only logout. - Operators and admins open the deck with F8. Opening an app URL with a `#statistics/...` hash takes them straight to the deck. ### Scope notes - **Admin tab swap is not in this PR.** #133 says to do the analytics → deck swap together with #130, which moves the calibration tools first so no admin tool becomes unreachable. Admins keep the `analytics` tab (F5) and get the deck on F8 until then. - **The period views (#134–#136)** plug in through `registerView`; the shell renders placeholder panels in each profile's grid until they land. ## Verification - **Frontend tests:** 27 new tests in `tests/frontend/test_statistics_deck.test.js`, run by pytest: - day classification and precedence - badge per profile and tier - detail lines (no internal codes) - period labels in ES/EN, including cross-month and cross-year weeks - default and stepping - deep-link parsing - the full numpad map, including NumLock off (same `code`, different `key`) - graph options and quality-mark geometry - controller behaviour against a fake DOM: deep link vs reload, per-tab choice, persistence surviving a reload, storage that throws, the badge that can't be hidden, viewer controls, and `hide()` restoring the chrome - **Full suite:** `pytest` 416 passed, including the i18n parity and key-resolution tests; `ruff` clean; `check_docs.py` clean. - **Real browser:** Chromium via Playwright against the app running from this worktree on a copy of the dev DB, logged in as operator: - No page scroll, no deck-root scroll, no overflowing cell, a 38 px top bar and no top-bar overflow, in all 9 combinations of Room/Desk/Laptop × Day/Week/Month, at **1920×1080**, **1920×960** and **760×600**. - At **700 px** the deck stacks. - Screenshots of the picker (calendar and list), the quality detail and the help were checked by eye. - No page errors. - **Found and fixed in that browser pass:** - The daily route's `end` is inclusive, while periods carry an exclusive `end`, so the detail panel now requests `end − 1`. A test covers it. - The status strip was overlapping the deck. - The fullscreen glyph is missing from the font; it's now a text label. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replace the #content-statistics placeholder with the RFC #80 deck shell,
as its own modules under app/static/js/src/ui/statistics_deck/:

- statistics_deck.js: controller. A fixed 100dvh frame replaces the app
  header, HUD ribbon, deck selector and status strip. The 38 px top bar
  holds the period button, Day/Week/Month tabs, the data-quality badge,
  Room/Desk/Laptop, ES/EN, fullscreen (a refused request is ignored),
  back-to-ops (logout for the viewer instead) and help. Views plug in via
  registerView(); until then each profile shows its placeholder grid.
- periods.js: client-side labels from the structured fields in both
  languages, latest-complete default, -/+ stepping, and #statistics/<g>/<start>
  deep links. A reload returns to the latest period.
- quality.js: day classification (OK / Estimate / Unreliable) mirroring
  day_quality_state(), the per-profile badge text, and plain-language
  detail lines.
- keyboard.js: numpad map matched by KeyboardEvent.code.
- charts.js: Chart.js options that fill their box and thin labels, plus
  the Estimate dashed-outline / Unreliable hatching plugin.
- translate.js: the deck's own translator for its own language.

Profile and language persist per browser (try/catch-wrapped storage).
statistics-deck.css keeps the grid at Room 12/55/33, Desk 17/50/33 and
Laptop 26/74, and stacks and scrolls below 760 px.

The admin analytics -> deck swap stays with #130, as #133 specifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Viewer checked in a real browser. I added a viewer user to this worktree's copy of the dev DB (the shared dev DB is untouched) and ran Chromium via Playwright:

Check Result
Viewer login Lands straight on the deck, header hidden, no page scroll, deep link written
Top bar for the viewer SALIR / LOG OUT instead of ◂ OPS
F1 / F2 / F5 as viewer No effect; the deck stays
Telemetry for the viewer No WebSocket opened
Viewer clicks log out Login screen shown, deck hidden, link cleared
Operator F8, then ◂ OPS Header and doors deck restored, link cleared

That run found that hide() left the deck section without its hidden class; it was invisible only because its parent was hidden. Fixed in 91cda04, and the unit test now asserts it.

🤖 Generated with Claude Code

**Viewer checked in a real browser.** I added a `viewer` user to this worktree's copy of the dev DB (the shared dev DB is untouched) and ran Chromium via Playwright: | Check | Result | |---|---| | Viewer login | Lands straight on the deck, header hidden, no page scroll, deep link written | | Top bar for the viewer | **SALIR / LOG OUT** instead of **◂ OPS** | | F1 / F2 / F5 as viewer | No effect; the deck stays | | Telemetry for the viewer | No WebSocket opened | | Viewer clicks log out | Login screen shown, deck hidden, link cleared | | Operator F8, then **◂ OPS** | Header and doors deck restored, link cleared | That run found that `hide()` left the deck section without its `hidden` class; it was invisible only because its parent was hidden. Fixed in `91cda04`, and the unit test now asserts it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(statistics-deck): hide() also hides the deck section
All checks were successful
CI / lint-and-test (pull_request) Successful in 2m10s
91cda043f4
Found while checking the viewer in a browser: after logout the section
kept no `hidden` class and only stayed invisible because its parent
container was hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Code review: PR #144 (feat/statistics-deck-shell @ 91cda04 vs master @ 6f4791b)

Standards sources: AGENTS.md, docs/standards/code-standards.md, docs/standards/ui-design-guidelines.md, ADR 0002, git-and-workflow.md, and the Fowler smell baseline. Spec: issue #133 and the accepted RFC docs/architecture/rfc-statistics-deck-display-model.md (§3, §4.1, §5.1, §5.2).

Standards

Verified:

  • Node tests 27/27; node --check clean.
  • XSS-safe: createElement/textContent only, no innerHTML.
  • CSS: zero radius, gap: 1px grids, tabular-nums, token vars, transition: none, no CDN. Help uses <kbd>.
  • The keydown listener is removed on hide and logout, and repeated show() calls don't stack it.
# Pri Finding
S-1 P2 ui-design-guidelines §3.3 and §7 "Semantic Rigidity" put numerical counts in <data value>. The badge counts, the picker coverage (5 / 7 DAYS), the period marks and the calendar day numbers are plain text.
S-2 P3 §5.1 ASCII framing: the quality badge is not framed ([ … ]), while the fullscreen label is.
S-3 P3 charts.js DECK_COLORS hard-codes hex copies of the CSS tokens (possible Duplicated Code). Read them from the computed style instead.
S-4 P2 hide() never runs viewCleanup, and logout drops the deck. Once #134–#136 register views, their Chart instances leak on every logout, and hidden decks keep live charts.
S-5 P2 A failed fetch looks like having no data: loadPeriods .catch(() => []) shows "NO CLOSED PERIODS WITH DATA" during an outage, and selectGranularity then discards the deep-link period.
S-6 P3 A show() that resolves after hide() or logout still runs afterSelection → renderView on a hidden deck. Guard on visible after the await.
S-7 P3 setLanguage doesn't re-render an open panel, so a picker or help panel stays in the old language.
S-8 P3 renderPanel neither awaits nor catches renderQualityDetail, so an error becomes an unhandled rejection.
S-9 P3 Possible Duplicated Code: periodMarks() and the calendar's glyph ternary repeat badgeText's Laptop branch. Extract periodGlyphs() into quality.js.
S-10 — The i18n cleanup is correct (statisticsDeckTitle is still used). No action.
S-11 P3 Possible Repeated Switches: handleKey's switch (action.type) is a second dispatch table beside KEY_ACTIONS.

Spec

Verified:

  • Frame rows per profile; stacks below 760 px.
  • Every top-bar item is present.
  • Picker: calendar and list, covered days, marks.
  • All 12 numpad codes; / help shows the diagram and the legend.
  • Room default, never inferred; language defaults to the app's.
  • Per-tab choice; the deep link restores tab and period; a reload returns to the latest period.
  • Marker is amber only and always on, and its precedence is identical to day_quality_state(). Badge text matches the §4.1 examples.
  • Chart.js is vendored; no confidence bands.
# Pri Finding
C-1 P2 "for admins, the deck replaces the analytics tab and F5 goes to the deck. Do the swap together with #130." Deferring is sound, but the PR says "Closes #133" while #130's Acceptance doesn't carry the swap or the F5 remap, so that acceptance line would close unmet and untracked.
C-2 P3 "any closed period with some data can be picked": limit=100 is the route's maximum, so the Day calendar and -/+ stop about 100 days back, and older deep links silently fall back to the latest period.
C-3 P3 Scope creep, but sound: the viewer LOG OUT control (the header holding the viewer's only logout is hidden; RFC §5.2 allowlists logout), hiding the status strip, Escape, and the #statistics hash at login. No action.
C-4 P2 "closed business periods in facility time", "counter gap 14:00–15:10": gap times use the browser's zone (getHours()), while the server uses facility_zone(). A presenter laptop in another zone shows wrong times.
C-5 P3 The backend lists fully closed periods (business_days == 0) "so the picker shows it as closed". The picker shows no closed indication.
C-6 P3 "badge: full text in Room and Desk": the badge can shrink in the one-line 38 px bar, so a long Room/Desk badge may be cut off in a narrow window. Check with a marked fixture.

Summary. Standards: 10 actionable findings, worst S-4 (view charts leak on hide and logout). Spec: 5 actionable findings, worst C-4 (gap times in the browser's zone instead of the facility's). First pass, so all findings get fixed on this branch.

🤖 Generated with Claude Code

# Code review: PR #144 (`feat/statistics-deck-shell` @ 91cda04 vs `master` @ 6f4791b) **Standards** sources: AGENTS.md, `docs/standards/code-standards.md`, `docs/standards/ui-design-guidelines.md`, ADR 0002, `git-and-workflow.md`, and the Fowler smell baseline. **Spec**: issue #133 and the accepted RFC `docs/architecture/rfc-statistics-deck-display-model.md` (§3, §4.1, §5.1, §5.2). ## Standards Verified: - Node tests 27/27; `node --check` clean. - XSS-safe: `createElement`/`textContent` only, no `innerHTML`. - CSS: zero radius, `gap: 1px` grids, tabular-nums, token vars, `transition: none`, no CDN. Help uses `<kbd>`. - The keydown listener is removed on hide and logout, and repeated `show()` calls don't stack it. | # | Pri | Finding | |---|---|---| | S-1 | **P2** | ui-design-guidelines §3.3 and §7 "Semantic Rigidity" put numerical counts in `<data value>`. The badge counts, the picker coverage (`5 / 7 DAYS`), the period marks and the calendar day numbers are plain text. | | S-2 | P3 | §5.1 ASCII framing: the quality badge is not framed (`[ … ]`), while the fullscreen label is. | | S-3 | P3 | `charts.js` `DECK_COLORS` hard-codes hex copies of the CSS tokens (possible Duplicated Code). Read them from the computed style instead. | | S-4 | **P2** | `hide()` never runs `viewCleanup`, and logout drops the deck. Once #134–#136 register views, their Chart instances leak on every logout, and hidden decks keep live charts. | | S-5 | **P2** | A failed fetch looks like having no data: `loadPeriods` `.catch(() => [])` shows "NO CLOSED PERIODS WITH DATA" during an outage, and `selectGranularity` then discards the deep-link period. | | S-6 | P3 | A `show()` that resolves after `hide()` or logout still runs `afterSelection` → `renderView` on a hidden deck. Guard on `visible` after the await. | | S-7 | P3 | `setLanguage` doesn't re-render an open panel, so a picker or help panel stays in the old language. | | S-8 | P3 | `renderPanel` neither awaits nor catches `renderQualityDetail`, so an error becomes an unhandled rejection. | | S-9 | P3 | Possible Duplicated Code: `periodMarks()` and the calendar's glyph ternary repeat `badgeText`'s Laptop branch. Extract `periodGlyphs()` into `quality.js`. | | S-10 | — | The i18n cleanup is correct (`statisticsDeckTitle` is still used). No action. | | S-11 | P3 | Possible Repeated Switches: `handleKey`'s `switch (action.type)` is a second dispatch table beside `KEY_ACTIONS`. | ## Spec Verified: - Frame rows per profile; stacks below 760 px. - Every top-bar item is present. - Picker: calendar and list, covered days, marks. - All 12 numpad codes; `/` help shows the diagram and the legend. - Room default, never inferred; language defaults to the app's. - Per-tab choice; the deep link restores tab and period; a reload returns to the latest period. - Marker is amber only and always on, and its precedence is identical to `day_quality_state()`. Badge text matches the §4.1 examples. - Chart.js is vendored; no confidence bands. | # | Pri | Finding | |---|---|---| | C-1 | **P2** | *"for admins, the deck replaces the analytics tab and F5 goes to the deck. Do the swap together with #130."* Deferring is sound, but the PR says "Closes #133" while #130's Acceptance doesn't carry the swap or the F5 remap, so that acceptance line would close unmet and untracked. | | C-2 | P3 | *"any closed period with some data can be picked"*: `limit=100` is the route's maximum, so the Day calendar and `-`/`+` stop about 100 days back, and older deep links silently fall back to the latest period. | | C-3 | P3 | Scope creep, but sound: the viewer LOG OUT control (the header holding the viewer's only logout is hidden; RFC §5.2 allowlists logout), hiding the status strip, Escape, and the `#statistics` hash at login. No action. | | C-4 | **P2** | *"closed business periods in facility time"*, *"counter gap 14:00–15:10"*: gap times use the browser's zone (`getHours()`), while the server uses `facility_zone()`. A presenter laptop in another zone shows wrong times. | | C-5 | P3 | The backend lists fully closed periods (`business_days == 0`) *"so the picker shows it as closed"*. The picker shows no closed indication. | | C-6 | P3 | *"badge: full text in Room and Desk"*: the badge can shrink in the one-line 38 px bar, so a long Room/Desk badge may be cut off in a narrow window. Check with a marked fixture. | --- **Summary.** Standards: 10 actionable findings, worst **S-4** (view charts leak on hide and logout). Spec: 5 actionable findings, worst **C-4** (gap times in the browser's zone instead of the facility's). First pass, so all findings get fixed on this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(statistics-deck): address code-review findings for PR #144
All checks were successful
CI / lint-and-test (pull_request) Successful in 2m12s
962f4657e1
Standards
- Counts render inside <data value> (badge, coverage, marks, calendar days).
- hide() runs the view cleanup, so registered views release their charts.
- A failed period load shows an error state, keeps the deep-linked period,
  and retries on the next tab pick. It is no longer shown as "no data".
- Guards against rendering after hide(); setLanguage re-renders an open
  panel; a failing quality-detail render is caught.
- periodGlyphs()/worstGlyph() in quality.js replace the duplicated glyph
  logic. Key actions dispatch through one table instead of a switch.
- Graph colours are resolved from the CSS tokens; hex values are fallbacks.

Spec
- Gap clock labels are server-formatted in facility time
  (GapInterval.start_label/end_label), not the browser's zone.
- GET /api/statistics/periods takes `before`, so the deck pages to older
  periods: from the picker, when stepping past the oldest, and for older
  deep links.
- Fully closed periods are labelled closed in the picker, calendar and view.
- The quality badge never shrinks; the period range gives way instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review fixes: 962f465

Every finding from the review is addressed, except S-2, which is kept on purpose (reason below).

Standards

# Resolution
S-1 (P2) Fixed. withData() wraps every number in <data value>: badge counts, picker coverage, period marks, calendar day numbers. Tested.
S-2 Kept on purpose. RFC §4.1 gives the badge's exact wording (◆ 2 OF 7 DAYS UNRELIABLE · 1 ESTIMATED, Laptop ◆ 2 · ◇ 1), and #133's acceptance ties to it. The RFC is the more specific spec, so it outranks the general §5.1 framing rule. The badge is still visually framed by its 1 px amber border.
S-3 Fixed. resolveDeckColors() reads --color-warning-amber, --color-text-muted and --color-border-grid from the computed style; the hex values remain only as fallbacks. Tested.
S-4 (P2) Fixed. disposeView() runs the view cleanup on every re-render and in hide() (logout calls hide()). registerView(g, null) unregisters a view. Tested with a registered view.
S-5 (P2) Fixed. A failed load leaves tab.error set and returns null, and it is not cached. The top bar, view and picker show "PERIODS COULD NOT BE LOADED · PICK THE TAB TO RETRY"; the deep-linked period is kept, and picking the tab retries. Tested (fail, then recover).
S-6 Fixed. selectGranularity and step check visible after each await, and afterSelection returns early when hidden. Tested (show() resolving after hide() writes nothing).
S-7 Fixed. setLanguage re-renders the open panel. Tested.
S-8 Fixed. renderPanel catches renderQualityDetail failures and shows the "could not be loaded" line.
S-9 Fixed. periodGlyphs() and worstGlyph() in quality.js are used by the badge (Laptop), the picker marks and the calendar. periodMarks() is gone. Tested.
S-10 No action (the reviewer confirmed it is correct).
S-11 Fixed. ACTION_HANDLERS maps each keyboard.js action type to a deck method; the switch is gone.

Spec

# Resolution
C-1 (P2) Fixed by tracking. #130's Acceptance now carries the admin swap: analytics leaves the admin decks, F5 opens the deck, and no admin tool becomes unreachable. So "Closes #133" leaves nothing untracked.
C-2 Fixed. GET /api/statistics/periods takes before=<start> and returns periods older than it (backend test added). The deck pages in older periods from the picker (LOAD OLDER), when − steps past the oldest loaded period, and for deep links beyond the first page (up to 10 pages). Tested with 105 days.
C-3 No action (scope accepted as sound).
C-4 (P2) Fixed. GapInterval gains computed start_label/end_label, formatted by the server in facility_zone() (DST-safe, the same approach as hourly label). The deck prints those labels and no longer calls getHours(). Backend test: a 10:00 ± 60 s gap → 09:59–10:01.
C-5 Fixed. business_days == 0 periods show CLOSED in the list, get a struck-through calendar day with a title, and show a CLOSED notice in the view. Tested.
C-6 Fixed. The badge is flex: 0 0 auto; the period range shrinks with an ellipsis instead. Re-checked in Chromium with the dev data's marked week (◆ 7 DE 7 DÍAS NO FIABLES).

Verification:

  • Frontend tests: 36 (9 new).
  • pytest: 416 passed, including the extended test_statistics_periods.py. ruff and check_docs are clean.
  • Playwright re-run over 37 viewport × profile × period combinations, now including 1024×700: no page or root scroll, no overflowing cell, a 38 px top bar, no top-bar overflow, and a badge that is never cut.

🤖 Generated with Claude Code

## Review fixes: 962f465 Every finding from the [review](https://git.gaboggamer.online/gabogg/hikcentral/pulls/144#issuecomment-2627) is addressed, except S-2, which is kept on purpose (reason below). ### Standards | # | Resolution | |---|---| | S-1 (P2) | **Fixed.** `withData()` wraps every number in `<data value>`: badge counts, picker coverage, period marks, calendar day numbers. Tested. | | S-2 | **Kept on purpose.** RFC §4.1 gives the badge's exact wording (`◆ 2 OF 7 DAYS UNRELIABLE · 1 ESTIMATED`, Laptop `◆ 2 · ◇ 1`), and #133's acceptance ties to it. The RFC is the more specific spec, so it outranks the general §5.1 framing rule. The badge is still visually framed by its 1 px amber border. | | S-3 | **Fixed.** `resolveDeckColors()` reads `--color-warning-amber`, `--color-text-muted` and `--color-border-grid` from the computed style; the hex values remain only as fallbacks. Tested. | | S-4 (P2) | **Fixed.** `disposeView()` runs the view cleanup on every re-render and in `hide()` (logout calls `hide()`). `registerView(g, null)` unregisters a view. Tested with a registered view. | | S-5 (P2) | **Fixed.** A failed load leaves `tab.error` set and returns `null`, and it is not cached. The top bar, view and picker show "PERIODS COULD NOT BE LOADED · PICK THE TAB TO RETRY"; the deep-linked period is kept, and picking the tab retries. Tested (fail, then recover). | | S-6 | **Fixed.** `selectGranularity` and `step` check `visible` after each await, and `afterSelection` returns early when hidden. Tested (`show()` resolving after `hide()` writes nothing). | | S-7 | **Fixed.** `setLanguage` re-renders the open panel. Tested. | | S-8 | **Fixed.** `renderPanel` catches `renderQualityDetail` failures and shows the "could not be loaded" line. | | S-9 | **Fixed.** `periodGlyphs()` and `worstGlyph()` in `quality.js` are used by the badge (Laptop), the picker marks and the calendar. `periodMarks()` is gone. Tested. | | S-10 | No action (the reviewer confirmed it is correct). | | S-11 | **Fixed.** `ACTION_HANDLERS` maps each `keyboard.js` action type to a deck method; the switch is gone. | ### Spec | # | Resolution | |---|---| | C-1 (P2) | **Fixed by tracking.** #130's Acceptance now carries the admin swap: `analytics` leaves the admin decks, F5 opens the deck, and no admin tool becomes unreachable. So "Closes #133" leaves nothing untracked. | | C-2 | **Fixed.** `GET /api/statistics/periods` takes `before=<start>` and returns periods older than it (backend test added). The deck pages in older periods from the picker (**LOAD OLDER**), when `−` steps past the oldest loaded period, and for deep links beyond the first page (up to 10 pages). Tested with 105 days. | | C-3 | No action (scope accepted as sound). | | C-4 (P2) | **Fixed.** `GapInterval` gains computed `start_label`/`end_label`, formatted by the server in `facility_zone()` (DST-safe, the same approach as hourly `label`). The deck prints those labels and no longer calls `getHours()`. Backend test: a 10:00 ± 60 s gap → `09:59`–`10:01`. | | C-5 | **Fixed.** `business_days == 0` periods show **CLOSED** in the list, get a struck-through calendar day with a title, and show a CLOSED notice in the view. Tested. | | C-6 | **Fixed.** The badge is `flex: 0 0 auto`; the period range shrinks with an ellipsis instead. Re-checked in Chromium with the dev data's marked week (`◆ 7 DE 7 DÍAS NO FIABLES`). | **Verification:** - Frontend tests: 36 (9 new). - `pytest`: 416 passed, including the extended `test_statistics_periods.py`. `ruff` and `check_docs` are clean. - Playwright re-run over 37 viewport × profile × period combinations, now including 1024×700: no page or root scroll, no overflowing cell, a 38 px top bar, no top-bar overflow, and a badge that is never cut. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Code review, second pass: PR #144 (feat/statistics-deck-shell @ 962f465 vs master @ 6f4791b)

Both axes confirm that the first-pass fixes (S-1, S-3–S-11, C-1–C-6) are correct and introduced no regressions. S-2 (kept on purpose) was not re-raised.

Standards

Verified:

  • 36/36 Node tests pass; node --check is clean.
  • loadPeriods and loadOlder share tab.loading safely: loadOlder returns early until the first page exists, so neither gets the other's promise back with the wrong shape.
  • The paging loop ends on failure and when exhausted, and every await re-checks visible.
  • ACTION_HANDLERS covers all 10 action types.
# Pri Finding
S2-1 P2 AGENTS.md §2 "Validate input strictly": before is not checked for period alignment. ?granularity=week&before=2026-09-24 (a Thursday) builds Thursday–Wednesday "weeks" with the wrong iso_week. The deck only sends aligned starts, but the route is public.
S2-2 P3 A failed LOAD OLDER or − past the oldest period fails silently: loadOlder .catch(() => null) sets no error state. This is the same pattern S-5 fixed for the first page.
S2-3 P3 Possible Divergent Change: gap labels are formatted in the schema (computed_field, with an import of facility_time). The sibling HourlyVisitors.label is formatted in the service.
S2-4 P3 currentDeckColors() and cachedHatch are cached once per page. That holds only while there is no theme switch; the assumption should be stated.
S2-5 P3 Possible Data Clumps: the loading state (loading, exhausted, error) is spread across two loaders that share one slot, relying on the unstated invariant "periods === null ⇔ first page".

Spec

Verified:

  • C-1: #130's Acceptance carries the admin swap.
  • C-2: before paging reaches older periods from the picker, from −, and from deep links (up to about 1,100 days on the Day tab).
  • C-4: gap labels come from facility_now(), clipped to the reset boundary.
  • C-5: closed periods are labelled.
  • C-6: the badge never shrinks.
  • No other consumer of these contracts breaks, since the changes are additive.
# Pri Finding
C2-1 P3 docs/api/README.md doesn't mention the gap start_label/end_label fields or the before query param.
C2-2 P3 Same root as S2-1: a week before that isn't a Monday reaches _week_period_end and raises ValueError, which comes back as a 500 because list_periods lacks handle_controller_errors(). Month and day are unaffected.

Summary. Standards: 5 findings, worst S2-1 (unvalidated before alignment, P2). Spec: 2 findings, both P3; worst C2-2 (the same root, surfacing as a 500).

Next steps, per the review policy: fix S2-1/C2-2 on this branch. The remaining P3s (S2-2, S2-3, S2-4, S2-5, C2-1) move to a follow-up issue.

🤖 Generated with Claude Code

# Code review, second pass: PR #144 (`feat/statistics-deck-shell` @ 962f465 vs `master` @ 6f4791b) Both axes confirm that the first-pass fixes (S-1, S-3–S-11, C-1–C-6) are correct and introduced no regressions. S-2 (kept on purpose) was not re-raised. ## Standards Verified: - 36/36 Node tests pass; `node --check` is clean. - `loadPeriods` and `loadOlder` share `tab.loading` safely: `loadOlder` returns early until the first page exists, so neither gets the other's promise back with the wrong shape. - The paging loop ends on failure and when exhausted, and every await re-checks `visible`. - `ACTION_HANDLERS` covers all 10 action types. | # | Pri | Finding | |---|---|---| | S2-1 | **P2** | AGENTS.md §2 "Validate input strictly": `before` is not checked for period alignment. `?granularity=week&before=2026-09-24` (a Thursday) builds Thursday–Wednesday "weeks" with the wrong `iso_week`. The deck only sends aligned starts, but the route is public. | | S2-2 | P3 | A failed **LOAD OLDER** or `−` past the oldest period fails silently: `loadOlder` `.catch(() => null)` sets no error state. This is the same pattern S-5 fixed for the first page. | | S2-3 | P3 | Possible Divergent Change: gap labels are formatted in the schema (`computed_field`, with an import of `facility_time`). The sibling `HourlyVisitors.label` is formatted in the service. | | S2-4 | P3 | `currentDeckColors()` and `cachedHatch` are cached once per page. That holds only while there is no theme switch; the assumption should be stated. | | S2-5 | P3 | Possible Data Clumps: the loading state (`loading`, `exhausted`, `error`) is spread across two loaders that share one slot, relying on the unstated invariant "`periods === null` ⇔ first page". | ## Spec Verified: - **C-1:** #130's Acceptance carries the admin swap. - **C-2:** `before` paging reaches older periods from the picker, from `−`, and from deep links (up to about 1,100 days on the Day tab). - **C-4:** gap labels come from `facility_now()`, clipped to the reset boundary. - **C-5:** closed periods are labelled. - **C-6:** the badge never shrinks. - No other consumer of these contracts breaks, since the changes are additive. | # | Pri | Finding | |---|---|---| | C2-1 | P3 | `docs/api/README.md` doesn't mention the gap `start_label`/`end_label` fields or the `before` query param. | | C2-2 | P3 | Same root as S2-1: a `week` `before` that isn't a Monday reaches `_week_period_end` and raises `ValueError`, which comes back as a **500** because `list_periods` lacks `handle_controller_errors()`. Month and day are unaffected. | --- **Summary.** Standards: 5 findings, worst **S2-1** (unvalidated `before` alignment, P2). Spec: 2 findings, both P3; worst **C2-2** (the same root, surfacing as a 500). **Next steps, per the review policy:** fix S2-1/C2-2 on this branch. The remaining P3s (S2-2, S2-3, S2-4, S2-5, C2-1) move to a follow-up issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(statistics): reject a misaligned before on the periods route (#144 review)
All checks were successful
CI / lint-and-test (pull_request) Successful in 2m7s
6dcb9562f8
A `before` that is not a period start (a non-Monday week, a month not
starting on the 1st) built shifted "weeks" or raised a ValueError that came
back as a 500. The service now validates alignment through
statistics_period_end(), and the route maps it to 422 VALIDATION_ERROR via
handle_controller_errors().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

S2-1 / C2-2 fixed in 6dcb956. The service validates that before is a period start via statistics_period_end(), and list_periods now runs inside handle_controller_errors(), so a non-Monday week or a month cursor not on the 1st gets 422 VALIDATION_ERROR instead of shifted weeks or a 500. There's a new test for both; the full suite passes (416).

The remaining P3s (S2-2, S2-3, S2-4, S2-5, C2-1) are filed as a follow-up issue.

**S2-1 / C2-2 fixed in 6dcb956.** The service validates that `before` is a period start via `statistics_period_end()`, and `list_periods` now runs inside `handle_controller_errors()`, so a non-Monday week or a month cursor not on the 1st gets **422 `VALIDATION_ERROR`** instead of shifted weeks or a 500. There's a new test for both; the full suite passes (416). The remaining P3s (S2-2, S2-3, S2-4, S2-5, C2-1) are filed as a follow-up issue.
gabogg merged commit 08b6d1c0e6 into master 2026-09-26 22:37:17 +00:00
gabogg deleted branch feat/statistics-deck-shell 2026-09-26 22:37:17 +00:00
Sign in to join this conversation.
No description provided.