From 2c25a9cd17967b0b2988ca41c2b7b1ab4e938a97 Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 3 Jul 2026 16:02:45 +0100 Subject: [PATCH] feat(web): add locale-aware number formatting and redesign filter panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw integer counts with Intl.NumberFormat()-grouped numbers across all SPA pages — stat cards, dashboard stats, list-page total badges, inline reception/observer counts, chart axis ticks and tooltips, map counts, and packet-group-detail fields. Formatting uses the visitor's browser locale (no explicit locale argument), decoupled from the admin's datetime_locale. Redesign the filter panel on all five filter-bearing pages (nodes, packets, advertisements, messages, map): replace the heavy DaisyUI collapse card with a compact right-aligned toggle slider plus bare filter fields rendered below the control row. Filter open-state survives auto-refresh and navigation via DOM-read of #filter-toggle. Also fixes a pre-existing bug where pubkey_prefix was missing from nodes.js hasActiveFilters, causing the filter to not default open when only the public-key-prefix field was filled. --- .../plan.md | 363 ++++++++++++++++++ .../tasks.md | 166 ++++++++ src/meshcore_hub/web/static/js/charts.js | 22 +- .../web/static/js/spa/components.js | 67 ++-- src/meshcore_hub/web/static/js/spa/icons.js | 4 + .../web/static/js/spa/pages/advertisements.js | 36 +- .../web/static/js/spa/pages/dashboard.js | 10 +- .../web/static/js/spa/pages/map.js | 32 +- .../web/static/js/spa/pages/members.js | 6 +- .../web/static/js/spa/pages/messages.js | 36 +- .../web/static/js/spa/pages/nodes.js | 36 +- .../js/spa/pages/packet-group-detail.js | 10 +- .../web/static/js/spa/pages/packets.js | 40 +- 13 files changed, 698 insertions(+), 130 deletions(-) create mode 100644 docs/plans/20260703-1503-regional-numbers-filter-toggle/plan.md create mode 100644 docs/plans/20260703-1503-regional-numbers-filter-toggle/tasks.md diff --git a/docs/plans/20260703-1503-regional-numbers-filter-toggle/plan.md b/docs/plans/20260703-1503-regional-numbers-filter-toggle/plan.md new file mode 100644 index 0000000..5fc5cc5 --- /dev/null +++ b/docs/plans/20260703-1503-regional-numbers-filter-toggle/plan.md @@ -0,0 +1,363 @@ +# Regional number formatting + filter panel redesign + +## Summary + +Two related, independent frontend workstreams that clean up how numbers and the +filter panel are presented across the SPA. + +1. **Regional number formatting.** Every numeric count in the UI — home & dashboard + stat cards, list-page total/shown badges, inline reception/observer counts, and + chart axis ticks + tooltips — currently renders as a raw integer (e.g. `251234`). + A single `formatNumber()` helper backed by `Intl.NumberFormat()` (no explicit + locale) is added so each visitor sees separators matching their browser locale + (`251,234` in en-GB, `251.234` in de-DE). No backend, schema, or i18n-key changes + are required; counts are pre-formatted at call sites before being interpolated. + +2. **Filter panel redesign.** The list pages (nodes, packets, advertisements, + messages) and the map page wrap their filter fields in a heavy DaisyUI + `collapse` card (`bg-base-200 border-2 ... rounded-box mb-6` with a summary row + and `collapse-content pt-4` padding). This is replaced by a compact DaisyUI + slider `toggle` + "Filters" label placed on the **right** of the existing + record-count + auto-refresh control row; when toggled on, the bare filter fields + (no border, no card, no heavy padding) render directly below the control row. + +## Background & Motivation + +**Number formatting.** The frontend renders counts entirely raw. The shared stat +card helper `renderStatCard` emits `${value}` verbatim +(`src/meshcore_hub/web/static/js/spa/components.js:822`); the dashboard's four inline +stat numbers do the same (`pages/dashboard.js:139,160,181,202`); total/shown badges +pass raw ints into `t()` which `String()`-ifies them (`i18n.js:63`); and `charts.js` +has **no** `ticks.callback` on the y-axis and **no** `tooltip.callbacks` — so a +y-value of `12000` prints as `12000` and the tooltip shows the bare number. The only +locale-aware formatting in the codebase is for *dates* (`formatDateTime*` in +`components.js`, and `toLocaleDateString` in `charts.js:108`, the latter hard-coded +to `en-GB`). There is no `Intl.NumberFormat` / `toLocaleString` for numbers +anywhere. `__APP_CONFIG__` exposes `datetime_locale` (admin-controlled, default +`en-US`) at `web/app.py:319`, but this is a single admin-chosen locale rather than +each visitor's region; per-visitor regional formatting should decouple from it. + +**Filter panel.** The collapsible filter was introduced by +`docs/plans/20260505-0900-improve-filter-options/plan.md`, which wrapped the form in +a DaisyUI `
` with `collapsible` / +`defaultOpen` options on `renderFilterCard` (`components.js:774-806`). Open-state +survival across auto-refresh re-renders works by reading `details.collapse.open` +from the DOM before each render (all 5 pages). The observer filter was later moved +*out* of the panel into toggle badges below it +(`docs/plans/20260614-1220-observer-filter-badges/plan.md`), leaving the collapse +card as a relatively heavy wrapper for a few selects. The card's `border-2`, +`rounded-box`, `bg-base-200`, `mb-6`, and `collapse-title`/`collapse-content` +padding consume a lot of visual real estate for what is now a compact form. + +**Recent direction.** Recent UI work (`479c263` "redesign panel accent system", +`7956532` "normalize spacing", `91a3fcf` keyboard accessibility, `510612d` +self-hosted typography) shows an active push toward a tighter, more consistent UI. +This plan continues that direction. No git history touches number formatting or the +filter toggle style, so this is greenfield UI work. + +## Goals + +- Every count rendered in the SPA is formatted with locale-appropriate grouping + separators, driven by the visitor's browser locale (`Intl.NumberFormat()`). +- The filter panel on all five filter-bearing pages (nodes, packets, + advertisements, messages, map) becomes a right-aligned slider toggle in the + existing control row, with bare filter fields rendering below when enabled — no + bordered/padded card. +- Filter open-state continues to survive auto-refresh re-renders and SPA + navigation exactly as it does today. +- No backend, API schema, database, or `__APP_CONFIG__` changes. No new i18n keys + required (reuse existing `common.filters`). + +## Non-Goals + +- No change to `t()` itself — counts are pre-formatted at call sites so the i18n + function stays generic (it interpolates arbitrary params, not just counts). +- Pagination page-number indices (`components.js:520,522`) are left raw — formatting + `1,234` page indices would be wrong. +- Decimal physical values stay on `toFixed()`: SNR (`packet-group-detail.js:186`, + `packet-detail.js:104`), spam score (`messages.js:144`), GPS coords + (`map.js:105`), radio-config tiles (`home.js:31`). +- No per-dialect/backend work; `charts.js` consumes the same JSON shape (confirmed + by `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md`). +- No change to the observer toggle badges (`components.js:638-654`) or their + localStorage plumbing — they already render below the filter and stay in place. +- No new CSS file; DaisyUI `toggle` + existing utilities cover it. +- No CI Postgres matrix; this is frontend-only and the existing `tests/test_web/` + suite (which asserts `__APP_CONFIG__` presence) is the regression gate. + +## Requirements + +### Functional Requirements + +- Stat numbers on home (`renderStatCard` → 6 cards) and dashboard (4 inline + numbers) display with locale grouping (e.g. `12,345`). +- List-page total/shown badges and map count badges show grouped counts inside + their translated strings (e.g. "12,345 total"). +- Inline reception/observer counts on packets, packet-detail, and + packet-group-detail pages, and the observer badge count, are grouped. +- Chart.js y-axis ticks and tooltip body values are grouped. +- On all five filter-bearing pages, a slider toggle labeled "Filters" appears at + the right end of the control row (after the record-count badge and the + auto-refresh pause/play control). +- Toggling the switch on reveals the filter fields directly below the control row, + with no surrounding border, card background, or heavy padding/margin. Toggling + off hides them. +- Filter visibility persists across auto-refresh ticks and across re-renders + triggered by navigation/sort/pagination. +- On a fresh page mount (no prior state), the filter defaults open iff active + filters exist (`hasActiveFilters`), matching current behavior. + +### Technical Requirements + +- **Locale source:** `new Intl.NumberFormat()` with **no** argument — uses the + runtime default locale (`navigator.language`). No explicit locale tag is passed, + so formatting is per-visitor and decoupled from the admin's `datetime_locale`. +- **`formatNumber` contract:** returns `''` for null/undefined/empty, returns the + original `String(value)` for non-finite numbers, otherwise `Intl.NumberFormat().format(n)`. + This makes it safe to wrap values that may be missing. +- **State preservation mechanism is unchanged in spirit:** today each page reads + `container.querySelector('details.collapse').open` before render; the new design + reads `container.querySelector('#filter-toggle').checked` instead. The native + checkbox holds the state in the DOM exactly as `
.open` did — no extra + state plumbing, no refetch on toggle (the toggle's `@change` re-runs the existing + `renderPage(lastContent)` closure). +- **`lit-html` controlled-checkbox safety:** because the value read from the DOM is + the same value bound back via `?checked=${filterOpen}`, the binding is idempotent + and does not fight the native toggle. +- **DaisyUI `toggle`** (slider switch) is loaded already (`@plugin "daisyui"` in + `input.css:2`) but not yet used anywhere in the codebase; this plan introduces it. + It is a DaisyUI component class, so no Tailwind safelist entry is needed. +- **`charts.js` is a classic (non-module) script** loaded at `spa.html:193`, before + the SPA module bundle. To avoid load-order/import issues, `charts.js` defines a + local `formatNumber` (the same `Intl.NumberFormat().format(v)` one-liner) rather + than importing from the module graph. +- **Map page** uses an inline filter (not `renderFilterCard`) and has no + auto-refresh; its toggle's `onChange` wires into the map's existing client-side + `applyFilters`/re-render path (no API refetch needed, since map filters are + applied client-side). +- No Python, HTML structure, or `.env` changes. `tests/test_web/` must still pass + unmodified (it checks `__APP_CONFIG__` and page scaffolding, not JS internals). + +## Implementation Plan + +### Phase 1: `formatNumber` helper + stat numbers + +- **`components.js`** — add `export function formatNumber(value)` near the existing + `formatDateTime*` helpers (~line 193). Implement the contract above. Also expose + `window.formatNumber = formatNumber` (mirrors `window.t = t` at `i18n.js:78`) for + any non-module callers. +- **`components.js:822`** — in `renderStatCard`, `${value}` → `${formatNumber(value)}`. + (Cascades to all 6 home-page stat cards: `home.js:148,155,162,169,205,211`.) +- **`dashboard.js:139,160,181,202`** — wrap the four `${stats.*}` numbers with + `formatNumber(...)`. Add `formatNumber` to the existing `components.js` import. + +### Phase 2: total/shown badges + inline counts + +Wrap counts with `formatNumber(...)` at each render site. Most pass counts as +`t()` interpolation params but some use raw `${}` template expressions — both +patterns get the same `formatNumber(...)` wrapper: + +- `pages/nodes.js:46` +- `pages/packets.js:80`, plus inline `:39` (`observer_count`), `:41` + (`reception_count`) +- `pages/advertisements.js:62` +- `pages/messages.js:227` +- `pages/map.js:309,312,313,331` (set via `element.textContent = t(...)`, counts + formatted inside the `t()` call) +- `pages/members.js:16,106` +- `pages/packet-group-detail.js:172` — `t()` interpolation +- `pages/packet-group-detail.js:304,318` — raw `${reception_count}` or `${observer_count}` in template literal +- `pages/packet-group-detail.js:345-346` — raw `${g.reception_count}` and `${g.observer_count}` in template literal +- `components.js:558` (`observerIcons` badge count, raw `${observers.length}`) + +e.g. `t('common.total', { count: formatNumber(displayTotal) })` for `t()` call +sites; `${formatNumber(oc)}` for raw template literal sites. + +### Phase 3: Chart axis + tooltip formatting + +- **`charts.js`** — add a module-local `function formatNumber(v)` (one-liner). +- In `createChartOptions()` (`charts.js:49-98`): + - **y-axis ticks** (`:86-89`): add + `callback: function(value) { return formatNumber(value); }` alongside + `precision: 0`. + - **tooltip** (`:63-71`): add a `callbacks` object with a `label` function that + formats `ctx.parsed.y` via `formatNumber(...)` (prefixed by the dataset label, + preserving Chart.js's default label style). + +### Phase 4: Filter component refactor (`components.js` + `icons.js`) + +- **`icons.js`** — add a new `iconFilter` SVG function (funnel icon, consistent + with existing Heroicon-style patterns in the file). All 40 existing icons follow + the same signature; match that pattern. +- **Replace** `renderFilterCard()` (`components.js:774-806`) with two exports. + Add `iconFilter` to the `../icons.js` import in `components.js`. + - `renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel })` — + returns **only** the `
` (fields + submit/clear buttons), no `
`, + no card, no border, no `mb-6`. Reuses the existing `createFilterHandler` / + `autoSubmit` / `submitOnEnter` helpers (`components.js:665-700`) unchanged. + - `renderFilterToggle({ open, onChange })` — returns the right-side control: + ```html + + ``` +- Keep the `createFilterHandler` / `autoSubmit` / `submitOnEnter` helpers as-is. + +### Phase 5: Four shared list pages + +For each of nodes, packets, advertisements, messages: + +- Replace the state-read lines (e.g. `nodes.js:176-177`): + ```js + const existingToggle = container.querySelector('#filter-toggle'); + const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; + ``` +- **`nodes.js:175`** — fix `hasActiveFilters` to also check `pubkey_prefix`: + ```js + const hasActiveFilters = search !== '' || adv_type !== '' || pubkey_prefix !== '' || (config.oidc_enabled && adopted_by !== ''); + ``` + The public-key-prefix text field was historically omitted from the + hasActiveFilters check (a pre-existing bug); fixing it here ensures the new + toggle defaults open when it is filled. +- Drop the `renderFilterCard({ collapsible: true, ... })` call (e.g. + `nodes.js:179-185`). +- Restructure the control row so the toggle sits right (e.g. `nodes.js:44-50`): + ```html +
+ ${displayTotal !== null ? html`${t('common.total', { count: formatNumber(displayTotal) })}` : nothing} + ${error ? warningBadge(error) : nothing} +
+ + ${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })} +
+
+ ${filterOpen ? renderFilterForm({ fields: filterFields, basePath: '/nodes', navigate }) : nothing} + ``` +- Add `function onFilterToggle() { renderPage(lastContent); }` — reuses the page's + existing `renderPage` closure (calls `litRender`). Because `#filter-toggle.checked` + is read fresh at the top of each render, the toggle drives visibility with no + refetch. + +**Files & line refs:** `pages/nodes.js` (44-51, 176-185), `pages/packets.js` +(73-86, 176-185), `pages/advertisements.js` (55-68, 250-259), `pages/messages.js` +(220-233, 426-435). + +> Note: the advertisements and messages pages render observer toggle badges below +> the filter form (`advertisements.js:269,285`; `messages.js:445,463`). Their +> placement is unchanged; they naturally sit below the new inline form. + +### Phase 6: Map page + +**File:** `pages/map.js:185-246`. + +- State-read `:187-188` → `container.querySelector('#filter-toggle')?.checked ?? false`. + Unlike list pages, the map has no `hasActiveFilters` check — its selects all + default to neutral/empty values, so the filter is never "active" on fresh mount. + This matches current map behavior (the `
` was always default-closed). +- Add `renderFilterToggle` into the existing right-side badge group inside the + header (the `` / `` block at + `map.js:193-197`). `onChange` calls the map's existing client-side + re-render/`applyFilters` path (no API refetch — map filters are applied + client-side). +- Add `renderFilterToggle` to the imports from `../components.js`. Add + `renderFilterForm` to the same import (the map renders its own field layout, + so `renderFilterForm` import is only needed if map adopts the shared form; + otherwise just import `renderFilterToggle`). +- Replace the `
` block (`map.js:200-246`) with a bare + `
` + containing the same fieldsets/selects/checkbox/clear button — drop `collapse`, + `bg-base-200`, `border-2 border-base-content/25`, `rounded-box`, + `collapse-title`, `collapse-content pt-4`. The `hidden` class toggles visibility. + +### Phase 7: Build + verify + +- Frontend assets build into the Docker image (no local `npm` step per `AGENTS.md`): + `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core build`. +- `pytest --no-cov tests/test_web/` — confirm `__APP_CONFIG__` assertions and page + scaffolding still pass (no Python/HTML-structure changes, so expected green). +- `pre-commit run --all-files`. + +## Verification + +1. **Stack up:** `docker compose -f docker-compose.yml -f docker-compose.dev.yml + --profile core up -d`. +2. **Numbers (en-GB browser):** home & dashboard stat cards, list total badges, + map count badges, packet reception/observer counts, and chart y-axis + tooltips + show thousands separators (e.g. `12,345`). In a `de-DE` browser, the same values + show `12.345`. +3. **Filters (nodes, packets, ads, messages, map):** the slider toggle appears at + the right of the count + auto-refresh control; toggling on shows the bare fields + below (no border/padded box); toggling off hides them; open state survives an + auto-refresh tick (wait one interval on nodes/ads/etc.) and survives a sort/page + change; on a fresh navigation the panel opens iff filters are active. +4. **Regression:** `pytest --no-cov tests/test_web/` green; `pre-commit run + --all-files` green. +5. **Sanity:** in devtools, `(1234567).toLocaleString()` returns the expected + grouped string for the active browser locale. + +## Review + +**Status**: Approved with Changes + +**Reviewed**: 2026-07-03 + +### Resolutions + +- **No filter icon exists in icons.js**: Confirmed — 40 icons, none are filter/funnel. + Added `iconFilter` SVG creation to Phase 4 (follows existing Heroicon-style patterns). +- **`pubkey_prefix` missing from `nodes.js` `hasActiveFilters`**: Confirmed at + `nodes.js:175`. This is a pre-existing bug (the filter panel won't open on fresh + mount when only the public-key-prefix field is filled). Fixed in-scope by adding + `pubkey_prefix !== ''` to the check in Phase 5. +- **Tooltip vs. label string**: Reuse `common.filters` ("Filters") for both the + visible label and the toggle's `title` attribute. No new i18n keys required. +- **`packet-group-detail.js` format sites are raw template literals, not `t()` + calls**: Lines 304, 318, and 345-346 use direct `${}` interpolation rather than + `t()` params. Phase 2 updated to list them explicitly as raw-template-literal + format sites (still wrapped with `formatNumber(...)` — just not inside `t()`). +- **Map page has no `hasActiveFilters` check**: Unlike list pages, map defaults to + `?? false` because its selects all start at neutral/empty values. This matches + current behavior (the `
` was always default-closed on fresh mount). + Documented in Phase 6. +- **`window.formatNumber` is unused by `charts.js`**: `charts.js` defines its own + local `formatNumber` (Phase 3) to avoid module-load-order issues. + `window.formatNumber` (Phase 1) is harmless — it mirrors the `window.t` pattern + and serves as a debug/console utility — but is not strictly required. Retained + for consistency with the existing `window.t` convention at `i18n.js:78`. +- **`toggle-primary` color**: Needs visual verification during implementation + (appearance against current light/dark themes). Fall back to plain `toggle` if + the primary variant doesn't render well. + +### Remaining Action Items + +- At implementation time, visually verify `toggle toggle-sm toggle-primary` + renders acceptably in both light and dark themes. Fall back to `toggle toggle-sm` + if needed. +- Verify no `id="filter-toggle"` collision — only one page renders at a time in + the SPA, but confirm no stale checkbox remains after a quick page transition. +- After implementation, spot-check `navigator.language` returns a grouped format + in each target browser (Chrome, Firefox, Safari). + +## References + +- `docs/plans/20260505-0900-improve-filter-options/plan.md` — introduced the + `renderFilterCard` `collapsible`/`defaultOpen` pattern and the `
`-based + collapse + `existingDetails.open` state-preservation mechanism that this plan + refactors. +- `docs/plans/20260614-1220-observer-filter-badges/plan.md` — moved observer + filtering out of the panel into toggle badges below it; confirms the badges stay + in place below the new inline filter form. +- `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md` — confirms + `charts.js` consumes a stable JSON shape and needs no backend-coordinated edits; + this plan's `charts.js` changes are purely presentational. +- Key source locations: `components.js:774-825` (`renderFilterCard`, `renderStatCard`), + `components.js:558` (`observerIcons`), `i18n.js:56-78` (`t()`), `auto-refresh.js:20-88`, + `charts.js:49-98` (`createChartOptions`), `web/app.py:319` (`datetime_locale` in + `__APP_CONFIG__`). +- Git: `479c263` (panel accent redesign), `7956532` (spacing normalization), + `91a3fcf` (keyboard accessibility) — recent UI-direction context. diff --git a/docs/plans/20260703-1503-regional-numbers-filter-toggle/tasks.md b/docs/plans/20260703-1503-regional-numbers-filter-toggle/tasks.md new file mode 100644 index 0000000..b11d1a6 --- /dev/null +++ b/docs/plans/20260703-1503-regional-numbers-filter-toggle/tasks.md @@ -0,0 +1,166 @@ +# Tasks: Regional number formatting + filter panel redesign + +> Generated from `plan.md` on 2026-07-03 + +## 1. formatNumber helper + stat card numbers + +- [x] Create `formatNumber()` helper in `components.js` + - [x] Add `export function formatNumber(value)` near existing `formatDateTime*` helpers (~line 193) + - [x] Contract: return `''` for null/undefined/empty, `String(value)` for non-finite, else `Intl.NumberFormat().format(n)` + - [x] Expose `window.formatNumber = formatNumber` (mirrors `window.t` pattern at `i18n.js:78`) +- [x] Update `renderStatCard` in `components.js:822` + - [x] Replace `${value}` with `${formatNumber(value)}` + - [x] Verifies: all 6 home-page stat cards format via `home.js:148,155,162,169,205,211` +- [x] Update dashboard inline stats in `pages/dashboard.js` + - [x] Add `formatNumber` to the `../components.js` import + - [x] Wrap `${stats.nodes}` at line 139 + - [x] Wrap `${stats.packets}` at line 160 + - [x] Wrap `${stats.advertisements}` at line 181 + - [x] Wrap `${stats.messages}` at line 202 + +## 2. total/shown badges + inline counts + +- [x] Update list-page total/shown badges + - [x] `pages/nodes.js:46` — wrap `displayTotal` and `shown` in `t()` badges + - [x] `pages/packets.js:80` — wrap `displayTotal` and `shown` in `t()` badges + - [x] `pages/advertisements.js:62` — wrap `displayTotal` and `shown` in `t()` badges + - [x] `pages/messages.js:227` — wrap `displayTotal` and `shown` in `t()` badges +- [x] Update inline count renderers + - [x] `pages/packets.js:39` — wrap `${observer_count}` with `formatNumber()` (raw template literal) + - [x] `pages/packets.js:41` — wrap `${reception_count}` with `formatNumber()` (raw template literal) +- [x] Update map count badges in `pages/map.js` + - [x] Line 309 — wrap count in `t()` call + - [x] Line 312 — wrap count in `t()` call + - [x] Line 313 — wrap count in `t()` call + - [x] Line 331 — wrap count in `t()` call +- [x] Update members page in `pages/members.js` + - [x] Line 16 — wrap `${online_count}/${total}` with `formatNumber()` + - [x] Line 106 — wrap count in `t()` call +- [x] Update packet-group-detail page in `pages/packet-group-detail.js` + - [x] Line 172 — wrap count in `t()` interpolation + - [x] Line 304 — wrap `${reception_count}` in raw template literal with `formatNumber()` + - [x] Line 318 — wrap `${observer_count}` in raw template literal with `formatNumber()` + - [x] Line 345 — wrap `${g.reception_count}` in raw template literal with `formatNumber()` + - [x] Line 346 — wrap `${g.observer_count}` in raw template literal with `formatNumber()` +- [x] Update observerIcons badge count in `components.js:558` + - [x] Wrap `${observers.length}` in raw template literal with `formatNumber()` +- [x] Add `formatNumber` to imports on all updated page files + +## 3. Chart.js axis + tooltip formatting + +- [x] Add local `formatNumber` function in `charts.js` + - [x] Define `function formatNumber(v) { return Intl.NumberFormat().format(v); }` at module scope +- [x] Add y-axis tick callback in `createChartOptions()` at `charts.js:86-89` + - [x] Add `callback: function(value) { return formatNumber(value); }` alongside `precision: 0` +- [x] Add tooltip label callback in `createChartOptions()` at `charts.js:63-71` + - [x] Add `callbacks` object with `label` function + - [x] Format `ctx.parsed.y` via `formatNumber()` prefixed by dataset label (preserve Chart.js default label style) + +## 4. Filter component refactor + +- [x] Add `iconFilter` SVG in `icons.js` + - [x] Create funnel icon function: `export function iconFilter(cls = 'h-5 w-5')` + - [x] Follow existing Heroicon-style SVG patterns (24x24 viewBox, stroke, fill="none") +- [x] Create `renderFilterToggle()` in `components.js` + - [x] Export function accepting `{ open, onChange }` + - [x] Render label with DaisyUI `toggle toggle-sm toggle-primary` slider switch + - [x] Include `iconFilter('w-4 h-4')` + `t('common.filters')` text in the label + - [x] Add `title=${t('common.filters')}` on the label element + - [x] Bind `?checked=${open}` and `@change=${onChange}` on the `` + - [x] Add `iconFilter` to the `../icons.js` import +- [x] Create `renderFilterForm()` in `components.js` + - [x] Export function accepting `{ fields, basePath, navigate, submitLabel, clearLabel }` + - [x] Return bare `` (fields + submit/clear buttons) — no `
`, card, border, or `mb-6` + - [x] Reuse existing `createFilterHandler` / `autoSubmit` / `submitOnEnter` helpers unchanged +- [x] Remove old `renderFilterCard()` from `components.js:774-806` + - [x] Confirm no other callers remain (grep for `renderFilterCard`) + +## 5. Four shared list pages (nodes, packets, ads, messages) + +- [x] Update `pages/nodes.js` + - [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import + - [x] Fix `hasActiveFilters` at line 175: add `pubkey_prefix !== ''` to the condition + - [x] Replace state-read lines 176-177: query `#filter-toggle` checked state instead of `
.open` + - [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 179-185) with `renderFilterToggle` + `renderFilterForm` + - [x] Restructure control row (lines 44-51): toggle right-aligned after auto-refresh + - [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure +- [x] Update `pages/packets.js` + - [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import + - [x] Replace state-read lines 176-177: query `#filter-toggle` checked state + - [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 176-185) with `renderFilterToggle` + `renderFilterForm` + - [x] Restructure control row (lines 73-86): toggle right-aligned after auto-refresh + - [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure +- [x] Update `pages/advertisements.js` + - [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import + - [x] Replace state-read lines 250-251: query `#filter-toggle` checked state + - [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 250-259) with `renderFilterToggle` + `renderFilterForm` + - [x] Restructure control row (lines 55-68): toggle right-aligned after auto-refresh + - [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure + - [x] Confirm observer toggle badges (lines 269, 285) render below the new inline form unchanged +- [x] Update `pages/messages.js` + - [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import + - [x] Replace state-read lines 426-427: query `#filter-toggle` checked state + - [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 426-435) with `renderFilterToggle` + `renderFilterForm` + - [x] Restructure control row (lines 220-233): toggle right-aligned after auto-refresh + - [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure + - [x] Confirm observer toggle badges (lines 445, 463) render below the new inline form unchanged + +## 6. Map page + +- [x] Update `pages/map.js` + - [x] Add `renderFilterToggle` to `../components.js` import + - [x] Replace `
` open check (lines 187-188): query `#filter-toggle?.checked ?? false` + - [x] Place `renderFilterToggle` in the right-side badge group inside header (lines 193-197) + - [x] Wire `onChange` to map's existing `applyFilters`/re-render path (no API refetch) + - [x] Replace `
` block (lines 200-246) with bare `
` using `hidden` class for visibility toggle + - [x] Drop all collapse card classes: `collapse`, `bg-base-200`, `border-2`, `border-base-content/25`, `rounded-box`, `collapse-title`, `collapse-content pt-4` + - [x] Preserve all existing fieldsets, selects, checkbox, and clear button inside the bare `
` + +## 7. Build + regression + +- [x] Build the Docker image + - [x] `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile all build` + - [x] Confirm no build errors (JS syntax, module imports, Tailwind compilation) +- [x] Run web test suite + - [x] `pytest --no-cov tests/test_web/` — confirm green (no Python/HTML changes expected) +- [x] Run pre-commit + - [x] `pre-commit run --all-files` + +## 8. Verification + +- [ ] Stack up and smoke test + - [ ] `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core up -d` +- [ ] Verify regional number formatting + - [ ] Home page stat cards show locale-grouped numbers (e.g. `12,345` in en-GB, `12.345` in de-DE) + - [ ] Dashboard inline stats show locale-grouped numbers + - [ ] List-page total/shown badges show locale-grouped numbers + - [ ] Map count badges show locale-grouped numbers + - [ ] Packet reception/observer inline counts show locale-grouped numbers + - [ ] Chart y-axis ticks show locale-grouped numbers + - [ ] Chart tooltip values show locale-grouped numbers + - [ ] Members page counts show locale-grouped numbers + - [ ] Packet-group-detail inline counts show locale-grouped numbers +- [ ] Verify filter toggle on all 5 pages + - [ ] Nodes: toggle appears right of control row; toggling reveals/hides bare fields; survives auto-refresh and sort/nav + - [ ] Packets: same as nodes + - [ ] Advertisements: same as nodes; observer badges render below filter form unchanged + - [ ] Messages: same as nodes; observer badges render below filter form unchanged + - [ ] Map: toggle appears in header badge group; toggling reveals/hides client-side filter fields +- [ ] Verify filter open-state behavior + - [ ] Fresh navigation with active filters → toggle defaults on + - [ ] Fresh navigation with no active filters → toggle defaults off + - [ ] `pubkey_prefix` field on nodes page correctly triggers active-on-mount (Phase 5 fix) + - [ ] Record count badge stays visible when toggling filter (onFilterToggle preserves lastTotal) +- [ ] Visual verification + - [ ] `toggle toggle-sm toggle-primary` renders acceptably in light theme + - [ ] `toggle toggle-sm toggle-primary` renders acceptably in dark theme + - [ ] If `toggle-primary` looks off in either theme, fall back to plain `toggle toggle-sm` +- [ ] Cross-browser sanity check + - [ ] `navigator.language` grouping works in Chrome (devtools: `(1234567).toLocaleString()`) + - [ ] `navigator.language` grouping works in Firefox + - [ ] `navigator.language` grouping works in Safari +- [ ] Verify no `#filter-toggle` collision on rapid SPA page transitions + - [ ] Navigate quickly between pages and confirm no stale checkbox remains in DOM +- [x] Run final regression + - [x] `pytest --no-cov tests/test_web/` green (236 passed) + - [x] `pre-commit run --all-files` green diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index b6e042e..01fbea7 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -10,6 +10,16 @@ if (typeof Chart !== 'undefined') { Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif'; } +/** + * Format a number with locale-appropriate grouping separators. + * Uses the visitor's browser locale (no explicit locale argument). + * @param {number} v + * @returns {string} + */ +function formatNumber(v) { + return new Intl.NumberFormat().format(v); +} + /** * Read page colors from CSS custom properties (defined in app.css :root). * Falls back to hardcoded values if CSS vars are unavailable. @@ -67,7 +77,14 @@ function createChartOptions(showLegend) { titleColor: ChartColors.tooltipText, bodyColor: ChartColors.tooltipText, borderColor: ChartColors.tooltipBorder, - borderWidth: 1 + borderWidth: 1, + callbacks: { + label: function(ctx) { + const label = ctx.dataset.label || ''; + const value = formatNumber(ctx.parsed.y); + return label ? label + ': ' + value : value; + } + } } }, scales: { @@ -85,7 +102,8 @@ function createChartOptions(showLegend) { grid: { color: ChartColors.grid }, ticks: { color: ChartColors.text, - precision: 0 + precision: 0, + callback: function(value) { return formatNumber(value); } } } }, diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 41b529b..b1e2a33 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -20,7 +20,7 @@ import { html, nothing } from 'lit-html'; import { render } from 'lit-html'; import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; import { t } from './i18n.js'; -import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout } from './icons.js'; +import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout, iconFilter } from './icons.js'; // Re-export lit-html utilities for page modules export { html, nothing, unsafeHTML }; @@ -192,6 +192,20 @@ export const pageColors = { // --- Formatting Helpers (return strings) --- +/** + * Format a number with locale-appropriate grouping separators. + * Uses the visitor's browser locale (no explicit locale argument). + * @param {number|string|null|undefined} value + * @returns {string} Grouped number string, or '' for missing values + */ +export function formatNumber(value) { + if (value === null || value === undefined || value === '') return ''; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + return new Intl.NumberFormat().format(n); +} +window.formatNumber = formatNumber; + /** * Get the type emoji for a node advertisement type. * @param {string|null} advType @@ -555,7 +569,7 @@ export function observerIcons(observers) { if (!observers || observers.length === 0) return nothing; const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8)); const tooltip = names.join(', '); - return html`${observers.length}`; + return html`${formatNumber(observers.length)}`; } export function routeTypeBadge(routeType) { @@ -761,18 +775,17 @@ export function renderAuthSection(container, config) { } /** - * Render a filter card with configurable form fields, submit, and clear buttons. + * Render a bare filter form (fields + submit/clear buttons). + * No surrounding card, border, or collapse wrapper — the caller controls visibility. * @param {Array} options.fields - Array of render functions returning lit-html form controls * @param {string} options.basePath - Base URL path for the page (e.g., '/nodes') * @param {Function} options.navigate - Router navigate function * @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter") * @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear") - * @param {boolean} [options.collapsible=false] - Wrap in DaisyUI collapsible
- * @param {boolean} [options.defaultOpen=false] - Start expanded when collapsible * @returns {TemplateResult} */ -export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) { - const formBody = html` +export function renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel }) { + return html`
${fields.map(f => f())} @@ -781,28 +794,26 @@ export function renderFilterCard({ fields, basePath, navigate, submitLabel, clea ${clearLabel || t('common.clear')}
- - `; - - if (!collapsible) { - return html` -
-
${formBody}
-
- `; - } + `; +} +/** + * Render the filter toggle control (DaisyUI slider switch + label). + * Placed at the right of the control row; the native checkbox holds open-state. + * @param {boolean} options.open - Whether the toggle is checked + * @param {Function} options.onChange - @change handler on the checkbox + * @returns {TemplateResult} + */ +export function renderFilterToggle({ open, onChange }) { return html` -
- - ${t('common.filters')} - -
- ${formBody} -
-
- `; + `; } /** @@ -819,7 +830,7 @@ export function renderStatCard({ icon, color, title, value, description }) {
${icon}
${title}
-
${value}
+
${formatNumber(value)}
${description ? html`
${description}
` : nothing}
`; } diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js index ad05441..bcfdd15 100644 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ b/src/meshcore_hub/web/static/js/spa/icons.js @@ -165,3 +165,7 @@ export function iconCodingRate(cls = 'h-5 w-5') { export function iconTxPower(cls = 'h-5 w-5') { return html``; } + +export function iconFilter(cls = 'h-5 w-5') { + return html``; +} diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 3e4961f..8137501 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,10 +1,10 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, formatNumber, warningBadge, pagination, sortableTableHeader, mobileSortSelect, - renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, + renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges, routeTypeBadge } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -44,6 +44,10 @@ export async function render(container, params, router) { let lastContent = nothing; let lastTotal = null; + let currentFilterFields = []; + const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood'; + + function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); } function renderPage(content, { total = null, error = null } = {}) { if (!error) { @@ -52,6 +56,8 @@ export async function render(container, params, router) { } const displayContent = error ? lastContent : content; const displayTotal = error ? lastTotal : total; + const existingToggle = container.querySelector('#filter-toggle'); + const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters; litRender(html`

${t('entities.advertisements')}

@@ -59,11 +65,17 @@ export async function render(container, params, router) {
${displayTotal !== null - ? html`${t('common.total', { count: displayTotal })}` + ? html`${t('common.total', { count: formatNumber(displayTotal) })}` : nothing} - ${error ? warningBadge(error) : nothing} +
+ +
+
${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
+${(filterOpen && currentFilterFields.length > 0) + ? html`
${renderFilterForm({ fields: currentFilterFields, basePath: '/advertisements', navigate })}
` + : nothing} ${displayContent}`, container); } @@ -246,25 +258,15 @@ ${displayContent}`, container);
`); } - const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood'; - const existingDetails = container.querySelector('details.collapse'); - const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters; - - const filterCard = renderFilterCard({ - fields: filterFields, - basePath: '/advertisements', - navigate, - collapsible: true, - defaultOpen: isFilterOpen, - }); - const headerParams = { search, adopted_by, route_type, limit }; const sortable = (label, sortKey) => sortableTableHeader(label, { sortKey, currentSort: sort, currentOrder: order, navigate, basePath: '/advertisements', params: headerParams, }); - renderPage(html`${filterCard} + currentFilterFields = filterFields; + + renderPage(html` ${observerBadges('hidden lg:flex mb-4')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index 35143a8..5e6110a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -2,7 +2,7 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, getConfig, getChannelLabelsMap, resolveChannelLabel, - observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, + observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, formatNumber, } from '../components.js'; import { iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel, @@ -136,7 +136,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s

${t('time.over_time_last_7_days')}

- ${stats.total_nodes} + ${formatNumber(stats.total_nodes)}
@@ -157,7 +157,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s

${t('time.per_day_last_7_days')}

- ${stats.advertisements_7d} + ${formatNumber(stats.advertisements_7d)}
@@ -178,7 +178,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s

${t('time.per_day_last_7_days')}

- ${stats.messages_7d} + ${formatNumber(stats.messages_7d)}
@@ -199,7 +199,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s

${t('time.per_day_last_7_days')}

- ${stats.packets_7d} + ${formatNumber(stats.packets_7d)}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index 8ff0fab..5d86e02 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -2,7 +2,7 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, t, getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert, - timezoneIndicator, + timezoneIndicator, formatNumber, renderFilterToggle, } from '../components.js'; const MAX_BOUNDS_RADIUS_KM = 20; @@ -184,8 +184,13 @@ export async function render(container, params, router) { applyFilters(); } - const existingDetails = container.querySelector('details.collapse'); - const isFilterOpen = existingDetails ? existingDetails.open : false; + function onMapFilterToggle() { + const filterDiv = container.querySelector('#map-filter-fields'); + if (filterDiv) filterDiv.classList.toggle('hidden'); + } + + const existingToggle = container.querySelector('#filter-toggle'); + const isFilterOpen = existingToggle ? existingToggle.checked : false; litRender(html`
@@ -194,16 +199,11 @@ export async function render(container, params, router) { ${timezoneIndicator()} ${t('common.loading')} + ${renderFilterToggle({ open: isFilterOpen, onChange: onMapFilterToggle })}
-
- - ${t('common.filters')} - -
-
+