Appearance
[UX] customer-portal — Customer assets
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 24 Patterns:user-feedback/empty-states(1get_patterncall;data-display/tableanddata-display/card-gridjudged covered by the baseline and skipped for budget)
Summary
The three asset surfaces split sharply. CustomerAssetsPage / CustomerAssetDetailPage are competent — PageLayout, DataTable with a card slot, SafeImage, real skeletons, a history-aware back affordance — with a cluster of ordinary defects around error feedback, empty states and disabled submits. LiveTrackingPage is a different thing entirely: every number it shows is invented on the client from a hash of the asset id (hours in operation, battery/fuel level, active/inactive, "last update"), and every map position is guessed by string-matching the location name against 20 hardcoded Norwegian cities. It is labelled "Shows all tracked units in real time" and is reachable by deep link today by anyone with read CustomerAsset. That is the one thing to fix first, and hiding the sidebar entry does not fix it.
Second in line, and much cheaper to fix: two file-upload controls on the detail page are <label> wrappers around display:none inputs, so they cannot be reached by keyboard at all, and five write paths swallow their errors with a bare catch { }.
Already covered project-wide, not re-filed here (see PROJECT-LEVEL.md): NAV-03 (no meta.title on any of the three routes, router/index.ts:100-125); MSG-02 at the getErrorMessage helper level (used at CustomerAssetsPage.vue:120); A11Y-04 for PrimeVue Card's #title rendering as a non-heading — note that PageLayout and MobileListHeader do emit a real <h1>, so the page-level part of that project finding does not apply to this feature.
Findings
1. Live tracking presents synthesised telemetry and guessed map positions as real data — Blocker · DATA-SYNTHETIC (proposed)
Where: apps/web/src/pages/LiveTrackingPage.vue:74-109 (synthesis), :42-72 (position guessing), :199-204 (map render) What: trucks is built by seeding a hand-rolled hash on the asset id and deriving every displayed value from it:
ts
const rng = pseudoRandom(seed);
const isBattery = rng % 2 === 0;
...
active: rng % 5 !== 0,
hoursToday: 3 + (rng % 10),
energyLabel: isBattery ? t("liveTracking.card.batteryLevel") : t("liveTracking.card.fuelLevel"),
energyPercent: 30 + (rng % 60),
lastUpdateMinutes: 2 + (rng % 30)Nothing there comes from the API. Whether a machine is shown as battery- or diesel-powered is decided by the parity of a hash. Positions are no better: when the location has no latitude/longitude, guessCoords() (:65-72) substring-matches the location name against a 20-entry city table and, failing that, picks a city by hash % 20; assets with no location at all resolve to the empty string and land on Oslo. Every marker is then jittered by up to ±0.05° (~±5 km) so the fabricated cluster looks organic (:99-100). The only real signal on the page is hasDevice (asset.deviceId != null, :102), and it merely tints the marker.
The page is titled "Live Tracking" with the subtitle "Shows all tracked units in real time" (en.yml / nb.yml, liveTracking.mapDescription), and the cards render a coloured energy bar with a percentage and a "Last update: 12 min" line. There is no demo/mock banner anywhere.
Why it matters: a fleet customer reads "Truck 4021 — Active — Fuel level 68% — 7 hours in operation today — last update 12 min" for a machine that may be parked, empty, or 300 km from the pin. This is decision-grade information about physical equipment and it is fiction. Per the queue's inventory note the sidebar entry is visible: false pending release, and I have not filed that as a defect — but visible: false on a menu item is not a release gate. router/index.ts:111-117 resolves the route for any authenticated user whose ability includes read CustomerAsset, i.e. every customer, and the URL is guessable.
Fix: two options, in order of preference. (a) Remove the synthesis: render only hasDevice, real latitude/longitude and fields the API actually returns, and show machines with no coordinates in an explicit "position unknown" list rather than on the map. (b) If the page must survive as a demo until the telemetry backend lands, put it behind a build/feature flag in the route definition (not a menu flag) so it cannot be deep-linked in production, and label it unmistakably as sample data in the UI. Either way, delete guessCoords and pseudoRandom — a "close enough" map pin for physical plant is worse than no pin.
2. Two file-upload controls are unreachable by keyboard — High · A11Y-03
Where: apps/web/src/components/customer-asset/AssetDocumentsCard.vue:81-90; apps/web/src/components/customer-asset/CreateServiceHistoryDialog.vue:377-386What: both are a styled <label> wrapping <input type="file" class="hidden">. Tailwind's hidden is display:none, so the input is neither focusable nor programmatically activatable, and a <label> is not in the tab order and has no keyboard activation behaviour of its own. There is no tabindex, no role="button", no keydown handler.
Why it matters: a keyboard-only user cannot upload a document to a machine, and cannot attach photos or files to a service-history entry — there is no alternative path to either. The same repo already has the correct shape two files away: CustomerAssetsPage.vue:510-526 and AssetOverviewCard.vue:178-192 use a real <Button> plus inputRef.click(), which is keyboard reachable. So this is an inconsistency within one feature, not a missing capability.
Fix: replace both labels with a <Button> (or a <button type="button">) that calls fileInputRef.click(), matching the two call sites that already do it.
3. Five write paths swallow their error and show the user nothing — High · MSG-06 (proposed — see PROJECT-LEVEL.md collision note; this is the "failure discarded" variant)
Where: AssetServicePlansSection.vue:112-114 (// silently handle), :145-147 (// handle), :161-163 (// handle); CreateServiceHistoryDialog.vue:263-264 (// handle), :256-258 (attachment upload) What: every one of these is catch { } with a comment where the error handling should be. Concretely:
- Log a service (
saveHistory) — on failure the dialog stays open, the button un-spins, and nothing else happens. The user's most likely reading is "it didn't register" and the most likely response is to press Save again, which is exactly how you get duplicate service records. - Attachment upload — the entry is created, then each attachment is POSTed in a loop whose
catchis annotated// non-fatal: entry was created, attachment upload failed. The dialog then closes on success. A technician who attaches four inspection photos and sees the dialog close believes the photos are on the record. They are not, and nothing ever says so. For an annual inspection (sakkyndig kontroll) that record is the documentation. - Link plan / unlink plan / resume schedule — same shape; the unlink confirm dialog simply does not close, which is the only hint that anything went wrong.
Why it matters: silent write failure is the worst failure mode available: the user forms a false belief about system state and cannot detect it. It also contradicts the repo's own frontend rule (apps/web/CLAUDE.md §7: "No silent failures").
Fix: every one of these already has useToast() available in-file or one import away — surface toast.add({ severity: "error", ... }) with a mapped message, keep the dialog open with the entered data intact, and for attachments report per-file failure and offer a retry against the created entry rather than closing.
4. Deleting an uploaded document is irreversible, one tap, unconfirmed and unreported — High · MSG-05
Where: apps/web/src/components/customer-asset/AssetDocumentsCard.vue:115-124What: a small text/rounded trash button calls deleteDocMutation.mutate(doc.id!) directly. No confirmation dialog, no undo, and .mutate is used with no onError, so a failed delete is also silent (the tile stays and the user assumes the button missed). Two files away, unlinking a service plan — a reversible action — does get a full confirm dialog (AssetServicePlansSection.vue:336-362), so the app applies the safeguard to the recoverable action and skips it on the destructive one.
Why it matters: I rate this High rather than Medium because it is unrecoverable server state destroyed by a single tap on a small target with no confirmation step — the severity table's "data loss risk". On touch the trash sits at the trailing edge of a tile the user also taps to open the document.
Fix: route it through the same ResponsiveDialog confirm the unlink flow uses, naming the file ("Delete CE-dokument.pdf? This cannot be undone."), and add an onError toast.
5. Checklist items typed signature and image are collected as free text — High · FORM-CONTROL-TYPE (proposed)
Where: apps/web/src/components/customer-asset/CreateServiceHistoryDialog.vue:520-539What: the item renderer has explicit branches for checkbox, dropdown and number, then a default <template v-else> that renders a <Textarea> for everything else — including signature and image, which are recognised enough to get a parenthetical label ("(signature)", "(image upload)", :527-531) but not enough to get a control. A required image item is satisfied by typing any character (validateForm, :170-188, only checks non-empty).
Why it matters: a checklist author (queue #17, checklist builder) can define "Photo of lifting hook" or "Technician signature" and the fill-in form cannot collect either. The inspection record then carries the word "yes" where an image or signature was required, and validation reports it as complete. Scope note: the defect is in the service-history dialog, which is opened from the asset detail page — flagging here, but the fix likely belongs with the checklist-builder audit.
Fix: implement a file input for image (reusing the attachment upload path) and a canvas/signature-pad control for signature; until then, do not let the builder offer item types the renderer cannot honour.
6. The list's error state renders a vue-query lifecycle token, and its 403 branch is dead code — Medium · MSG-02, MSG-03
Where: apps/web/src/pages/CustomerAssetsPage.vue:242; packages/domain-helpers/src/create-domain-helpers.ts:135; packages/ui/src/components/DataTable.vue:247-259, :270-275What: the page passes :error-message="t('customerAssets.list.error', { status })", where status comes from useCustomerAssetList() and is computed(() => queryResult.status.value) — the vue-query lifecycle string ("pending" | "error" | "success"), not an HTTP status. en.yml:564 / nb.yml:564 are "Failed to load (status: {status})" / "Kunne ikke laste (status: {status})", so the rendered message is literally "Kunne ikke laste (status: error)".
The same value is passed as :status, and DataTable's statusCode computed only recognises a number, { status: number } or { response: { status: number } } — a string falls through to null. Consequences: errorStateDescription is never rendered, and the statusCode === 403 branch that would show forbiddenMessage (:271-273) can never fire on any page wired this way. The list also passes no forbiddenMessage at all. There is no retry control in either branch.
The detail page gets this right — useStatusCode(assetQuery.error) yields a real numeric code and a dedicated 403 message (CustomerAssetDetailPage.vue:44-49) — so the two halves of one feature behave differently.
Why it matters: a user denied access to the machines list sees a generic failure carrying a meaningless engineering token, with nothing to act on. (status: error) is noise that reads as a bug even when the cause is a mundane permission or network problem.
Fix: pass the query error (not the lifecycle status) into DataTable, mirroring useStatusCode; supply forbiddenMessage; drop {status} from the user-facing string in favour of a next step; add a Retry button to the error EmptyState via a slot.
7. One empty state serves three different situations, and offers no way out — Medium · EMPTY-01 (proposed)
Where: apps/web/src/pages/CustomerAssetsPage.vue:241; en.yml:565 / nb.yml:565What: the only empty message is "No machines match the search." / "Ingen maskiner matcher søket.", rendered for all three of: a customer with zero machines, a search with no hits, and a page beyond the last result. DataTable (:532-541) accepts emptyDescription, emptyIcon and an #empty-action slot; the page passes none of them.
Why it matters: the user-feedback/empty-states pattern separates first-use, no-results and completed precisely because they need different copy and different actions, and its anatomy requires a primary action plus a secondary recovery path. A new customer whose fleet has not synced yet is told their search matched nothing though they never searched — and the "Add machine" button that would resolve the state is up in the header (desktop) or a small round FAB (mobile), not in the empty state where the user is looking.
Fix: branch on searchQuery: with no query, a first-use state ("No machines registered yet") with the Add machine CTA in the #empty-action slot; with a query, a no-results state that echoes the term and offers a clear-search action.
8. Submits are disabled while invalid and during submission; the validation message they gate is unreachable — Medium · FORM-05, FORM-06
Where: apps/web/src/pages/CustomerAssetsPage.vue:539 (with the dead error at :410-416); CreateServiceHistoryDialog.vue:556; AssetServicePlansSection.vue:320What: the add-machine submit is :disabled="!addForm.name.trim() || createMutation.isPending.value". The page also builds a full validation apparatus — addSubmitAttempted (:54), :invalid on the input (:400), an aria-describedby-wired error span (:410-416) and a submitAddMachine guard (:106-108) — none of which can ever run, because the button that would set addSubmitAttempted cannot be pressed while the name is empty. The same disabled-while- invalid pattern repeats in the log-service dialog and the link-plan dialog. Both also disable during submission, which (subject to PrimeVue's :loading → disabled mapping, see Unverified) drops focus to <body> at the moment of activation.
Why it matters: FORM-05's point exactly — a greyed button in a bottom sheet gives the user nothing to act on and no statement of what is missing, while the app has already written the sentence that would explain it.
Fix: keep the buttons enabled, let submitAddMachine's existing guard do the blocking, and use aria-busy rather than disabled during the request.
9. The Leaflet map is never destroyed on unmount — Medium · NAV-02
Where: apps/web/src/pages/LiveTrackingPage.vue:133-146 (initMap), :195-197 (onMounted), :199-204 (watch) What: map is a module-scope let assigned in initMap(); there is no onUnmounted and no map.remove(). Leaflet registers window-level resize/scroll handlers and a tile pipeline per instance.
Why it matters: in an SPA with a persistent shell, every visit to /live-tracking leaves a live map behind — handlers, tile requests and DOM held by the detached container. On the Capacitor build, where the process is long-lived, that accumulates across a session.
Fix: onUnmounted(() => { map?.remove(); map = null; }).
10. Clickable rows are role="button" and one of them nests a real button inside — Medium · A11Y-03
Where: packages/ui/src/components/DataTable.vue:608-617; apps/web/src/components/customer-asset/AssetServiceHistorySection.vue:140-150 and :197-205What: DataTable puts role="button" and tabindex="0" on each <tr> and binds @keydown.enter only. role="button" on a table row removes it from the table's accessibility tree — a screen-reader user in table-navigation mode loses row/column context for the whole grid — and Space, the other expected activation key for a button, does nothing. The service-history list has the same role="button" on a <div> (:145) but does handle Space (:149), so the two lists in one feature answer different keys.
Worse, that history row contains a real <Button> ("Process", :197-205) inside the role="button" container. Nesting an interactive control inside another is invalid, and the inner button's @click.stop does not stop keyboard activation of the outer role.
Related and lower-cost: CustomerAssetsPage.vue:266 puts cursor-pointer on the whole Card while only the inner <button> (:268-367) is clickable, so the card's padding advertises a click it will not honour.
Why it matters: this is the shared @aadalen/ui table, so the row-semantics half of it lands on every list page in the app. Flagging it here because the nested-button case is specific to this feature.
Fix: in DataTable, drop role="button" from the <tr>, make the first cell (or a trailing chevron cell) contain a real link/button carrying the row's accessible name, and handle both Enter and Space wherever a row-level handler stays. In AssetServiceHistorySection, hoist "Process" out of the clickable container or make the row a link and the container non-interactive.
11. "Request agreement" hands off to a hardcoded Norwegian mailto: with no fallback — Medium · CONTENT-01, MSG-04
Where: apps/web/src/components/customer-asset/AppointmentRequestDialog.vue:17-28What: choosing an appointment type closes the dialog and sets window.location.href = "mailto:service@aadalen.no?subject=…&body=…", where subject and body are Norwegian string literals built in code — "Forespørsel om time – …", "Hei,\n\nJeg ønsker å bestille time for følgende:…" — regardless of the active locale. Only the type label inside them goes through t().
Why it matters: an English-locale user gets a Norwegian draft email, which is a CONTENT-01 failure the i18n files cannot catch because the strings never enter them. And because the dialog closes before the navigation, a device with no registered mail handler (common in the Capacitor webview and on shared/kiosk desktops) leaves the user on the detail page with no feedback, no draft and no other route to the same request — this is the only "no agreement" call to action on the page.
Fix: move subject/body into en.yml/nb.yml; keep the dialog open and show the address plus a copyable pre-filled body as a fallback, or replace the handoff with the in-app contact form (contact route) which already exists.
12. Add-machine rejects normal phone photos that the detail page accepts — Medium · MEDIA-COMPRESS (proposed)
Where: apps/web/src/pages/CustomerAssetsPage.vue:29 and :75-83, versus apps/web/src/components/customer-asset/AssetOverviewCard.vue:126-139What: the add dialog hard-rejects any file over 2 * 1024 * 1024 bytes with "Image is too large. Maximum size is 2 MB." and does no client-side processing. The picture control on the detail page for the same asset pipes the file through compressImageToBase64() and has no size ceiling.
Why it matters: apps/web/CLAUDE.md states the app is used primarily on phones. A default-quality photo from a current handset is routinely 3–5 MB, so the flow is: try to add a machine with a photo → rejected as too large → add it without → open the detail page → the identical photo uploads fine. The user has no way to know the second path exists. The constraint is at least disclosed up front (pictureHint, :518), so FORM-04 passes; this is the inconsistency, not the disclosure.
Fix: call compressImageToBase64() in onAddPictureSelected as the detail page does, and keep the size check only as a post-compression backstop.
13. The table view's asset thumbnail is a bare <img> — Low · MEDIA-SAFEIMAGE (proposed)
Where: apps/web/src/pages/CustomerAssetsPage.vue:151What: the name column cell renders h("img", { src: row.pictureUrl, alt: "" }). The card slot for the same data uses <SafeImage> (:277-282), and apps/web/CLAUDE.md requires it explicitly: "Data-bound remote images (S3/Dataverse URLs) use <SafeImage> … never a bare <img> — expired URLs must degrade to the placeholder, not the browser's broken-image glyph."
Why it matters: low because default-view="card" (:240) and the mobile forced-card path mean the table is only reached by a deliberate desktop toggle — but when reached with expired presigned URLs, every row shows a broken-image icon instead of the wrench placeholder the card view falls back to.
Fix: render h(SafeImage, { src: row.pictureUrl, alt: "", class: "..." }) in the cell.
14. Untranslated English in the live-tracking map popups — Low · CONTENT-01
Where: apps/web/src/pages/LiveTrackingPage.vue:167-169, :96What: const assetLabel = pin.assetCount === 1 ? "asset" : "assets" is interpolated straight into the popup HTML, and the truck name falls back to the literal "Unknown". Neither goes through t(); there are no i18n keys for them. (The rest of the page is correctly keyed under liveTracking.* with full en/nb parity.)
Why it matters: a Norwegian user tapping a location pin gets "3 assets". Minor on its own, and academic while finding #1 stands — but it is the kind of literal that survives a rewrite of the surrounding page.
Fix: add liveTracking.map.assetCount with a plural form and a name fallback key.
Unverified
- A11Y-01 (contrast). Not determinable from code. The page uses ÅDALEN semantic tokens throughout (
text-text-3,text-status-danger-fg,bg-status-warn-bg), so the answer lives inpackages/ui/src/styles/theme.cssand needs a contrast tool on a rendered page. Two spots worth measuring first:text-text-3onbg-sunken(used for serial numbers and hints throughout), and white-on-bg-accentin theDataTableview toggle (DataTable.vue:430). - A11Y-06 (responsive / short viewport). Needs a rendered viewport. Specific things to check at 320px: the add-machine bottom sheet with the optional-details section expanded and the keyboard open (
CustomerAssetsPage.vue:434-529); the log-service sheet with a multi-item checklist, which hasmaxHeight: 90vhon desktop but relies onBottomSheet's 86% on mobile; and the detail page's fixedh-[400px]map block (LiveTrackingPage.vue:226). - PrimeVue internals —
node_modulesis not installed (PROJECT-LEVEL standing caveat), so I could not confirm: whetherMessagecarriesrole="alert"(affects MSG-01 onCustomerAssetDetailPage.vue:141-154andLiveTrackingPage.vue:233-253); whetherButton's:loadingalso setsdisabled(affects the FORM-06 half of finding #8); whetherDialog's focus trap honours the nativeautofocusattribute onCustomerAssetsPage.vue:399(FORM-11); and what elementCard's#titleslot renders (A11Y-04, already a project-level item). - Backend telemetry. Finding #1 is asserted from client code only — the synthesis is unambiguous in the source. I did not check whether the API exposes real telemetry fields that the page could have used instead; that would sharpen the fix but does not change the finding.
AssetOverviewCard.vue:178-185— the picture-change overlay button has:titlebut noaria-labelor text.titleis a valid last-resort accessible name, so I have not filed it as A11Y-05; worth confirming with a screen reader that it is announced.
Baseline additions
Proposed with descriptive IDs per the brief — the orchestrator should renumber, and several of these overlap with collisions already logged in PROJECT-LEVEL.md.
DATA-SYNTHETIC— No user-facing value is fabricated, randomised or inferred on the client. Every displayed datum either comes from the API or is shown as explicitly unavailable. Placeholder/demo data must not be reachable in production, and hiding a navigation entry is not a release gate. (Finding #1. Distinct from the MSG- family: those cover failures shown wrongly; this covers data invented where there was none. Likely to matter in playout, which has an on-air surface.)*EMPTY-01— First-use, no-results and error are three distinct states with distinct copy. A first-use empty state names the primary action; a no-results state echoes the query and offers a way to clear it. One string for all cases is a defect. (Finding #7; drawn from theuser-feedback/empty-statesanatomy.)FORM-CONTROL-TYPE— A field declared as a specific input type (signature, image, date, signature-pad) is collected with a control of that type, or the type is not offered. A free-text box standing in for a required artefact makes validation lie. (Finding #5.)MEDIA-SAFEIMAGE— Data-bound remote images degrade to a placeholder rather than the browser's broken-image glyph. (Finding #13. This is already a written repo convention in customer-portal; promoting it because expiring presigned URLs are a four-project problem.)MEDIA-COMPRESS— Client-side image handling (compression, size limits) is identical everywhere the same asset can be attached. A file accepted in one place must not be rejected in another. (Finding #12.)- Findings #3 and #6 cite the existing proposed
MSG-06; #3 is the "failure discarded, nothing shown" variant, which PROJECT-LEVEL.md already groups with variants (a)/(b)/(d). No new ID needed.
Cross-project note
- Silent
catch { }on writes (#3) — the strongest cross-project candidate. It is already logged as MSG-06 against all four projects in PROJECT-LEVEL.md's alignment table; this feature adds five more instances, including one (attachment upload) where the failure is explicitly acknowledged in a comment and still not surfaced. - Keyboard-unreachable
<label>+display:nonefile inputs (#2) — worth grepping for in playout, members and tt-time-tracker; all three have upload surfaces, and the pattern is a common Tailwind idiom. - Disabled-while-invalid submits (#8) — FORM-05/FORM-06 have already been filed against playout and customer-portal auth flows; this shows the same habit in non-auth CRUD dialogs, which suggests it is a house style rather than a local slip. Check members and tt-time-tracker.
- One empty string for first-use and no-results (#7) —
en.ymlshows the identical string reused for marketplace (:146) and machines (:565); the same conflation is likely in every list surface in this repo and in tt-time-tracker's admin tables. - Fabricated data (#1) — no evidence of an equivalent elsewhere. playout is the only other project with a real-time surface and its problem is the opposite (success reported before the write resolves).