Skip to content

[UX] customer-portal — Dashboard ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 12 Patterns: data-display/dashboard

Summary ​

HomePage.vue is the post-sign-in landing surface for every authenticated user and is in better shape than most of this app: the skeleton is correctly gated on an isLoading load state (not on a data value), the error branch is reachable and carries a retry control, list rows are keyboard-operable with Enter/Space, and en/nb locale coverage for home.dashboard.* is complete with real Norwegian. The important defect is that the hero's numbers are not true: every headline count is array.length on a list the server has already truncated to 5 or 10, so a customer with 40 overdue services is told "10 items need your attention" — and no card offers a "view all" route to the rest. Second in importance: the whole load lifecycle (loading → loaded → error) is silent for screen readers, and the first-run/zero-data account is rendered as "Your fleet is in good shape".

Project-level, not re-filed here: NAV-03 (one static document title for the whole app — confirmed again, router/index.ts has no meta.title and no title mechanism) and MSG-02 (getErrorMessage prefers raw backend strings — this page does not use it, it uses a fixed localised string, so it is unaffected). A11Y-04 is not failed here in the loaded state: the hero renders a real <h1> (HomePage.vue:123), section headings are <h2>, no level is skipped, and the <main> landmark comes from AppShellLayout in @aadalen/ui.

Findings ​

1. Hero counts are server-truncated lists presented as totals — High · DATA-TRUTHFUL-COUNT (proposed) ​

Where: apps/web/src/pages/HomePage.vue:46-72; server caps at apps/api/src/modules/dashboard/dashboard.repository.prisma.ts:19-20 (LIST_LIMIT = 5, TOP_LOCATIONS_LIMIT = 5) and :88, :116 (take: 10) What: overdueCount = dashboard.overdueServices?.length ?? 0 and the "Upcoming services" chip dashboard.plannedBookings.length count the rows in a list the repository already truncated — take: 10 for overdue services and planned bookings, take: 5 for completed bookings, expiring skills, open requests and locations. The hero therefore reports at most 10 overdue services and at most 10 upcoming ones, with no "+N more" and no link to the full list on any of the six cards. The DTO proves the correct shape exists and is simply not used for the others: dashboard.dto.ts:125 returns a real openServiceRequestCount, which the template does render (HomePage.vue:372), alongside a list capped at 5. Why it matters: the "N items need your attention" headline (HomePage.vue:138) is the entire point of this screen, and it silently plateaus at 10. A fleet customer with 40 overdue services works through the ten shown, sees the count fall, and reasonably concludes the fleet is current. Overdue service on trucks is a safety- and warranty-relevant status, so a confidently wrong number is worse than no number. Graded High rather than Blocker because the page renders and the data shown is itself correct — the harm is misinformation, not breakage. Fix: return true counts from the API (overdueServiceCount, plannedBookingCount, expiringSkillCount, mirroring the existing openServiceRequestCount) and drive the hero chips and attentionCount from those. Add a "View all" link in each card header to the corresponding list route (service-overview, customer-assets.index, skills), and show "Showing 5 of N" when the list is truncated.

2. "Nothing needs attention" contradicts the warning cards below it — Medium · CONTENT-SUMMARY-AGREES (proposed) ​

Where: apps/web/src/pages/HomePage.vue:47 and :138What: attentionCount = overdueCount — only overdue services. Expiring skills (rendered in a warn-toned card, :266-312) and open service requests (:365-419) do not contribute. A user with zero overdue services and six certificates expiring in the next 30 days is told "Your fleet is in good shape — nothing needs attention right now" directly above a card headed "Skills expiring soon" listing six of them. Why it matters: the headline is the one line most users read; when it disagrees with the content two rows down, users stop trusting the summary and have to audit the cards manually, which is exactly the work the dashboard exists to remove. Fix: either include the other warning categories in attentionCount, or narrow the copy so the scope is explicit — home.dashboard.hero.healthy should say "No overdue services" rather than "nothing needs attention" if that is all it measures.

3. First-run / zero-data account is rendered as a healthy fleet, with no action anywhere — Medium · EMPTY-FIRSTRUN (proposed) ​

Where: apps/web/src/pages/HomePage.vue:118-458; empty branches at :178, :223, :272, :320, :376, :429What: a brand-new or not-yet-synced account renders the full page: chips at 0/0/0, the green check icon and "Your fleet is in good shape — nothing needs attention right now", then six cards each saying "No …". Every empty branch is a bare muted sentence — none offers a next step (book a service, add a machine, contact Aadalen), and nothing distinguishes "you have no data yet" from "your data is all healthy". Why it matters: this is the surface every authenticated user lands on immediately after sign-up. A new customer is congratulated on a fleet they have not registered, given nothing to do, and has to find the sidebar to make any progress. The data-display/dashboard pattern names this directly under Common Mistakes — "Ignoring non-happy states … design the data lifecycle up front, including empty, partial, stale, and failed results". Fix: branch the hero on "has any data at all" and show a first-run headline plus one primary action; give at least the Upcoming and Alerts empty states an action link rather than a full stop.

4. The load lifecycle is silent for assistive tech, and retry drops focus — Medium · MSG-01, CONTENT-04 ​

Where: apps/web/src/pages/HomePage.vue:81-116What: the loading branch renders three bare <Skeleton> blocks with no role="status", no aria-busy and no text naming what is being loaded (PrimeVue Skeleton is aria-hidden — unverified, node_modules is not installed). Neither the arrival of data nor the switch to the error branch happens inside a live region, so a screen-reader user hears nothing between navigating to the dashboard and manually re-reading the page. Compounding it: pressing Retry (:109-115) sets isLoading = true, which unmounts the button the user just activated — focus falls to <body> and nothing is announced, so there is no feedback that the retry ran at all. That is the same shape FORM-06 forbids for submit buttons. Why it matters: on the app's front door, a non-sighted user cannot tell "still loading" from "loaded and empty" from "failed", and cannot tell whether their retry did anything. Fix: wrap the state region in <div role="status" aria-live="polite"> with visually-hidden text naming the state ("Loading dashboard…" / "Dashboard updated" / the error string, per CONTENT-04), and keep the Retry button mounted during the retry with :loading / aria-busy and a guard in loadDashboard instead of swapping it for skeletons.

5. Overdue alerts with no customerAssetExternalId are a styled dead end — Medium · MSG-04 ​

Where: apps/web/src/pages/HomePage.vue:232-242 (same shape at :385-394) What: when overdue.customerAssetExternalId is missing the row drops role/tabindex and gains cursor-not-allowed opacity-70, while the base class list still contains cursor-pointer (:235) — so the row is simultaneously declared clickable and not-allowed, and which cursor wins depends on Tailwind's generated rule order, not on the attribute order. More importantly the row becomes a faded, unexplained "disabled" alert: it still says a service is overdue and has not been completed, but offers no route to act on it and no explanation of why it cannot be opened. Why it matters: the highest-urgency item on the page can render as something the user can see but not act on, with no alternative path (no "book service", no "contact us"). MSG-04 requires a dead end to offer a way out. Fix: remove cursor-pointer from the base class when the row is not navigable, and give the non-navigable variant an explicit secondary action (book-service or contact) or a one-line explanation instead of a disabled appearance.

6. Data is fetched once on mount and never revalidated; no refresh, no freshness marker — Medium · NAV-STALE-DATA (proposed) ​

Where: apps/web/src/pages/HomePage.vue:24-41What: the page hand-rolls ref + onMounted(loadDashboard) instead of @tanstack/vue-query, which apps/web/CLAUDE.md §2 requires for server state. Consequences: no refetch on window focus or reconnect, no background revalidation, no cache shared with the rest of the app, no Retry-equivalent control in the success state, and no "last updated" timestamp. A dashboard left open on a phone or a wall screen keeps showing the overdue count from whenever the tab was opened. (The account-switch case is handled — AppLayout.vue:446 keys <RouterView> on an account-switch counter, so the page remounts and refetches; the const { accountId } = useAdminAccountStore() destructure at HomePage.vue:15 loses reactivity but is therefore not user-visible.) Why it matters: the data-display/dashboard pattern lists stale as a state that must be designed for. Here a stale number is indistinguishable from a fresh one, on the one screen whose job is to tell users whether anything is wrong right now. Fix: move the fetch to useQuery with the standard refetch-on-focus behaviour, and surface a visible refresh control plus a relative "updated N minutes ago" line in the hero.

7. The skeleton does not resemble the page it stands in for — Low · MSG-01 ​

Where: apps/web/src/pages/HomePage.vue:81-96What: the loading state renders a hero block plus two 16rem blocks; the loaded state renders a hero plus six cards in three sections. The page roughly triples in height the moment data arrives. Why it matters: a large jump on the landing screen, on mobile in particular, undoes the reason for having a skeleton — perceived stability while waiting. Fix: mirror the real grid: hero, 2fr_1fr pair, three-up row, and the full-width locations strip.

8. Sidebar says "Dashboard", the page says "Hi, Edward" — Low · CONTENT-02 ​

Where: apps/web/src/pages/HomePage.vue:123-125; unused keys at apps/web/src/i18n/messages/en.yml:307-308 and nb.yml:307-308What: the control that leads here is labelled app.dashboard ("Dashboard"), but the destination's only heading is a greeting. home.dashboard.title ("Welcome back" / "Velkommen tilbake") and home.dashboard.subtitle exist in both locales and are never rendered, as are home.dashboard.usage.title / .subtitle (leftovers of a removed usage chart). With NAV-03 also failing app-wide, nothing on screen or in the tab names the page. Why it matters: small on its own; combined with the static document title it means a user with several tabs open has no way to identify this one. Fix: render the greeting as an eyebrow above a "Dashboard"/"Oversikt" heading, or set meta.title. Delete the four dead keys.

9. assetCount has no plural form — Low · CONTENT-01 ​

Where: apps/web/src/i18n/messages/en.yml:343, nb.yml:343What: assetCount: "{count} assets" / "{count} maskiner" is a single form interpolated at HomePage.vue:451, so a location with one machine reads "1 assets" / "1 maskiner". Every other count string on this page uses the | plural branch correctly (e.g. hero.attention, :313). Fix: "{count} asset | {count} assets" / "{count} maskin | {count} maskiner" and call with the count as the third argument, as hero.attention already does.

10. Alert rows have no hover feedback and no app-standard focus ring — Low · A11Y-03 ​

Where: apps/web/src/pages/HomePage.vue:235What: the overdue row's class list is bg-status-danger-bg … hover:bg-status-danger-bg — the hover colour is identical to the resting colour, so a row advertised with cursor-pointer gives no hover response (the other four row types use hover:bg-sunken, which does change). None of the role="button" rows declare a focus-visible style, so they fall back to the UA outline while the app's own equivalent clickable rows get an explicit accent ring (packages/ui/src/styles/theme.css:371-373, .dt-row.cursor-pointer:focus-visible). Why it matters: keyboard users get a weaker, inconsistent focus indicator on the most urgent rows on the page, and mouse users get no affordance confirming the red cards are clickable. Fix: use a distinct hover tone for the alert rows and apply the same outline: 2px solid var(--color-accent) focus-visible treatment used by .dt-row, ideally by extracting that rule into a shared class.

11. Greeting can render as "Hi, " — Low · CONTENT-04 ​

Where: apps/web/src/pages/HomePage.vue:19-22, :124What: firstName falls back to "" when session.data.user has neither name nor email, or while the session is still hydrating; the <h1> then reads "Hi, " / "Hei, " with a trailing comma at display size. The route guard makes a fully-absent session unlikely, so this is a hydration-window and missing-profile-name edge, not the common path. Fix: fall back to a nameless variant of the greeting key when firstName is empty.

Unverified ​

  • A11Y-01 (contrast). Needs a rendered page. Highest-risk spots: the muted empty-state text text-sm text-text-3 on paper (:180, :225, :275, :322, :378, :431); the hero chip labels text-white/55 on the surface-hero gradient (:159); the sub-headline at text-white/70 (:128); and text-status-danger-fg body copy on bg-status-danger-bg (:247-257).
  • A11Y-06 (responsive / short viewport). The hero packs three metric chips into grid-cols-3 with px-3 at 320px (:143-147) while carrying long Norwegian labels ("Forfalte servicer", "Aktive maskiner") in uppercase tracking-wide — plausible wrapping or overflow, but it needs a real viewport at 320/360/390 px in nb, per apps/web/CLAUDE.md §12.
  • PrimeVue internals. node_modules is not installed, so I could not confirm that <Message> (:103) emits role="alert", nor that <Skeleton> is aria-hidden, nor whether <Card>'s content wrapper introduces a landmark. Finding 4 is written to stand regardless: no live region exists at the page level either way.
  • Whether LIST_LIMIT/take: 10 truncation is ever hit in practice depends on real customer data volume; the defect in finding 1 is that the UI cannot express the difference, which is true at any volume.

Baseline additions ​

Descriptive IDs with definitions — the orchestrator should renumber.

  • DATA-TRUTHFUL-COUNT — a number presented as a total must come from a count, never from .length of a server-truncated list; where a list is truncated, the UI says so and links to the full set.
  • CONTENT-SUMMARY-AGREES — a summary headline must not contradict the detail rendered below it; if it measures a narrower scope than it appears to, the copy names that scope.
  • EMPTY-FIRSTRUN — a zero-data (first-run) state is visually distinguishable from a healthy/complete state and offers the action that creates the first item.
  • NAV-STALE-DATA — a status surface that can go stale exposes its freshness and a way to refresh without a full page reload.

Note for reconciliation: EMPTY-FIRSTRUN overlaps the existing MSG-06 proposals (b) "failed fetch must not render as empty state" — they are adjacent but distinct: MSG-06 is failure-shown-as-empty, this is empty-shown-as-success. Both belong in one family about honest state rendering.

Cross-project note ​

  • DATA-TRUTHFUL-COUNT (finding 1) — likely in playout and tt-time-tracker, both of which have count-bearing overview surfaces; the customer-portal admin analytics page (queue #30, same data-display/dashboard + data-display/statistics patterns) should be checked for the identical array.length-as-total shape before this is filed, since a single fix may cover both.
  • EMPTY-FIRSTRUN / finding 3 — the members and tt-time-tracker audits have already reported failure-rendered-as-empty (MSG-06); the inverse (empty-rendered-as-success) is worth a deliberate check on every landing surface in all four projects.
  • Finding 4 (silent load lifecycle) — the role="status" gap around skeletons is a whole-programme theme; only AppLayout.vue:439 (offline banner) uses a live region anywhere in this app's shell.
  • Finding 6 (fetch once on mount) — a repo-rule violation specific to customer-portal (apps/web/CLAUDE.md §2 mandates TanStack); worth grepping for other pages that hand-roll ref + onMounted fetches before filing.