Appearance
[UX] customer-portal — Admin analytics
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 5 Patterns: data-display/statistics
Summary
admin.analytics is a single, self-contained page (AnalyticsPage.vue) that renders three KPI-tile groups (Users & Accounts, Operations, Sync Health) from one adminStatsGetStats query polled every 30s. There are no charts despite the assigned data-display/chart pattern — it is a statistics/KPI strip only, and the tiles themselves are sound. The real defects are cross-cutting: none of the page copy goes through i18n, and the loading/error states are not announced to assistive tech.
Findings
1. Entire page is hardcoded English, bypassing i18n — Medium · CONTENT-01
Where: apps/web/src/pages/admin/AnalyticsPage.vue:24,45,50,58,69,84,89,97,105,120,125,130-138,143,151,161 (page title "Dashboard", every section <h2>, every tile label, the error string, both Tag values "Healthy"/"Attention") What: customer-portal ships en + nb and is held to CONTENT-01 per feature (BASELINE per-project note). Every string on this page is a literal — no t(...) call anywhere in the component. The sidebar entry that leads here is translated (navigation.admin.analytics → "Analytics"/"Analyse", AppLayout.vue:254), so the page is an island of English inside a localised shell. Why it matters: an nb admin navigates from "Analyse" and lands on a page reading "Dashboard / Users & Accounts / Total Users / Sync Health / Healthy". The whole surface is untranslated with no CI signal, because there are no keys to check parity against. Fix: move all copy to en.yml/nb.yml under an admin.analytics.* namespace and render via t(), matching the rest of the admin section.
2. Loading and error state changes are silent to screen readers — Medium · MSG-01
Where: AnalyticsPage.vue:25-30 (loading), :32-37 (error) What: the loading branch is a bare spinner <div> and the error branch is a styled <div> with neither role="alert" nor aria-live. The 30s refetchInterval also swaps content in and out with no announcement. Why it matters: a screen-reader user gets no notification that stats are loading, that the load failed, or that figures refreshed — a silent DOM swap (WCAG 4.1.3). The statistics pattern's own accessibility guidance calls out announcing loading/error/completion "in the right place and with the right politeness". Fix: add role="status" / aria-live="polite" to the loading container and role="alert" to the error container.
3. Error state offers no recovery and no next step — Medium · MSG-03 (also MSG-04)
Where: AnalyticsPage.vue:32-37What: on failure the page shows only "Failed to load dashboard stats." — a dead end with no Retry control and no guidance. (The query does keep polling every 30s, but the user is never told that, so the copy reads as terminal.) Why it matters: the user is told what went wrong but not what to do; the page looks broken with no way to act. MSG-03 requires the message say what to do next; MSG-04 requires a route out of a dead end. Fix: add a Retry button wired to the query's refetch, and copy that names the auto-retry or the action ("Couldn't load stats. Retrying… / Try again").
4. Dates render in a fixed nb-NO locale, and counts skip locale number formatting — Low · CONTENT-04-adjacent / consistency
Where: AnalyticsPage.vue:11 (useDateFormatter() called with no options), :147 (formatDateTime); raw count interpolation at :54,62,72,92,100,108,129,155What: useDateFormatter defaults locale to "nb-NO" when no reactive getter is passed (packages/ui/src/composables/useDateFormatter.ts:29), and its own doc-comment says i18n apps must pass one. This page passes none, so "Last Successful Run" is always formatted nb-NO even for an en admin. Separately, every count (stats.totalUsers, etc.) is interpolated raw rather than through the composable's locale-aware formatNumber, so large values print as 12345 with no thousands separator. Why it matters: locale-inconsistent dates and un-grouped large numbers in a metrics view; minor but avoidable given the helper is already imported. Fix: pass a reactive locale getter to useDateFormatter({ locale: () => locale.value }) and wrap counts in formatNumber.
5. Nav label ("Analytics") does not match page heading ("Dashboard") — Low · CONTENT-02
Where: AppLayout.vue:254 (navigation.admin.analytics) vs AnalyticsPage.vue:24 (title="Dashboard") What: the control the user clicks says "Analytics"/"Analyse"; the destination <h1> says "Dashboard". Why it matters: breaks the echo between control and destination heading, a small orientation cost. Fix: title the page "Analytics" (via the same i18n key) so heading echoes the nav entry.
6. Data gate (read SyncAdmin) differs from the route gate (manage all) — latent blank state — Low · MSG-06-shaped
Where: AnalyticsPage.vue:13,18 (enabled: canRead, canRead = can("read","SyncAdmin")) vs router/index.ts:374 (route meta manage all) What: the query only runs when the user can read SyncAdmin, but the route admits anyone with manage all. Today manage all short-circuits every can() so the two always agree and it never blanks. But if a role were ever granted route access without SyncAdmin, the query stays disabled → isLoading=false, isError=false, stats=undefined → none of the three v-if branches render → a blank page under the "Dashboard" title with no message. Why it matters: a silent empty page with no explanation is the MSG-06 empty/dead-state shape; recorded as latent because it is not reachable on the current ability set. Fix: gate the query on the same ability as the route, or add an explicit "no access / no data" branch so the page can never render empty.
Unverified
- A11Y-01 (contrast) — token-based classes (
text-text-2,text-text-3,status-danger-fgonstatus-danger-bg, PrimeVueTagseverities). Needs computed colour values; not assertable from code. - A11Y-06 (short viewport / mobile) — tiles use
grid-cols-1 sm:grid-cols-3|4; stacking looks reasonable but must be confirmed in a rendered ≤700px-height viewport. - PrimeVue
Taginternals — whether the "Healthy"/"Attention"Tagexposes its status as text to AT rather than colour alone;node_modulesnot installed, so unverifiable from source.
Baseline additions
- CONTENT-STATE-ANNOUNCE (candidate; likely folds into MSG-01): async loading/refresh/error surfaces in a polled view must be exposed via
role/aria-live, not only visually. Overlaps existing MSG-01 — no new rule needed if MSG-01 is read to cover polled refreshes.
Cross-project note
- CONTENT-01 hardcoded page copy: the same "translated nav label, English page body" split is worth checking on the other admin pages here (#31–#36) and is a general risk in playout (en/fr/no).
- MSG-01 silent loading/error swaps and MSG-03 error with no retry: recurring shapes across all four projects' list/dashboard surfaces (see PROJECT-LEVEL DataTable notes for customer-portal).
- NAV-03 — one static document title app-wide; fails project-wide, see PROJECT-LEVEL.md (customer-portal, three agents). Not re-filed here.