Skip to content

[UX] customer-portal — Sync monitoring (admin) ​

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

Summary ​

Sync monitoring is a competent admin console — five surfaces (status, runs, events, failures) behind a tab strip, live polling that backs off when idle, server-side entity/status/mode filters, and toast feedback on every trigger. Its weakest points are that the whole feature is hardcoded English except the four tab labels (CONTENT-01), the tab strip renders no associated tab panel (A11Y), and the free-text search on the three paginated tables filters only the rows of the current page, so "no matches" can be shown while matches sit on page 2. Heavy operations (Full / Force re-sync) fire on a single click with no confirmation. It also inherits the project-level shared-DataTable gaps.

Assignment checks, answered explicitly:

  • Do the filter controls reach the API? Partly. The <Select> filters (entity / mode / status / source / jobStatus) are bound into each query's queryParams and re-fetch from the server — verified end to end (SyncRunsPage.vue:24-36, SyncEventsPage.vue:54-82, SyncFailuresPage.vue:37-58). This is not the tt-time-tracker inert-filter defect. The free-text search box is the exception — see Finding 1.
  • Displayed totals / aggregates? Pass. Backlog / failed / Dynamics / DB counts all come from the server (syncAdminStatus, syncAdminGetRecordCounts), not from .length of a truncated page, so the "aggregate computed from one page" theme does not apply here.
  • Offboarding / last-admin hypotheses: not applicable — this feature has no user/role management surface.
  • Money precision: not applicable — no currency is rendered; formatNumber handles integer record counts only.

Project-level, already filed (one-line each, per PROJECT-LEVEL.md):

  • NAV-03 — one static document title app-wide; none of the four child routes set one.
  • Shared DataTable cluster (see drafts/customer-portal--catalog.md findings 4–7): no aria-live on the results region (MSG-01 / CONTENT-04); desktop search InputText and filter <Select>s have no label / aria-label (FORM-01 / A11Y-05); clickable rows respond to Enter but not Space (A11Y-03). All four sync tables ride this component. One instance is feature-specific and live here: SyncStatusPage.vue:406 passes forbidden-message but never wires :status, so DataTable's 403 branch (DataTable.vue:271-274) is unreachable and that message can never render.
  • MSG-02 — getErrorMessage prefers the raw backend string; it is on this feature's trigger/retry error paths (SyncStatusPage.vue:92,201). See Finding 5 for a second, more direct instance.

Findings ​

1. Search on the paginated tables filters only the current page — Medium · proposed DATA-PAGE-SCOPE (see MSG/FILTER-SCOPE theme) ​

Where: apps/web/src/pages/admin/SyncRunsPage.vue:137, SyncEventsPage.vue:284, SyncFailuresPage.vue (search placeholder via DataTable); mechanism at packages/ui/src/components/DataTable.vue:199-226. What: Runs / Events / Failures are server-paginated by the page's own useTablePagination offset, but the pages do not pass page/totalPages (or serverSearch) to DataTable. So filteredData runs its client-side substring search over tableData — i.e. only the 25/50 rows currently loaded. A search that matches nothing on the visible page renders EmptyState with "No sync runs match the current filters." (SyncRunsPage.vue:132), even when matching rows exist on another page. Why it matters: An admin hunting a specific entity id or error among failed events types it into the search box labelled "Search events", sees "No sync events found.", and concludes the record does not exist — when it is one page over. The control looks global and behaves page-local. Fix: Either wire the search term into the server query (add it to queryParams and pass server-search), or, if search is only ever meant to be a within-page convenience, drop the search box on these three tables and rely on the server <Select> filters. The entity <Select> already scopes server-side, so routing free text through the same path is the consistent option.

2. The whole feature is hardcoded English except the four tab labels — Medium · CONTENT-01 ​

Where: every sync page. SyncPage.vue:17-22 is the only file that calls t() (the tab labels). SyncStatusPage.vue, SyncRunsPage.vue, SyncEventsPage.vue, SyncFailuresPage.vue, SyncEventDetail.vue and SyncFailureDetailModal.vue import no useI18n at all. What: Page titles ("Sync Status", "Sync Runs"), every column header ("Backlog", "Failed", "Current run", "Fetched", "Unchanged"…), all <Select> option labels ("All entities", "Delta", "Running"…), empty/error strings, button labels ("Refresh counts", "Re-queue", "Retry", "Force"), the popover ("Latest failed events", "No failed events found."), the detail modals, and every notify* toast body are literal English. customer-portal is held to CONTENT-01 per feature (en/nb). Why it matters: An nb admin gets an all-English monitoring console sitting under Norwegian nav chrome and Norwegian tab labels — the one screen where the tabs are translated but nothing they open is. It is also a large future translation-debt surface. Severity call: Medium not High — admin-only internal tooling limits the audience, consistent with how CONTENT-01 was rated on the catalog feature. Fix: Move the strings into admin.sync.* keys in both YAML files and route them through t(). The tab labels already model the split.

3. The tab strip has no associated tab panel — Medium · A11Y (navigation/tabs) ​

Where: apps/web/src/pages/admin/SyncPage.vue:34-54. What: <Tabs><TabList><Tab> renders the tab row, but the tab content is a sibling <div class="mt-4"><RouterView/></div> outside the <Tabs> subtree. There is no <TabPanels>/<TabPanel>, so the rendered panel carries no role="tabpanel" and no aria-labelledby back to its tab, and any aria-controls PrimeVue puts on the tab points at a panel id that does not exist. Per the navigation/tabs pattern ("Missing ARIA Roles" anti-pattern) a tab without a linked panel is invisible-as-a-relationship to assistive tech. Navigating between the child routes via router.push is itself fine — this is the pattern's legitimate "URL-synced tabs" variation and stays client-side (no reload); the defect is purely the missing tab↔panel wiring. Why it matters: A screen-reader user activating "Failures" is told they are on a tab but is given no programmatic path from that tab to the table that appeared, and the table is not announced as the tab's panel. Verify: what element PrimeVue's Tab emits (role="tab", its aria-controls target) is Unverified — node_modules is not installed. The absence of any panel element is assertable from this template regardless. Fix: Render the routed content inside PrimeVue's <TabPanels>/<TabPanel> (keyed to the route name) so the panel gets role="tabpanel" + aria-labelledby, or, keeping the router-view, hand-add role="tabpanel" / :aria-labelledby / tabindex="0" to the content wrapper and matching ids on the tabs.

4. Full and Force re-syncs fire on a single click, no confirmation — Medium · MSG-05 ​

Where: apps/web/src/pages/admin/SyncStatusPage.vue:180-206, buttons at :359-376 (Full, Force) and :242-252 (Re-queue); triggerSync calls mutateAsync immediately with no intervening dialog. What: "Delta", "Full" and "Force" each trigger a sync run on click. Full and Force are heavyweight, entity-wide re-ingestion operations (the run reports processed / created / removed counts, so a run can delete rows), and "Force" is styled severity: "danger". None prompts for confirmation. retryFailed, retryEvent, reprocessEvent and the bulk retrySelected (SyncEventsPage.vue:160-177) likewise fire immediately. Why it matters: The three action buttons sit adjacent in the row's Actions cell; a mis-click on "Force" instead of "Delta" kicks off a full re-sync of an entity with no chance to cancel and no "this will re-process N records" warning. MSG-05 asks that heavy/irreversible actions confirm first and say what will happen. Severity call: Medium not High — these enqueue backend work rather than destroy user data directly, and the button disables while a run is active; but Force on a large entity is disruptive and unstoppable once started. Fix: Gate at least Full and Force behind a PrimeVue confirm/dialog naming the entity and mode ("Force re-sync all Assets? This re-processes every record."). Delta and the retry actions can stay one-click.

5. Raw error strings are shown verbatim in toasts — Medium · MSG-02 ​

Where: apps/web/src/pages/admin/SyncEventsPage.vue:141,154,173 — notifyError(error instanceof Error ? error.message : "…"); and via getErrorMessage at SyncStatusPage.vue:92,201 (the project-level MSG-02 helper, which prefers the backend string). What: The events page bypasses even getErrorMessage and pipes error.message — the raw SDK/network string — straight into the toast body on retry / reprocess / bulk-retry failure. There is no code-to-copy mapping and no nb translation. Why it matters: A 500 or a network drop surfaces as whatever prose the backend or fetch layer produced (e.g. a stack-derived message or "Failed to fetch"), untranslated, with no next step. Fix: Map known statuses to human sentences and fall back to one generic localized line, the same treatment recommended for the shared getErrorMessage helper. Reuse that helper here at minimum instead of error.message.

6. Failures table error text uses a non-existent design token, so it renders unstyled — Low · repo convention (design tokens) ​

Where: apps/web/src/pages/admin/SyncFailuresPage.vue:103 — class: "max-w-xs truncate block text-sm text-danger-600". What: text-danger-600 is a raw Tailwind palette scale, which the repo's own token rules forbid (the eslint no-restricted-syntax rule warns on it) and which is not defined in theme.css; the ÅDALEN token is text-status-danger-fg. An undefined utility produces no color, so the truncated error message in the Error column inherits default body text instead of the intended danger red — every other error surface in the feature (SyncStatusPage popover, SyncFailureDetailModal, SyncEventDetail) correctly uses text-status-danger-fg. Why it matters: The one column whose whole purpose is to flag the error is the one not visually marked as an error, and it is inconsistent with the modal it opens. Fix: text-status-danger-fg.

7. "Next" is offered on a full final page, leading to an empty page — Low · minor friction ​

Where: apps/web/src/composables/admin/use-table-pagination.ts:17 — canNext = getRowCount() === limit; consumed by all three paginated sync pages. What: With no total count available the composable treats "this page is full" as "there is a next page." When the last page holds exactly limit rows, Next stays enabled; clicking it loads an empty page that then shows the empty-state copy (and Next correctly disables, so it is recoverable). Why it matters: A small but repeatable dead-click that makes the admin briefly think the data ended in error. It is a documented convention in the composable, so this is flagged as shared friction rather than a bug. Fix: Return a total from the list endpoints and paginate on it, or label the control so a possibly-empty next page is expected.

Unverified ​

  • A11Y-01 (contrast). Not assessable from code. Watch spots if a tool is run: the many text-text-3 / text-[10px] / text-[11px] muted labels in SyncStatusPage cells and SyncEventDetail.vue:120, and status-badge text on pale status-*-bg fills.
  • A11Y-06 (short viewport / responsive). These are dense multi-column admin tables (Status has 8 columns) with a horizontally scrollable tab strip (scrollable) and a min-width: 320px popover; behaviour at 320/360/390px and at ~700px height needs a rendered check. DataTable has no #card slot wired here, so on mobile these fall to horizontal table scroll.
  • PrimeVue / @aadalen/ui internals (node_modules absent): whether Tab emits role="tab"/aria-controls (Finding 3); whether Button :loading drops focus when it flips to disabled (FORM-06) on the trigger buttons; whether notify* toasts and Message carry role="alert"/status (MSG-01); whether Popover/Dialog trap and restore focus and are keyboard-dismissable.

Baseline additions ​

  • DATA-PAGE-SCOPE — A search/filter control presented over a server-paginated list must query the whole set, not silently filter only the loaded page; otherwise "no results" is a lie. (Finding 1. Same family as the PROJECT-LEVEL "filters apply to one page" theme — recommend merging into the DATA-TRUTHFUL-AGGREGATE/filter-scope reconciliation rather than adding separately.)
  • No new rule needed for Findings 2–7 (CONTENT-01, navigation/tabs A11Y, MSG-05, MSG-02, repo token convention).

Cross-project note ​

  • Finding 4 (heavy actions fire with no confirm) is worth checking on every admin operational surface in the programme — tt-time-tracker and members both have admin action buttons; the "danger-styled button that acts on one click" shape is easy to repeat.
  • Finding 1 (search that only filters the loaded page) likely recurs wherever a TanStack-table list is server-paginated but keeps a client-side search box — tt-time-tracker uses the same TanStack Table stack, and members' TableData.vue is the equivalent shared component. One coordinated check.
  • Finding 3 (tabs with no linked panel) — grep the other three for tab components whose content is rendered as a sibling/router-view rather than a real tab panel; the same "tabs as route switcher, panel disconnected" shape is a common Vue admin idiom.
  • Finding 5 / MSG-02 is already a confirmed four-project alignment theme; the error.message-straight-to-toast variant here is the most raw instance.