Skip to content

[UX] tt-time-tracker — Syncs ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch: develop @ bb3238c · Files reviewed: 10 Patterns: user-feedback/progress-indicator

Summary ​

Syncs is a small admin list: a trigger button, a status table, an empty state and a details modal. The list plumbing is above the project average — Table wires an explicit error/retry state and the empty state has a real CTA, so this screen does not repeat the "failure renders as empty" defect flagged project-wide. The two real problems are that the whole feature is keyboard- unreachable past the header (rows are mouse-only, so a keyboard/SR admin can never open a failed sync's error), and that a triggered sync gives no live progress — the table never refetches, so the run you just launched and its outcome are invisible until a manual page reload.

Findings ​

1. Sync rows are mouse-only — details/error unreachable by keyboard — High · A11Y-03 ​

Where: services/client/src/components/Table.vue:92-97 (consumed by views/Admin/Syncs.vue:22,52-56) What: Each data row is a bare <tr class="cursor-pointer" @click="emit('select', item)"> with no role, no tabindex, and no keydown handler. Selecting a row is the only way to open ModalSyncDetails, which is where a failed sync's error text and the emails-scanned / invoices-found counts live. The sortable column headers on the same table are done correctly (role="button", tabindex="0", Enter/Space handlers, aria-sort), so the omission is on rows specifically. Why it matters: A keyboard-only or screen-reader admin can sort the table but cannot open any sync. When a sync fails, the reason is only in the modal, so the very users who most need the diagnostic are locked out of it. Direct WCAG 2.1.1 barrier. Fix: Make the row a real control — render an interactive element per row (or add role="button", tabindex="0" and @keydown.enter/@keydown.space.prevent="emit('select', item)" to the <tr>), with a visible focus ring. Because Table.vue is shared across every list in the app, one fix here lifts all of them.

2. Triggered sync shows no live progress and never appears until manual reload — Medium · CONTENT-04 (progress-indicator) ​

Where: views/Admin/Syncs.vue:84-97 and collections/syncs.ts:9-19 (no refetchInterval); composables/collections/useSyncs.tsWhat: handleSync awaits only the trigger POST, then flips syncing back to false and toasts « Une nouvelle synchronisation a été lancée ». The actual import is a background job that moves running → success/error, but the list is a TanStack DB collection with no polling and the mutation does not invalidate/refetch the ["syncs", org] query. So after the toast: the new running row does not appear, the SyncStatus badge never advances, and completion/failure is silent. The refetch returned by useSyncs is wired only to the error-state's Réessayer button, which does not show on a successful (empty-or-stale) load. Why it matters: The user is told a sync "started" and then sees nothing change — no way to tell whether it is still running, finished, or failed without reloading the page. The progress-indicator pattern's own checklist calls out "confirm that state survives / reconciles after refresh, navigation, or retry" and "announce completion"; this does neither. Fix: After triggerSync succeeds, refetch the collection (or set a short refetchInterval while any row is running) so the launched sync appears and advances; give the running badge an aria-live="polite" announcement of completion. Even a one-shot reloadSyncs() in the success branch would close the worst of the gap.

3. Raw sync error string shown verbatim with no guidance — Low · MSG-02 / MSG-03 ​

Where: components/Modals/ModalSyncDetails.vue:9 — <code v-if="sync.error">{{ sync.error }}</code>What: The details modal renders the server/worker error field raw inside a <code> block. Whatever the import job threw (SDK/IMAP/exception text) reaches the admin unmapped, and there is no "what to do next" line — no retry affordance inside the modal, no hint about re-running or checking the mailbox connection. Why it matters: Raw exception prose is confusing and occasionally leaks internals; on its own it tells the admin nothing actionable. This is milder than a user-facing surface because the audience is an org admin debugging their own import, hence Low. Fix: Keep the raw string available (it is useful for admins) but precede it with one human sentence and a recovery action ("L'import a échoué. Réessayez ; si le problème persiste, vérifiez la connexion email.") and a Réessayer button in the modal footer.

4. Page has no <h1> — heading starts at <h2> — Medium · A11Y-04 ​

Where: components/Layout/LayoutMain.vue (both the mobile :9 and desktop :74 title render <h2>); Syncs supplies « Synchronisations » via #title. What: The shared page shell renders the page title as <h2>, and there is no <h1> anywhere on the route. Heading hierarchy starts at level 2. Why it matters: Screen-reader users navigating by heading level find no top-level heading and a skipped level. This is a shared-shell defect — every admin page built on LayoutMain inherits it, so it is a candidate to raise once project-wide rather than per feature. Fix: Promote the LayoutMain page title to <h1> (reserve <h2> for sections within the page).

Unverified ​

  • A11Y-01 (contrast). The SyncStatus badge uses white text on bg-blue-500 / bg-success / bg-error, and the empty/error states use text-surface-400 on light surfaces — plausibly thin, but contrast needs a rendered check, not class names.
  • A11Y-06 (responsive / short viewport). Table wraps in overflow-x-auto and the modal is a bottom Drawer on mobile; behaviour at ~700px / keyboard-open not verified.
  • FORM-06 (focus drop on the trigger button). Button :loading="syncing" — PrimeVue's loading typically sets disabled, which would drop focus to <body> when the just-clicked button becomes busy. PrimeVue internals are not readable (node_modules absent), so Unverified per the standing caveat.
  • MSG-01 (status/alert roles on toasts). layout.showSuccess/showError set store refs; the renderer that turns them into toasts was not inspected, so whether success carries role="status" and errors role="alert" is unverified.

Baseline additions ​

None. Finding 2 is covered by CONTENT-04 plus the progress-indicator pattern; it also overlaps the project's existing MSG-06 theme ("async state not reflected"). No new rule needed.

Cross-project note ​

  • A11Y-03 (finding 1) — the click-only-row pattern is already confirmed in all four projects (see PROJECT-LEVEL.md cross-project table). This is the tt-time-tracker instance, and because it lives in the shared Table.vue it affects every list feature here, not just Syncs.
  • CONTENT-01 — not-applicable (no i18n layer by design; see project-level i18n finding).
  • NAV-03 — fails project-wide (index.html:17 is « Tim » on every route); see PROJECT-LEVEL.md, not re-filed here.
  • Finding 2 (no refetch after a triggered background job) likely recurs on any tt-time-tracker screen that fires a worker job and shows its status (e.g. #22 Jobs, #20 API keys); worth checking those with the same lens.