Skip to content

[UX] tt-time-tracker — Integrations ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch: develop @ bb3238c · Files reviewed: 7 Patterns: none consulted (baseline covers the feature; budget preserved)

Summary ​

The Integrations admin screen is a PrimeVue card-per-provider list with a connect/configure Dialog, a per-integration recent-syncs Table, and toast + ConfirmDialog feedback. It is in good shape on the destructive-action path (MSG-05 passes — the disconnect confirm names what is lost) and on feedback plumbing. The most actionable defects are a save button that is disabled-without-explanation (FORM-05), credential inputs whose labels are not programmatically associated (FORM-01), and the page having no <h1> because LayoutMain renders the page title as <h2> (A11Y-04) — the last two are shared across every LayoutMain-based admin view.

Findings ​

1. Connect/configure save button is disabled while invalid, with nothing to act on — Medium · FORM-05 ​

Where: services/client/src/views/Admin/Integrations.vue:241-246 (:disabled="!canSave"), logic at :344-349What: The dialog's primary button ("Connecter"/"Enregistrer") is hard-disabled unless form.invoiceTypes.length > 0 and — on a fresh connect — every credential field is non-empty. When those conditions are not met the button is greyed out and there is no inline validation message telling the user which field is missing or that at least one invoice type must be checked. Password/InputText fields have no required/invalid state either. Why it matters: A user who opens the dialog and unchecks all three invoice types, or leaves a credential blank, sees a dead button and no explanation — the baseline's exact "disabled submit gives the user nothing to act on" failure. Discoverability is worst when the reason is the invoice-type checkboxes, which read as optional. Fix: Keep the button enabled; on click, validate and surface per-field messages ("Renseignez au moins un type à synchroniser", "Champ requis"). Mark empty required credential fields invalid. Use aria-busy for the in-flight state rather than relying on :disabled.

2. Credential inputs and field-group labels are not programmatically associated — Medium · FORM-01 ​

Where: services/client/src/views/Admin/Integrations.vue:181-194 (credential <label> + Password/InputText), also the group labels at :203 and :223What: Each credential field renders <label class="text-sm font-medium">{{ field.label }}</label> immediately followed by a Password or InputText that has no id/inputId, and the label has no for. There is no aria-label/aria-labelledby fallback. The "Types à synchroniser" and "Déclencheur de synchronisation" group labels are likewise bare <label>s not tied to their control groups. (The invoice-type checkboxes at :209-217 are correctly associated via :input-id/:for — so the association is understood, just not applied to the text/secret inputs.) Why it matters: A screen-reader user tabbing into a secret field hears an unlabelled edit box and cannot tell the API key from the account id — a real a11y barrier on a data-entry form. Rated Medium rather than High because it is an admin-only configuration surface and the labels are visually adjacent. Close call. Fix: Give each input a stable inputId and point the <label for=…> at it (mirror the checkbox rows). Wrap each field group in a <fieldset>/<legend> or add role="group" + aria-labelledby.

3. Page has no <h1> — top heading is <h2> — Medium · A11Y-04 ​

Where: services/client/src/components/Layout/LayoutMain.vue:6 and :74 (title slot rendered inside <h2>), consumed by Integrations.vue:2-5What: LayoutMain renders the #title slot ("Intégrations") inside an <h2> on both the mobile and desktop headers; there is no <h1> anywhere on the route. Auth/standalone views (Dashboard.vue, UserInvoices.vue, NotFound.vue, …) each render their own <h1>, so the document-outline convention exists in the app but every LayoutMain-shell admin page — this one included — starts at <h2>. Why it matters: The page's main heading is not exposed as the top of the outline; screen-reader users navigating by heading level find no <h1> and a level skip from the (absent) top. WCAG 2.2 A11Y-04. Fix: Render the LayoutMain title slot as <h1> (it is the page's primary heading). This is a one-file change that fixes every admin view at once — cross-reference from the other LayoutMain feature drafts rather than re-filing.

4. Raw backend error strings reach the user on the save path — Medium · MSG-02 ​

Where: services/client/src/views/Admin/Integrations.vue:394-396; helper at composables/useIntegrations.ts:81-87What: handleSave does layout.showError(e instanceof Error ? e.message : …), and the request() helper throws new Error(detail?.message ?? \Request failed: ${res.status}`). So whatever the NestJS layer puts in message— or, on a non-JSON/500 response, the literalRequest failed: 500 — is shown verbatim in the error toast. The other handlers (handleTest, handleBackfill, handleResync, handleRemove, load`) already use fixed human sentences; only the save path leaks the raw string. Why it matters: Users can see server/validation prose (or an HTTP-status string) that is not written for them and, in French, may arrive untranslated. Fix: Map known error codes to French sentences; fall back to one generic "Erreur lors de l'enregistrement de l'intégration." Reserve the raw message for the console.

5. Connect dialog does not focus its first field on open — Low · FORM-11 ​

Where: services/client/src/views/Admin/Integrations.vue:156-199, openConnect at :351-358What: Opening the connect/configure Dialog sets showDialog = true but never moves focus to the first credential input. PrimeVue Dialog focuses the dialog container, not the first field. Why it matters: Keyboard users must tab in before typing; minor added friction on a form whose whole purpose is credential entry. Fix: autofocus the first credential input, or focus it in an @show handler.

6. Error toasts are announced politely, not assertively — Low · MSG-01 ​

Where: services/client/src/components/PebbleToastHost.vue:3-8 (shared host); errors routed via App.vue:73-75What: Both success and error toasts render into a single role="region" aria-live="polite" container. Per MSG-01, error output should carry role="alert" / aria-live="assertive" so it interrupts. This feature's ~10 feedback calls (save, test, backfill, resync, disconnect, load failure) all flow through this shared host, so its errors are announced only when the SR reaches a pause. Shared-infra, not feature-specific — noted once here. Why it matters: A failed save/test may not be announced promptly to a screen-reader user. Fix: Give error-severity toasts role="alert"/assertive live semantics while keeping success polite.

7. No non-destructive way to pause an integration despite an active flag — Low · proposed FORM-TOGGLE-DISABLE (see Baseline additions) ​

Where: services/client/src/views/Admin/Integrations.vue:79-113; model field active at composables/useIntegrations.ts:44What: The Integration model carries an active boolean, but the card exposes no toggle to enable/disable syncing. The only way to stop an integration is "Déconnecter", which the confirm copy says deletes the sync history. The assigned forms/toggle pattern has no on-screen presence. Why it matters: An admin who wants to temporarily halt syncing (e.g. during an accounting close) must destroy the connection and its history and re-enter credentials to resume. Fix: Add a PrimeVue ToggleSwitch on the card bound to active (PATCH { active }), with an accessible name and programmatic pressed state, so pause is non-destructive.

Unverified ​

  • A11Y-01 (contrast): the muted greys used pervasively (text-surface-400/-500 on bg-surface, e.g. the statusDetail line at Integrations.vue:63-68 and the sync-table error/externalId cells) are plausibly below 4.5:1 but cannot be settled from class names. Needs a contrast tool on the rendered page.
  • A11Y-06 (short viewport / mobile keyboard): the connect Dialog is fixed at width: 480px with a footer; on a short viewport with the keyboard open the credential fields and footer buttons may be clipped. Needs a rendered viewport.
  • FORM-06 (loading→disabled focus drop): the save button combines :loading="saving" with :disabled="!canSave". PrimeVue Button maps :loading to disabled, which would drop focus to <body> on submit — but node_modules is not installed, so PrimeVue internals cannot be read from source. Flagged Unverified per the standing caveat.
  • Currency: this feature displays no monetary amounts (no Intl.NumberFormat/toFixed on money in Integrations.vue), so the cross-project currency-precision check does not apply here.

Baseline additions ​

  • FORM-TOGGLE-DISABLE (proposed; orchestrator to renumber): where a resource has an enabled/disabled state, the UI must offer a non-destructive toggle for it; "delete/disconnect" must not be the only way to stop an active integration/subscription. (Finding 7.)
  • CONTENT-01: not-applicable — see project-level i18n finding (tt-time-tracker has no i18n layer by design).

Cross-project note ​

  • A11Y-04 (no <h1>, title rendered as <h2>) stems from the shared LayoutMain shell and so affects every admin/list feature in tt-time-tracker, not just Integrations. Sibling shells were flagged in customer-portal's auth/onboarding layouts (PROJECT-LEVEL) — worth a single shell-level fix per project.
  • MSG-01 polite-not-assertive error toasts is shared infra (PebbleToastHost); check the toast hosts in playout, customer-portal and members for the same success/error live-region conflation.
  • MSG-02 raw backend message on one handler while siblings use fixed copy is the same inconsistency shape logged for customer-portal (getErrorMessage prefers raw string) — likely present wherever handlers pass e.message straight to a toast.
  • NAV-03 (no per-route document title) fails project-wide — see PROJECT-LEVEL.md; not re-filed here.