Skip to content

[UX] tt-time-tracker — Overview ​

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: none consulted (baseline sufficient; budget conserved)

Summary ​

The Overview (/admin/overview) is the admin-wide hours table: a filter bar (period + user/project/status), server-computed totals, an infinite-scroll table with batch approve/reject, and per-entry detail modal. It is well built in several respects the baseline cares about — totals come from a real aggregate endpoint rather than the loaded page, reject is confirmed and approve is one-click-with-undo, sort headers are keyboard-operable with aria-sort, and toasts carry role/aria-live. The material gaps are all accessibility: table rows can only be opened with a mouse, and on mobile the header action buttons lose their accessible names.

Findings ​

1. Time-entry rows can only be opened with a mouse — High · A11Y-03 ​

Where: services/client/src/components/InfiniteTable.vue:98-102 (consumed at services/client/src/views/Admin/Overview.vue:240-242) What: Each data row is a <tr class="cursor-pointer" @click="emit('select', item)"> with no tabindex, role, or key handler. Opening an entry's detail modal (ModalEntryDetails, the only way to view/edit remark, note, times) is a pure click. The sort headers on the same table are keyboard-operable (role="button", tabindex="0", Enter/Space) — so the omission is on the rows specifically, and the sub-row (:130-135) shares it. Why it matters: A keyboard or screen-reader user can filter and read the table but cannot open, inspect, or edit any time entry — a hard barrier on the admin's core review surface. Fix: Make the row a focusable control: tabindex="0", role="button", an accessible name, and @keydown.enter/.space.prevent="emit('select', item)" — mirroring what the header cells already do. (Cross-project theme: click-only rows, see PROJECT-LEVEL.md A11Y-03.)

2. Mobile header action buttons have no accessible name — Medium · A11Y-05 ​

Where: services/client/src/components/Layout/LayoutMain.vue:109-111 (affects Overview's #buttons: Export, Ajouter, "Approuver jusqu'au…", Overview.vue:9-32) What: On mobile the shell hides PrimeVue's .p-button-label with display:none, collapsing each labelled button to its icon. The icon is a decorative <i class="pi pi-download"> with no text. display:none also removes the label from the accessible-name computation, so the button is announced only as "button". The two shell-owned icon buttons beside them (search, close) correctly set aria-label; the slotted action buttons do not. Why it matters: A screen-reader user on a phone hears three unlabelled "button"s for Export / Add / Approve. Fix: Add aria-label to each action button (they already pass label, so mirror it), or hide the label with a visually-hidden utility instead of display:none. Caveat: whether PrimeVue's Button auto-mirrors label→aria-label could not be confirmed — node_modules is absent (PROJECT-LEVEL.md caveat); by default it does not. If a follow-up pnpm install shows it does, this downgrades. Shell-wide, not Overview-only.

3. Filter controls rely on placeholders instead of labels — Medium · FORM-01 ​

Where: services/client/src/views/Admin/Overview.vue:88-162What: The period SelectButton, the year/month/range DatePickers, the user select, the project select and the status Select have no programmatically associated <label>. Identity is conveyed by placeholder text where present (FieldSelectUser.vue:8 "Utilisateur"; status has none — it shows the selected value) and by nothing at all for the SelectButton/DatePickers. Why it matters: A screen-reader user tabbing the filter bar hears "combobox" with no indication of which axis it filters; once a value is chosen even the placeholder is gone. Fix: Give each control a <label> (visually-hidden is fine for a compact filter bar) or an aria-label naming the filter axis.

4. Admin pages have no <h1> — Medium · A11Y-04 ​

Where: services/client/src/components/Layout/LayoutMain.vue:6,74What: The page title slot ("Heures") is rendered as <h2> in both the mobile and desktop headers; no <h1> exists anywhere on the Overview page (grep of src/ finds <h1> in other views but not in Overview or the admin shell). The document heading hierarchy therefore starts at h2. Why it matters: Screen-reader users navigating by heading find no top-level heading, and the level skip misrepresents structure. Affects the whole /admin section, not just Overview. Fix: Render the page title as <h1> in LayoutMain.

5. Whole-list load error is not announced — Low · MSG-01 ​

Where: services/client/src/components/ListErrorState.vue:1-22 (rendered by InfiniteTable.vue:62-77) What: When the entries query fails, InfiniteTable swaps in ListErrorState, which has a retry button but no role="alert" and sits in no live region. A filter/period change that errors updates the DOM silently for assistive tech. (The toast path — layout.showError, toast.success — is fine: PebbleToast.vue:5 sets role and PebbleToastHost.vue is an aria-live region.) Why it matters: A screen-reader user who re-filters into an error sees no rows and hears nothing explaining why. Fix: Give ListErrorState's container role="alert".

6. Small inline text-button targets — Low · A11Y-02 ​

Where: services/client/src/views/Admin/Overview.vue:200-204 ("approuver toute la période"); services/client/src/components/MobileFilterSheet.vue:33-41 ("Réinitialiser les filtres") What: Both are bare text <button>s well under the 24×24 CSS-px minimum (11-13px inline text, no padding to speak of). Why it matters: Hard to hit accurately, especially on touch. Fix: Pad to a ≥24px target or promote to a small Button.

7. Period and filter state is not in the URL — Low · NAV-06-proposed (view/filter state reflected in URL) ​

Where: services/client/src/stores/filters.store.ts:5-58What: All filter state (year, month, range, user, project, status) lives in plain Pinia refs — no URL sync and no persistence. A browser refresh resets to the current month; a specific filtered/period view cannot be deep-linked, shared, or restored via Back. Why it matters: An admin who filters to e.g. one user's rejected hours in March cannot bookmark or share that view, and loses it on reload. Fix: Reflect the period + filters in query params (the same theme flagged for customer-portal list features in PROJECT-LEVEL.md).

Unverified ​

  • A11Y-01 (contrast): the many text-surface-400 secondary strings (empty-state description Overview.vue:311, ListErrorState/ListEmptyState detail text) and the 10px uppercase text-surface-700 totals labels (Overview.vue:167,175) need a contrast tool.
  • A11Y-06 (responsive): the inline filter bar wraps to a mobile "Filtres" sheet; behaviour at ~700px height / with a keyboard open needs a rendered viewport.
  • FORM-06: the batch approve/reject buttons use PrimeVue :loading; whether that disables the just-activated button and drops focus is a PrimeVue internal — node_modules absent.
  • Finding 2 outcome: depends on whether PrimeVue Button mirrors label→aria-label (see caveat there).

Baseline additions ​

  • NAV-06-proposed (view/filter state reflected in URL): list/period/filter selection that changes what data is shown should be reflected in the URL so it survives refresh and can be deep-linked. (One of several colliding NAV-06 proposals — orchestrator to reconcile per PROJECT-LEVEL.md.)
  • CONTENT-01: not-applicable — see project-level i18n finding.
  • NAV-03: fails project-wide (index.html:17 is "Tim" everywhere) — see PROJECT-LEVEL.md; not re-filed here.
  • Positive confirmations worth recording: totals use the server entryControllerAggregate endpoint (allEntries.store.ts:88-90), not a page sum — the "aggregate from one page" theme in PROJECT-LEVEL.md does not apply to this screen. Reject is confirmed (MSG-05, Overview.vue:588); approve is one-click with an 8s undo toast (justified deviation).

Cross-project note ​

  • A11Y-03 (click-only rows): already confirmed in all four projects (see PROJECT-LEVEL.md cross-project table); this is another instance.
  • FORM-01 (placeholder-only filter labels): likely present in customer-portal / members filter bars — worth checking their list filters.
  • Finding 2 (mobile label hidden via display:none): tt-time-tracker–specific — it comes from this repo's LayoutMain, not a shared library.