feat(statistics-deck): Month view (Room, Desk, Laptop) (#136) #147
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!147
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/deck-month-view"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #136
Problem
The statistics deck shell (#133, PR #144) has a Month tab, but it only showed placeholder panels. RFC #80 §4 defines what the Month view shows: six ranked KPIs and five ranked graphs, laid out separately for each of the three profiles, with the data-quality marker on per-day graphs.
Approach
app/static/js/src/ui/statistics_deck/views/month_view.js. It callsregisterView('month', renderMonth)when imported, and index.html imports it next to the StatisticsDeck import. Everything that maps route JSON to KPIs or graph inputs is an exported pure function:mapMonthKpis(driven by a KPI spec table),comparisonLine,line,hasLastYear,monthDaySeries,runningTotals,previousTotalReason,weekdayAverages,mapEntrances,peakDay,barTiers, and the config buildersperDayBarConfig,runningTotalConfigandweekdayConfig.MONTH_LAYOUT), each smaller profile keeping the top of the ranking:ctx.loadDays()for the month andctx.loadPeriodDays(ctx.previousPeriod())for the previous month (#150). Both reject on failure, and the affected cells then show an error.ctx.createChart(deck locale on the ticks), and numbers usectx.localewith a memoisedIntl.NumberFormat.tiersto the shell'sdeckQualityMarksplugin.sdmDayMarksplugin shades Closed Days as closed (not missing). It hatches open days with no data in the shared Unreliable style, because they have no bar for the shared plugin to mark. It also draws a short bar at the top of each holiday.previous.reason === 'PARTIAL_PERIOD'(RFC §5.3), and the legend gives that reason.basis: DAILY_AVERAGE. The last-year line appears only when last year has data (RFC §5.1); otherwise it is hidden, not shown as "—".<data value>wraps only real numbers. The covered-days note and the "+N MORE" line use the shell'sformatCoverageandwithData.app/static/css/statistics-deck-month.css, with view-scopedsdm-*classes and colours from tokens. i18n (es and en, full parity) is understatisticsDeck.views.month.*andstatisticsDeck.comparison.reason.<CODE>(all 10 codes), placed at the end of eachstatisticsDeckblock.Verification
tests/frontend/test_statistics_deck_month.test.jshas 34 node:test cases. They cover:<data>only on numbers, and the counts in the coverage and "+N MORE" notes;loadDays, an unreadable payload, a throwing chart builder (no unhandled rejection), and cleanup;TZ=America/Caracas pytest -q: 416 passed.ruff check .,ruff format --check .andscripts/check_docs.pyare clean./periods?granularity=monthreturns[], and the live Month tab shows the shell's "no closed periods with data" notice, which I checked. For the layout runs, Playwright served the month routes from fixtures. The login, shell, top bar and badge were live.Decisions
previous_month_daily_average, e.g.PREV. MONTH 968/DAY), or "—" with its reason. The route always sends this value, even when the visitors comparison is withheld as PARTIAL_PERIOD, so the tile can put two daily averages side by side over unequal coverage. The view does not compute a percentage from them.VISITORS VS PREV. MONTH. I did not derive a previous-month share on the client, because an entrance present only last month is missing from the response and would skew the total. At most 8 entrances are listed, with a "+N MORE" line for the rest.peak_timestamp_epochmatches the route's peak exactly (tie-proof). That row must be covered and not excluded. Without a timestamp, the label falls back to the first reliable row with the same value..sd-root[data-granularity="month"] .sd-view[data-profile="room"]), not varied by viewport. It holds down to 600 px of height, where the shell's 12% KPI row cannot fit a value and two lines. RFC §3.5 allows a view to adjust its split.Known duplication left for consolidation
These are local to
month_view.jsand are candidates to share with the Day and Week views: KPI tile rendering (kpiTile,lineEl,valueEl),line,comparisonLine,reasonText,formatNumber,formatPercent,formatChange,formatDwell, and the entrance share list (mapEntrancesplusfillEntrances). The i18n keys understatisticsDeck.comparison.reason.*may collide with the sibling PRs.The shell modules were not changed. The branch is rebased on #150 (
fix/deck-shell-view-support), so until #150 merges this PR's diff against master also shows #150's commits.🤖 Generated with Claude Code
Code review: PR #147 (
feat/deck-month-view@128ab91vsmaster@08b6d1c)Standards sources: AGENTS.md,
docs/standards/code-standards.md,docs/standards/ui-design-guidelines.md, ADR 0002, the deck shell's exported helpers, and the Fowler smell baseline. Duplication across the Day, Week and Month views is known and will be consolidated after merge, so it isn't flagged here. Spec: #136 and RFC §3–§5.Standards
Verified:
h(... text:).disposedguard.loadDays.registerView,createDeckChart,deckQualityMarkstiers,classifyDay,hatchPattern) are used correctly.Promise.allSettled([...]).then(... fillMonth ...)has no.catch. A mapping error on an unexpected payload becomes an unhandled rejection, and every cell stays on LOADING.formatCoverageand drops<data>(ui-design-guidelines §3.3). The "+N more" text (L721) has the same gap. UsewithData(doc, formatCoverage(period, tr)).period.business_days === 0re-implements the shell'sisClosedPeriod().rootStyle()/token()(L330–353) copycharts.js. The CSS hard-codesrgba(69, 79, 94, 0.35)(L182) instead of a token (§2.1). Extend the shell'sCOLOR_TOKENS.monthUrls().previousDaysrebuilds the daily-route URL, andcachedJsonis a second per-URL cache next to the shell'sloadDays. Letctx.loadDays(period)take another period.dayByDayConfigandpeakPerDayConfigdiffer only in field and colour. Use oneperDayBarConfig(series, field, color).{label, value, missing, reason, perDay, change}literal appears 6 times, andmapMonthKpisrepeats one if/else pattern six times. Use aline()factory or a table.options.locale), unlikeformatNumber.Intl.NumberFormatis also rebuilt on every call; memoise it.<data value="">wraps non-numeric text ("—", "1.234/día" with a null change). Use<data>only for real numbers.export const renderMonthView = renderMonth;. L103 has a redundantMath.floor.Spec
Verified:
basisisDAILY_AVERAGE.runningTotalsdraws last month's cumulative line even whensummary.previous.reason === 'PARTIAL_PERIOD', putting raw totals from unequal coverage side by side.views.month.noDatais added in both languages but never used.previous_month_daily_average, and no change is shown on PARTIAL_PERIOD). The PR's claim that the coverage rule "cannot be bypassed" is inaccurate.peak_timestamp_epoch === peak.timestamp_epochwould be exact and tie-proof.@mediaswitch sits uneasily with §2/§3.3. Prefer one fixed Room split that holds down to about 600 px, or record the allowance in the RFC.Summary. Standards: 11 findings, worst S-1 (an unhandled rejection leaves every cell on LOADING). Spec: 7 actionable findings, worst C-1 (the running total compares raw totals across unequal coverage). First pass, so all findings get fixed on this branch. S-4, S-5 and S-9 build on a small shell-support PR that is being prepared (color tokens,
ctx.loadDays(period), chart locale).🤖 Generated with Claude Code
128ab914f25d97b52ea5First-pass review: all findings fixed in
5d97b52The branch is rebased on #150 (
fix/deck-shell-view-support) and builds on its shell APIs. Until #150 merges, the diff against master also shows #150's commits..catch. Any failure while filling the view, such as an unreadable payload or a throwing chart builder, replaces every cell with the error notice. Tests cover a throwing payload getter and a throwingcreateChart, the latter with no unhandled rejection.withData(doc, formatCoverage(period, tr)), and "+N MORE" useswithDatatoo. A test checks their<data>values.rootStyle()/token(). The view uses the shell'sdim/primary/cyan, andmonthColorsonly adds the closed shade fromdim. The CSS swatch usescolor-mix(var(--color-text-dim) 35%). A test asserts that the CSS has norgba(.ctx.loadDays()andctx.loadPeriodDays(ctx.previousPeriod()).monthUrls().previousDaysandcachedJsonare gone. A rejectedloadDaysshows the error in the per-day graphs, and this is tested.perDayBarConfig(series, field, color, colors)replacesdayByDayConfig/peakPerDayConfig.line()factory builds every sub-line, andmapMonthKpisis driven by aKPI_SPECStable.ctx.createChart(deck locale on the ticks). Numbers usectx.locale, andIntl.NumberFormatis memoised per locale and number of decimals.<data value>is used only for real numbers, via anumberfield on each line; "—" and day labels are<span>. A test checks that every<data>value is numeric.renderMonthis exported directly (no alias), and the redundantMath.flooris gone.previousTotalReason(summary): whensummary.previous.reason === 'PARTIAL_PERIOD', the previous month's line is not drawn and the legend reads "PREV. MONTH — too few days covered to compare". Tested as a unit and rendered.views.month.noData(es and en). A test asserts it is absent.VISITORS VS PREV. MONTH, both in the column note and on each comparison line.peakDaymatchespeak_timestamp_epochexactly among covered, non-excluded rows. It falls back to the value only when the route gives no timestamp. Tested with a three-way tie that includes an excluded day.@mediawith one fixed Room split for the month,.sd-root[data-granularity="month"] .sd-view[data-profile="room"]at 17/50/33. It is overridden only by the shell's existing stack-and-scroll rule below 760 px width. A test asserts that the CSS has no height media query.hasLastYearis false for a missinglast_year, forNO_DATA_LAST_YEAR, and for a nullreference_visitors. Tested.Verification:
TZ=America/Caracas pytest -q: 416 passed.ruff check,ruff format --checkandcheck_docsare clean, and the pre-commit hook passed.One process note: while correcting the description for C-4, I briefly overwrote it with another PR's text (my scratchpad file had been replaced). It has been restored with the corrections.
🤖 Generated with Claude Code
5d97b52ea564e1351127Code review, second pass: PR #147 (Month) @
64e1351, rebased on masterAll first-pass fixes (S-1–S-11, C-1–C-8) are verified; 77 node tests pass.
loadDays/loadPeriodDays(previousPeriod())/createChart/locale/currentDeckColors); the shell owns the empty, error and closed states; no workarounds are left.@media.VISITORS VS PREV. MONTH.Standards
showErrorcallschart.destroy()without the try/catch that cleanup uses. A throwing destroy inside.catchbrings back an unhandled rejection.showErrorclears each graph's body but not its note, so leftover legends and coverage notes sit beside the error.Spec
basis: DAILY_AVERAGE), but the running total still draws raw cumulative totals over unequal coverage. For example, with 25 of 31 days covered, the line ends below last month while the tile reads "+5% PER DAY". Withhold the line, or mark it not comparable, onDAILY_AVERAGEtoo, and add a test.Verdict: mergeable after the C2-1 fix. The P3s are filed as a follow-up issue.
🤖 Generated with Claude Code
Second-pass review: C2-1 fixed in
3ba8726previousTotalReasonnow also withholds last month's running-total line when the route compares by daily average (previous.basis === 'DAILY_AVERAGE'), not only onPARTIAL_PERIOD. The legend reads "PREV. MONTH — compared per day, not as a total", and the Visitors tile still shows the route's "+x% PER DAY". A new i18n key,views.month.comparedPerDay, carries that reason in es and en. It is covered by a unit test and a rendered test (a partial month covering 25 of 31 days, compared by daily average).withDatawithData: "18 / 31 DAYS" and "+2 MORE". Every formatted figure (decimals, thousands separators, percentages) is already a single<data value={raw}>. The existing test checks the<data>values ['18','31'] and ['2'], and that every<data>value is numeric.S2-1 and S2-2 are left for the filed follow-up issue.
Verification:
TZ=America/Caracas pytest -q: 416 passed.ruff check,ruff format --checkandcheck_docsare clean.🤖 Generated with Claude Code