Skip to content

[UX] tt-time-tracker — My invoices ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch: develop @ bb3238c · Files reviewed: 23 Patterns: user-feedback/empty-states, forms/currency-input Preflight: SHA matches. Working tree carries one unrelated modification (STACK.md); no reviewed file is dirty.

Summary ​

The member-facing invoice surface is a well-considered mobile screen — a single prominent capture CTA, a bottom-sheet flow, an SSE-driven live refresh so the OCR result lands without a reload, and a real archive confirmation. Two defects undermine it on the money path: a failed file upload is reported to the user as success, leaving a receipt-less invoice the member has no way to repair; and the list is silently scoped to today while its heading and empty state claim to show everything, so yesterday's receipts read as "you have no invoices". Both are invisible from the screen itself, which is what makes them serious.

Findings ​

1. A failed receipt upload is reported as success, and the resulting invoice cannot be repaired — Blocker · MSG-04, MSG-02, MSG-06 (proposed) ​

Where: services/client/src/components/Modals/ModalAddInvoice.vue:256-276, services/client/src/composables/useInvoiceUpload.ts:39-59What: handleFileImport catches its own upload failure, shows an error toast and returns normally (useInvoiceUpload.ts:55-57 — the catch does not rethrow). handleSave therefore continues past await handleFileImport(...) on line 267 and runs layout.showSuccess("Facture ajoutée") on line 268, emits saved, and closes the sheet. The invoice row was already created by invoices.add(...) on line 263, so what survives is a persisted invoice with link: "".

Server-side, link is only written by the upload endpoint (services/api/src/modules/invoice/invoice.service.ts:88-94), and the OCR job is only enqueued there (lines 95-105). So a failed upload means: no file, no OCR, ever. On the list, isProcessing (UserInvoices.vue:144) is !!invoice.link && !ocrProcessed — with link empty it is false, so the row skips the "Analyse en cours…" state and renders as a finished invoice: « Sans nom » and « 0 € » (UserInvoices.vue:75,99).

There is no recovery path. ModalInvoiceDetails.vue:61-67 renders the file as a read-only field; the member surface has no re-upload control anywhere. Why it matters: A worker photographs a fuel receipt, is told « Facture ajoutée », and closes the app. The expense has no receipt attached and reads as €0. They have no signal anything failed (the error toast and the success toast fire in the same tick), no way to attach the file, and no way to delete-and-retry except archiving. This is money the worker is owed and will not be paid. Fix: Make handleFileImport rethrow so handleSave's existing catch handles it; show the failure and keep the sheet open with the file still attached so the user can retry. If the invoice row was already inserted, either create it only after a successful upload, or add a re-upload affordance to ModalInvoiceDetails for invoices where link is empty.


2. The list shows only today's invoices, but nothing on screen says so — High · MSG-07 (proposed) ​

Where: services/client/src/views/UserInvoices.vue:129, 36-38, 47-60; services/client/src/composables/collections/useInvoices.ts:124-135What: The page calls useMyTodayInvoices (line 129), which filters createdAfter/createdBefore to a midnight-to-midnight window. The section heading reads « Factures récentes » (line 37) and the empty state reads « Aucune facture » / « Appuie sur Nouvelle facture pour en ajouter une » (lines 55-59). Neither mentions a date. There is no date control, no "see all", and no way to widen the window.

The sibling page proves this is the project's own convention being broken: Hours.vue uses the identical layout, heading style and empty-state block, and labels them « Aujourd'hui » (Hours.vue:57) and « Aucune entrée aujourd'hui » (Hours.vue:82).

The user-feedback/empty-states pattern separates a first-use empty state from a no-results (filtered) one. This is a no-results state wearing first-use copy: it tells the user their account is empty and instructs them to create their first invoice. Why it matters: Any member opening « Factures » on the day after uploading a receipt is told they have none, with a CTA inviting them to add it again. Two plausible responses — re-photographing the same receipt (duplicate expense claim) or reporting the receipt as lost — both cost someone real work. Fix: Match Hours.vue: heading « Factures d'aujourd'hui », empty state « Aucune facture aujourd'hui ». Better, since invoices are not a per-day artefact the way hours are, keep a today section but add a route to the member's full history.


3. Invoices cannot be opened by keyboard — High · A11Y-03 ​

Where: services/client/src/views/UserInvoices.vue:66-71What: Each invoice row is a <div> carrying @click="openInvoice(invoice)", with no tabindex, no role, and no keydown handler. Opening an invoice is the row's only purpose and the only route into ModalInvoiceDetails. Confirmed by grep: the view and both modals contain zero role= or aria-* attributes. Why it matters: Keyboard-only and screen-reader users can reach the « Nouvelle facture » CTA (a real <button>, line 15) but cannot open, review, correct or archive any invoice they have submitted. The list is announced as unstructured text. Fix: Make the row a <button type="button"> (or add role="button", tabindex="0" and Enter/Space handling). The CTA on line 15 and the type selectors in both modals already use real <button> elements — this is the one control that does not.


4. Save is permanently disabled when no project is set, with nothing explaining why — High · FORM-05, FORM-04 ​

Where: services/client/src/components/Modals/ModalInvoiceDetails.vue:153-158, 96-102, 52; services/client/src/components/Modals/ModalAddInvoice.vue:91What: completed ends with return project.value != null (line 158), and the Enregistrer button is bound :disabled="!completed" (line 97). Nothing in the modal states that a project is required — the label on line 52 is a bare « Projet », while the fields that are required (Type line 13, Véhicule line 41) carry a red asterisk. The add modal is worse: it marks Projet as optional too (ModalAddInvoice.vue:91) and lets the user save without one, so the common path is an invoice that arrives in this modal with no allocation and an unexplained dead button.

Two aggravating cases:

  • The user only wants to fix the comment. They type it, the button stays grey, no message appears anywhere.
  • Organisations with the projects feature off. hasProjects false hides the whole project block (line 49), so project.value can never be set and Save is unreachable by any input. routes.ts:46 has no feature guard on user-invoices, unlike router/index.ts:66-73, which redirects projects, vehicles and dashboard away when their flag is off — so a bookmarked /invoices still opens a fully functional-looking page whose edit sheet can never be saved.

Why it matters: FORM-05 exactly: a disabled submit gives the user nothing to act on. They cannot tell whether the app is broken, whether their change saved, or what would unblock it. Fix: Keep Enregistrer enabled and validate on click, surfacing « Choisis un projet pour enregistrer ». Mark Projet required in both modals if it truly is. Drop the project requirement (and hide it) when !organization.hasProjects, and add the missing hasInvoices guard for user-invoices alongside the existing feature redirects.


5. Amounts bypass the app's own currency formatter and render a missing amount as "0 €" — Medium · CONTENT-05 (proposed) ​

Where: services/client/src/views/UserInvoices.vue:99What: {{ invoice.amount_ht ?? 0 }} €. The view already imports useFormat (line 118) but destructures only capitalize (line 142), ignoring currency() — which is Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }) (composables/useFormat.ts:26) and returns "-" for a null amount. This is the only money value in the whole client rendered without it: InvoiceList.vue:150,233, UserDetails.vue:331, ProjectDetails.vue:319 and ModalInvoiceReview.vue:347-348 all call currency().

Two visible consequences: 120.5 renders as « 120.5 € » rather than the French « 120,50 € » (fr-FR uses a decimal comma and a space thousands separator, so 1234.5 renders « 1234.5 € » instead of « 1 234,50 € »); and amount_ht is optional in the schema (packages/schemas/src/invoice.schema.ts:16), so an invoice whose OCR ran but extracted no total displays a confident bold « 0 € ». The forms/currency-input pattern names both — "always pass an explicit locale and currency" and, under edge cases, "zero is handled correctly (not shown as 0 or blank)". Why it matters: The member's only view of what they are owed shows an unknown amount as zero, in a format that looks wrong to a French reader and disagrees with the admin screens reviewing the same invoice. Fix: {{ currency(invoice.amount_ht) }} — drop the ?? 0 and the literal €; currency() supplies the symbol and renders - for a missing value.


6. Server error strings reach the user verbatim, in English — Medium · MSG-02, MSG-03 ​

Where: services/client/src/utils/index.ts:83-86, services/client/src/composables/useInvoiceUpload.ts:53What: extractErrorMessage returns fromValue(e.message) ?? fromValue(e.error) ?? e.statusMessage ?? fallback — the curated French ERROR_CODE_MESSAGES map only applies when the error carries a recognised code (line 58-59); otherwise any raw message wins over the French fallback the caller supplied. The upload path throws new Error(\Upload failed: ${res.status}`), so the toast on a rejected upload reads **« Erreur — Upload failed: 413 »** in an otherwise entirely French UI. The same route surfaces NestJS class-validatorconstraint strings (lines 73-77), which are English by default. **Why it matters:** MSG-02 verbatim. The user gets an HTTP status code and no idea what to do — and per finding 1, this is the only signal that their receipt did not upload. **Fix:** HaveuseInvoiceUploadthrow a coded error, and inextractErrorMessageprefer the caller's Frenchfallbackover an unrecognised rawmessage` (or gate the raw path behind a dev flag).


7. No live region on any state change in the feature — Medium · MSG-01 ​

Where: services/client/src/views/UserInvoices.vue:41-104, services/client/src/components/Modals/ModalAddInvoice.vue:70-73What: Neither the view nor either modal contains a single role or aria-live attribute. Four state changes swap silently: spinner → list (lines 41-47); the per-row « Analyse en cours… » skeleton resolving to a real vendor and amount when the SSE invoice.updated event invalidates the query (composables/useEventStream.ts:73); the empty state appearing; and the validation error « Un fichier est requis » (ModalAddInvoice.vue:70-73), rendered as a plain <small> with no role="alert" and no aria-describedby linking it to the file input. Why it matters: WCAG 4.1.3. A screen-reader user gets no notification that the list loaded, that OCR finished and filled in the amount, or that their save was rejected for a missing file — the button simply does nothing from their perspective. The empty-states pattern lists "skipping announcement strategy" as a named anti-pattern. Fix: aria-live="polite" on the list container, role="status" on the loading and empty blocks, role="alert" on the validation <small> plus aria-describedby from the file input.


8. Member shell has no main landmark; the page's only h1 is today's date — Medium · A11Y-04, CONTENT-02 ​

Where: services/client/src/components/Layout/LayoutApp.vue:10, services/client/src/views/UserInvoices.vue:8-10What: LayoutApp.vue renders <RouterView> inside plain <div>s (lines 2-10) with a <nav> alongside (line 13) — so all member page content sits outside any landmark. The admin shell does this correctly (LayoutMain.vue:2 has <main>), as does OnboardingWizard.vue:26; the member shell is the gap.

Separately, the page's single h1 is capitalize(todayDate) — « 30 Juillet 2026 ». The bottom-nav control that leads here is labelled « Factures » (LayoutApp.vue:58), and the destination's top-level heading is a date. The feature name appears only as a small uppercase h2 further down. Why it matters: A screen-reader user cannot skip to main content, and on landing hears a date rather than confirmation that they reached the invoices page (CONTENT-02). Fix: Wrap <RouterView> in <main> in LayoutApp.vue. Make the invoices heading the h1 and demote the date, or add a visually-hidden h1 naming the page.


9. Submit button is disabled during submission — Medium · FORM-06 ​

Where: services/client/src/components/Modals/ModalAddInvoice.vue:125What: :disabled="uploading" on the Enregistrer button. Activating it disables the element the user just activated, which drops focus to <body>. Note the validation side is correct here — the button stays enabled while the form is invalid and handleSave guards on line 258, which is exactly what FORM-05 asks for. Why it matters: On a slow mobile upload the user loses their place in the sheet; a screen-reader user is returned to the top of the document mid-submission. Fix: Keep the button enabled, add aria-busy="true" while uploading, and guard re-entry at the top of handleSave (if (uploading.value) return;). The spinner and « Envoi… » label already communicate the state.


10. Icon-only controls have no accessible name — Low · A11Y-05 ​

Where: services/client/src/components/Modals/ModalInvoiceDetails.vue:87-93, services/client/src/components/Modals/ModalAddInvoice.vue:61-67What: The archive control is <Button icon="pi pi-trash" severity="danger" text /> with no aria-label — its accessible name is empty. Same for the file-remove X (MdiClose) in the add sheet. SheetOrDialog.vue:31,91 shows the project already knows the idiom (aria-label="Retour"), so this is an omission, not a convention. Why it matters: A screen-reader user hears "button" for a destructive, unlabelled control. The confirmation dialog behind it is good (useArchiveConfirm.ts:29-33 names the action and offers Annuler — MSG-05 passes), but only if you can tell what you pressed. Fix: aria-label="Archiver la facture" and aria-label="Retirer le fichier".


11. File constraints are stated only after they are violated — Low · FORM-04 ​

Where: services/client/src/components/Modals/ModalAddInvoice.vue:47, 70-73; services/api/src/modules/invoice/invoice.controller.ts:201-206What: The dropzone says « PDF, image — tu peux aussi importer ». The server enforces a 25 MB limit (line 202) that is never mentioned; the client converts photos to PDF at JPEG 0.7 but at full sensor resolution (useInvoiceUpload.ts:33-36 — no downscale), so a high-megapixel phone photo can exceed it. That the file is required at all is communicated only after a failed submit (lines 70-73). Why it matters: The one constraint the user can trip is invisible, and tripping it lands in finding 1's failure mode. Combined with finding 6 the user sees « Upload failed: 413 » and nothing else. Fix: State « PDF ou photo, 25 Mo maximum » in the dropzone copy, check file.size client-side in onFileChange/onDrop, and downscale the canvas to a sensible max dimension before toDataURL.


12. Type selection is conveyed by colour alone — Low · A11Y-07 (proposed) ​

Where: services/client/src/components/Modals/ModalAddInvoice.vue:15-32, services/client/src/components/Modals/ModalInvoiceDetails.vue:17-32What: The three type options are a v-for of <button>s whose selected state is expressed purely as border/background/text colour. No aria-pressed, no role="radio"/aria-checked, no role="radiogroup" on the container, and no non-colour affordance such as a checkmark. Why it matters: A screen-reader user cannot determine which invoice type is selected, and the group is not announced as a single choice. The empty-states pattern's visual-accessibility guidance names this directly: do not rely on colour alone to convey selection state. Fix: Wrap in role="radiogroup" with an accessible name and give each button role="radio" + :aria-checked, or at minimum :aria-pressed. Add a check icon on the selected option.


13. The "today" window is captured once and goes stale at midnight — Low · NAV-02 ​

Where: services/client/src/composables/collections/useInvoices.ts:125-134What: useMyTodayInvoices computes today/tomorrow as plain (non-reactive) Dates at setup. The filter object is baked into the collection cache id (createScopedCollection.ts:63), so it never refreshes while the component stays mounted. A PWA left open on the invoices tab across midnight keeps querying yesterday's window; an invoice added after midnight will appear optimistically and then disappear on the next refetch, because the server filter excludes it. Why it matters: Narrow, but this is a time-tracking app for shift workers, where crossing midnight is routine — and the symptom (an invoice vanishing right after a success toast) is the same failure shape as finding 1. Fix: Derive the window from a reactive "current day" source, or recompute on visibilitychange/app resume and rebuild the collection when the day changes.


Not applicable ​

  • CONTENT-01 — not-applicable; see project-level i18n finding. tt-time-tracker has no i18n layer by design (BASELINE.md, CONTENT-01 section).
  • SEC-01 … SEC-05, FORM-02/03/07/09/10, NAV-01/04/05 — no credential, token or multi-step surface in this feature.
  • NAV-03 — fails, but project-wide rather than feature-specific: no route in routes.ts sets a title and nothing calls document.title/useHead anywhere in the client; every page is « Tim » (services/client/index.html:17). Recorded here for the orchestrator to raise once, like CONTENT-01, rather than 25 times.

Passing, worth noting ​

  • MSG-05 — archive confirms first and names the action (useArchiveConfirm.ts:29-33).
  • FORM-05 (add sheet) — submit stays enabled while invalid; the guard is in the handler, not the disabled attribute. This is the behaviour finding 4 asks the edit sheet to adopt.
  • NAV-02 — SSE stream is torn down via onScopeDispose and reconnects on org change (useEventStream.ts:108); the OCR result therefore lands live, with no polling and no stale "Analyse en cours…" requiring a reload.
  • The file-picker dismiss guard (ModalAddInvoice.vue:177-194) is a genuinely thoughtful mobile fix, and worth copying to the other projects' upload sheets.

Unverified ​

  • A11Y-01 (contrast) — cannot be settled by reading code. Several values need measuring: text-surface-500 on bg-surface-50 for the weekday eyebrow (UserInvoices.vue:5), text-white/80 on bg-accent in the CTA subtitle (line 23), text-surface-700/70 on bg-surface for the dropzone hint (ModalAddInvoice.vue:47), and --text-secondary throughout the empty state (lines 54-59). The /70 and /80 alpha modifiers are the likeliest failures.
  • A11Y-06 (short viewport / mobile keyboard) — SheetOrDialog pins the mobile sheet to h-[80dvh] (SheetOrDialog.vue:173) and the desktop dialog to minHeight: 32rem (line 85). Whether the footer's Enregistrer button stays reachable at ~700px height with a keyboard open needs a rendered viewport.
  • A11Y-02 (24×24 targets) — the file-remove X is w-7 h-7 (28px, passes) and the Annuler button is py-2 px-6 (passes). Not asserting a failure, but the icon-only PrimeVue text Button on ModalInvoiceDetails.vue:87-93 renders at a theme-derived size I could not determine from source.

Baseline additions ​

Four proposed rules, each seen concretely here and each plausible in the other three projects:

Proposed IDWording
MSG-06A multi-step action reports success only when every step succeeded. A sub-step that fails must abort the flow, not be caught and swallowed while the caller proceeds to a success message.
MSG-07An empty state distinguishes "nothing exists yet" from "nothing matches the current view", and names the active filter or time window. Filtered-empty must never be shown as first-use-empty.
CONTENT-05Money, numbers and dates render through the project's shared locale-aware formatter, never by string concatenation. A missing value renders as an explicit placeholder, never as 0.
A11Y-07Selection state in a custom control group is exposed via ARIA (aria-pressed, or role="radiogroup" + aria-checked) and by a non-colour affordance — never by colour alone.

MSG-06 is the important one: it is the rule that turns finding 1 from an opinion about error handling into a checkable defect, and the "catch, toast, continue to success" shape is common enough that it is worth grepping for across all four repos.

Cross-project note ​

  • MSG-06 / finding 1 — highest cross-project suspicion. Any upload or multi-call save where a helper owns its own try/catch and the caller shows a success toast afterwards. Check playout (media upload) and customer-portal first; the grep is a catch block containing a toast but no throw.
  • CONTENT-05 / finding 5 — customer-portal shares the money domain and the PrimeVue stack, and has an nb/en locale split that makes an unlocalised {{ value }} kr more visible, not less. Also worth checking tt-time-tracker's own components/Invoice.vue:12, which renders {{ invoice.amount_ht }} bare — the same defect outside this feature.
  • MSG-07 / finding 2 — any list scoped to a default window. members and playout both have dashboard lists; check whether their empty copy names the scope. Note that within this repo Hours.vue gets it right, so the canonical behaviour is already established internally.
  • A11Y-03 / finding 3 — clickable <div> rows are the most portable defect in the programme. Expect hits in all three others; worth a single cross-project grep for <div + @click in list templates.
  • A11Y-04 / finding 8 — the missing <main> is in LayoutApp.vue, so it affects every member-facing page in this repo (Hours, Dashboard, Profile, My invoices), not just this feature. Fix once in the shell.
  • NAV-03 — tt-time-tracker sets no route titles at all. Compare with the other three before the consistency pass; if playout does it properly, it is the model.