Appearance
[UX] tt-time-tracker — Projects
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 25 Patterns:data-display/table(body was mangled compiled MDX as warned; only the anatomy/TOC was readable — the sort-control-inside-header-cell anatomy is the one thing cited from it)
Summary
The Projects list is in good shape: cards are real <button>s, ListErrorState is wired, and the empty states are properly differentiated per view (search / active / completed / archived) with recovery actions. Almost everything below is on the details page, which has no error branch at all — a failed or 404 project fetch renders a completely blank route — and whose two tables and two of its four stat cards report page-1 slices as if they were whole-dataset totals, so the « Coût HT », « Factures » and « Équipe » figures are wrong for any busy project.
Two things the brief asked me to check specifically:
- Filter wiring: no
buildApiFiltersdefect here.projectControllerFindAllaccepts no query parameters at all; the search box and the actifs/terminés/ archivés split are pure client-side filters over a fully-loaded collection (useProjects.ts:18-19,ProjectList.vue:188-201). Nothing is dropped. - Rows as click-only
divs: the list grid passes —ProjectList.vue:106is a<button>. The failure is one level down, in the sharedTable.vue, whose<tr>rows the details page relies on.
Project-wide, already recorded: CONTENT-01 — not-applicable, see project-level i18n finding. NAV-03 — fails project-wide, see PROJECT-LEVEL.md.
Findings
1. A failed or missing project renders a completely blank page — High · MSG-04
Where: services/client/src/views/Admin/Projects/ProjectDetails.vue:2, :332, :423-431What: The template has exactly two top-level branches: <LayoutMain v-if="project"> and <LayoutMain v-else-if="loading">. useQuery is destructured for data and isLoading only — isError is never read. On a 404 (deleted project, mistyped or stale projectId), a 403, or a network failure, isLoading settles to false with data undefined, so neither branch renders. The route paints nothing: no header, no title, no back button, no message. queryClient.ts:11-14 retries once and stops. Why it matters: Deep-linking to a project that has since been deleted — or following a stale link from the global search or a recents list — gives an admin an empty white content area with no explanation and no control inside the view to recover from. The app shell's sidebar is the only way out, so it is not a total trap, which is why this is High and not Blocker. Fix: Destructure isError/error from the projectControllerFindOne query and add a third branch rendering ListErrorState (already imported elsewhere in this feature) inside a LayoutMain that still carries the « Retour » button, with distinct copy for 404 (« Ce projet n'existe plus ») and for a transport failure (retry button).
2. Failed entry and invoice loads are shown as "this project has no data" — High · MSG-06 (project-level family)
Where: ProjectDetails.vue:267-303 and :311-326; queries at :520-532 and :557-565What: Table.vue:71-84 exposes error / errorTitle / errorDescription / @retry props with an explicit comment — "so a failed fetch never masquerades as 'no data'" — and renders ListErrorState in-table when error is true. Neither <Table> on this page passes it: all three useQuery calls destructure only isLoading. A failed entries fetch therefore falls through to the #no-items slot and prints « Aucune entrée pour ce projet »; a failed invoice fetch prints « Aucune facture rattachée à ce projet ». Why it matters: These are the two statements an admin uses to decide whether work was booked and whether the project was billed. A transport failure asserting "nobody worked on this" is worse than an error, because it is actionable and wrong. The capability to do this correctly already exists and is simply not wired up. Fix: Pull isError (and a refetch) out of both queries and bind :error="entriesError" / :error-title="…" / @retry="refetchEntries()" on each <Table>. Same one-line change on both.
3. « Coût HT », « Factures » and « Équipe » are computed from one page, not the dataset — High · proposed DATA-PAGE-AGGREGATE
Where: ProjectDetails.vue:600 and :147 (invoices), :580-587 (team), against queries at :520-532 and :557-565What: Two independent instances of the same mistake.
- Invoices. The invoice query passes
{ projectId }and notake.services/api/src/modules/invoice/invoice.service.ts:174caps the page atMath.min(filters.take ?? 50, 100)— so the client receives at most 50 invoices.totalCost(:600) sums the allocations of exactly those, and the « Factures / rattachées » card (:147) printsinvoicesList.length. The response carriestotal(the entries query at:534already uses it) and it is ignored. - Team.
teamCount(:580-587) counts distinctmemberProfile.idacrossentriesList, which is the 10-row page requested at:526-527. The number is capped at 10 and changes as the user pages the table below it.
By contrast the hours card is right: it uses entryControllerAggregate (:537-545), a real server-side aggregate. So one of the four cards is computed correctly and three neighbours that look identical are not. Why it matters: « Coût HT » is a money figure on an admin summary. A project with 51+ invoices silently under-reports its cost, with no truncation indicator anywhere. « Équipe: 10 intervenants » on a project with 30 contributors is simply false. Both are presented as authoritative totals in large bold type. Fix: Add a projectId-scoped aggregate endpoint (or reuse entryControllerAggregate) for distinct contributors and invoice cost, and read rawInvoicesData.total for the invoice count. As an interim, pass an explicit take and label the cards as partial. Also pass :total/:page to the invoices <Table> (:311-317 sets :page-size="10" with no total, so it client-paginates the already-truncated 50 and reports "Page 1 sur 5" for a set that may be far larger).
4. Table rows are click-only <tr> — mouse-only access to entries and invoices — High · A11Y-03
Where: services/client/src/components/Table.vue:88-92What: <tr class="relative cursor-pointer …" @click="emit('select', item)"> — no tabindex, no role="button", no @keydown.enter/space, no focus style. On the project page this is the only way to open an entry's edit drawer (ProjectDetails.vue:274) or to navigate to a linked invoice (:316). Why it matters: A keyboard or switch user can reach the project page, read it, and edit its name — but cannot open a single time entry or invoice from it. There is no alternate route to the entry drawer on this page. Fix: Put a real <button> or <RouterLink> in the first cell as the row's primary action (and keep the row click as a mouse convenience), or make the row tabindex="0" role="button" with Enter/Space handlers and a focus-visible ring. Belongs in Table.vue, so it fixes every admin table at once — and this row shape is already flagged as a cross-project theme in PROJECT-LEVEL.md, so expect duplicates from the sibling tt audits.
5. On mobile the save / complete / restore buttons have no accessible name — High · A11Y-05
Where: services/client/src/components/Layout/LayoutMain.vue <style scoped> (.mobile-header-buttons :deep(.p-button-label) { display: none }), consumed by ProjectDetails.vue:38-74 and ProjectList.vue:18-21What: The mobile header deliberately hides PrimeVue button labels to render the header actions icon-only. A PrimeVue Button's accessible name comes from its label content; display: none removes that content from the accessibility tree, and none of these buttons sets aria-label. So on a narrow viewport « Enregistrer », « Terminer », « Rouvrir », « Restaurer » and « Ajouter » become five unnamed icon buttons. The « Plus d'actions » ellipsis is the only one that got an aria-label (ProjectDetails.vue:76, ProjectList.vue:23) — which shows the pattern was known. Why it matters: tt-time-tracker ships as a PWA and mobile is a primary surface. A screen-reader user on a phone is offered "button, button, button" where one of them saves and one of them takes the project out of every worker's picker. Fix: Add aria-label matching each label on every button placed in the #buttons slot, or better, have LayoutMain stop hiding labels via CSS and expose a mobile-icon-only convention that sets aria-label from label automatically. Verified from source; how PrimeVue composes the label span is Unverified (node_modules absent) but the CSS selector .p-button-label is the documented one and the views set no aria-label, so the conclusion holds either way.
6. « Rouvrir » reports success but the page keeps showing « Terminé » — Medium · MSG-06 (project-level family)
Where: ProjectDetails.vue:493-503, against :423-433 and :405-409What: Every other action handler navigates away on success (:443, :456, :472, :486), so the list refetch masks the issue. handleReopen is the one that stays on the page. It mutates through the collection (query key ["projects", orgId], see createScopedCollection.ts:97-104), while the page's own state comes from a separate projectControllerFindOne query that nothing invalidates. So after the toast « Le projet a été rouvert », the status pill still reads « Terminé » and the header still offers « Rouvrir ». Why it matters: The admin sees a success message and unchanged state, concludes the action failed, and clicks again. The second click re-sends status: ACTIVE, which is harmless here but the pattern is a footgun. Fix: Invalidate the projectControllerFindOne query key after every project mutation on this page (or drive the header from the collection record rather than a second query).
7. The project name can be cleared and saved empty — Medium · FORM-05
Where: ProjectDetails.vue:168-172 and :437-449; compare ModalCreateProject.vue:56What: Creating a project requires a name (z.object({ name: z.string().min(1, "Nom requis") })). Editing one does not: InputText v-model="project.name" has no validation, handleUpdate only checks changed, collections/projects.ts sends body.name = "" because "" !== undefined, and services/api/src/modules/project/dto/update-project.dto.ts declares @IsOptional() @IsString() name with no @MinLength(1). The empty name persists. Why it matters: Projects are what hours are booked against. A nameless project renders as « Sans nom » on its own page (:21) and as a blank card in the list (ProjectList.vue:113), and becomes unfindable by the search box, which matches on project.name (ProjectList.vue:197). The constraint the user was taught at creation silently stops applying. Fix: Reuse useZodForm with the same schema in handleUpdate, mark the field :invalid with the message, and add @MinLength(1) to UpdateProjectDto.name so the API is not the weaker gate.
8. Unlabelled combobox and date fields — Medium · FORM-01
Where: ModalCreateProject.vue:20-24; ProjectDetails.vue:199-217, :231-235, :418What: Three instances.
- The create modal's task-list field is a bare
<FieldSelectTaskList>with no<label>at all, andFieldSelectTaskList.vue:5hard-codesplaceholder=""— so it renders as an empty box with a dropdown chevron and no name of any kind. - On the details page,
ids.taskListis generated (:418) and passed as:input-id(:234) but no element references it withfor; the nearby<h3>Liste de tâches</h3>is a heading, not a label. - « Période » (
:200) is a<span>, so the twoDatePickers are identified only by theirplaceholder("Début"/"Fin") — placeholder as the sole label. The name, responsible and zone fields on the same card are correctly associated (:164-197), so this is an oversight rather than a house style. Why it matters: A screen-reader user tabs into the create form and hears "combobox" with no indication of what it selects, on the field that determines which tasks workers can book time against. Fix: GiveFieldSelectTaskListalabelprop (or wrap it), addfor=bound toids.taskListon the details page, and replace the « Période »<span>with a<fieldset><legend>plus per-input labels oraria-labels.
9. required on the task-list field is decorative — Medium · FORM-04
Where: ModalCreateProject.vue:20-24, :56, :66What: <FieldSelectTaskList required> passes required through $attrs into FieldSelect → PrimeVue AutoComplete, but the form's zod schema validates only name and handleAdd only calls validate({ name: project.name }). Server-side, CreateProjectDto.taskListId is @IsOptional(). So a project is created with no task list, silently, in an organisation where hasTasks is on. Why it matters: Task lists are what a worker picks from when booking hours against the project. A project created without one degrades the core time-entry flow, and nothing in the creation path told the admin anything was missing. Whether the required attribute reaches AutoComplete's inner <input> (and so whether the browser shows any hint) is Unverified — node_modules is absent — but the client-side and server-side validation gaps are both verified from source. Fix: Add taskList to the zod schema conditionally on organization.hasTasks, pass it to validate(), and render the same <small class="text-(--danger)"> message the name field uses.
10. Sorting the entries table sorts only the visible page, and aria-sort is announced on a cell that is no longer a column header — Medium · A11Y-03 + proposed DATA-PARTIAL-SORT
Where: services/client/src/components/Table.vue:190-208, :225-227, :19-33; columns declared at ProjectDetails.vue:547-554What: Two defects in the same control.
- The entries table is server-paginated (
ProjectDetails.vue:270-275passes:totaland:page), yetentriesColumnsmarks "Nom" and "Date"sortable.Table.vuesortsitems— the 10 rows currently loaded — and whenisServerSideit returnssortedItemsunchanged aspagedItems. The header therefore presents itself as a table-wide sort and delivers a within-page shuffle. - The sort control is
role="button" tabindex="0"applied to the<th>itself (:19-33). An explicitroleoverrides the implicitcolumnheader, so the cell stops being announced as a column header and thearia-sortset three lines above it is not exposed at all. Thedata-display/tablepattern's anatomy places the sort control inside the header cell (HeaderCell --> SortButton) for exactly this reason. Why it matters: An admin sorting by Date to find when work on a project started gets the earliest row of page 1 and no signal that the other pages were not considered. And the sorting affordance is invisible to assistive tech even though someone wrote thearia-sortfor it. Fix: InTable.vue, either emitupdate:sortand let the caller sort server-side whenisServerSide, or refuse to rendersortableheaders in server-paginated mode. Separately, moverole="button" tabindex="0"off the<th>onto a<button>inside it, keepingaria-sorton the<th>.
11. Nothing warns before unsaved project edits are discarded — Medium · proposed FORM-DISCARD-GUARD (already proposed elsewhere as FORM-12(d))
Where: ProjectDetails.vue:433, :5-11; composables/useChangeTracker.ts:23-29What: The page tracks changed and greys out « Enregistrer » accordingly, but nothing consumes it on the way out: the « Retour » button (:8), the sidebar links and the browser back button all navigate away with no onBeforeRouteLeave guard, losing whatever was typed. Separately, useChangeTracker's watch(getter, …, { deep: true }) overwrites copy unconditionally, so if the underlying projectControllerFindOne result is refetched and has actually changed (queryClient.ts:10 sets staleTime: 30_000 and refetchOnWindowFocus is left at its default true), the in-progress edits are replaced by server values and changed flips back to false — the Save button greys out and the user has no indication anything was lost. With TanStack's structural sharing an unchanged refetch keeps the same reference and is harmless, so this second path needs a concurrent edit to trigger; the navigation path needs nothing. Why it matters: Editing a project means retyping a name, reassigning a responsible and setting two dates. One mis-click on « Retour » throws all of it away silently. Fix: Add onBeforeRouteLeave gated on changed with a confirm dialog (useArchiveConfirm shows the house pattern), and have useChangeTracker skip the overwrite while changed is true, surfacing a "this project was modified elsewhere" notice instead.
12. The create modal keeps its previous values, so the same project can be created twice — Low · FORM-09
Where: ModalCreateProject.vue:57, :65-77What: project is a module-level reactive({ … }) in the component, and handleAdd hides the sheet on success (:71) without resetting it or clearing errors. Reopening « Nouveau projet » shows the previous name, zone and task list pre-filled; pressing « Créer » again creates a second, identically named project (the collection assigns a fresh UUID at useArchivableCollection.ts:48, so nothing deduplicates it). Nor does creation navigate to or highlight the new project. Why it matters: Duplicate projects are damaging in this app specifically — workers pick the wrong one and hours land against a phantom, splitting a project's cost across two records. Fix: Reset project and call clearErrors() on close/success, and either navigate to the new project's details page or scroll it into view in the list.
13. The actifs / terminés / archivés view is component state, not URL state — Low · proposed (already proposed elsewhere as NAV-06(e))
Where: ProjectList.vue:164, :174-185; ProjectDetails.vue:443, :456, :472, :486What: view is a local ref. It is lost on reload, cannot be linked or bookmarked, and — because every successful details action does router.push({ name: "projects" }) with no state — an admin working through the archive is dropped back into « actifs » after each restore, and has to reopen the menu and re-enter « Voir les archives » every time. Why it matters: Restoring or reviewing a batch of archived projects becomes three clicks per project instead of one. Fix: Bind view to a ?view= query parameter and preserve it on the post-action router.push.
14. « Terminer » takes effect immediately, with no confirmation — Low · MSG-05
Where: ProjectDetails.vue:57-65, :480-491What: « Archiver » is properly confirmed with consequence copy (« Il ne sera plus disponible pour les nouvelles entrées », :465) via useArchiveConfirm. « Terminer » — which also removes the project from useProjects().list and therefore from every worker's picker (useProjects.ts:18) — fires on a single click and navigates away. Placed at Low rather than Medium because it is reversible: an admin sees « Rouvrir » on the same page. Note that reversal is admin-only, so a non-admin who completes a project cannot undo it — though :49 and :58 already gate both controls behind authStore.isAdmin, so in practice only admins can reach either. Fix: Route « Terminer » through the same confirm helper with copy naming the consequence for time entry.
Unverified
- A11Y-01 (contrast). Needs computed colour values. Several low-contrast-looking combinations are worth checking on a rendered page: the completed-view banner (
ProjectList.vue:44-51,text-emerald-700onbg-emerald-50), thetext-surface-400secondary copy inListEmptyState.vue:19andListErrorState.vue:14,text-primary-700/60andtext-primary-500/70onbg-primary-50in the hours card (ProjectDetails.vue:99-107), and thetext-surface-400pagination label inTable.vue. All asserted as needs measurement, not as failures. - A11Y-06 (short viewport / responsive). The details page stacks four stat cards, two panels and two tables; the mobile header is
stickyand the layout reservespb-24. Needs a rendered ~700px-tall viewport with a keyboard open. - PrimeVue internals.
node_modulesis not installed, so I could not confirm from source howButtoncomposes its label (finding 5), whetherAutoCompleteforwards arequiredattr to its inner input (finding 9), or whetherMenu/ConfirmDialogmanage focus correctly. In each case the finding rests on application-level code that is verified. - Duplicated header slots.
LayoutMainrenders#titleand#buttonstwice (mobile block and desktop block), soProjectDetails.vue:20's<h1>exists twice in the DOM andref="menu"is bound to twoMenuinstances. Both blocks are toggled with Tailwindhidden/lg:hidden(i.e.display: none), which removes the inactive one from the accessibility tree — so I am not filing a duplicate-<h1>finding. Whether the sharedref="menu"resolves to the wrong instance on mobile (PrimeVueMenuteleports its popup tobody, so it may well still work) needs a rendered page to settle. - A11Y-04 heading structure.
LayoutMainputs the#titleslot inside an<h2>, andProjectDetails.vue:20places an<h1>inside that slot — producing<h2><h1>…</h1></h2>, invalid nesting, on the details page;ProjectListsupplies plain text, so the list page has no<h1>at all and its section content sits under an<h2>. The fix belongs inLayoutMain(make the page title the<h1>) and so is likely to recur in every tt feature audit — recording it here rather than filing it per feature. Verified from source; flagged for the orchestrator to promote to PROJECT-LEVEL.md if a sibling audit saw the same thing.
Baseline additions
DATA-PAGE-AGGREGATE— a total, count or average presented to the user is computed over the whole dataset, never over the currently loaded page. Where only a page is available, the figure is labelled as partial or the control is not shown. (Findings 3; the invoices<Table>truncation is the same root cause.)DATA-PARTIAL-SORT— a sort control offered on a server-paginated collection sorts the collection, not the loaded page. If the sort cannot reach the server, the control is not rendered. (Finding 10.)FORM-DISCARD-GUARD— a form holding unsaved user input warns before navigation discards it, and an asynchronous data refresh never silently overwrites in-progress edits. (Finding 11. This is the same rule already proposed elsewhere in this batch as FORM-12(d); flagged for reconciliation, not as a new ID.)- Note for the reconciliation pass: findings 2 and 6 are both instances of the MSG-06(a)/(b) reading — "a failure or a stale read is never rendered as a success or an empty state". They do not need a new ID.
Cross-project note
- Findings 4, 10 (shared
Table.vue) affect every admin table in tt-time-tracker — invoices, users, vehicles, syncs, API keys, organizations, jobs. Expect duplicate reports from the sibling tt audits; fix once inTable.vue.A11Y-03click-only rows is already confirmed in playout, customer-portal and members per PROJECT-LEVEL.md, so this is the fourth of four. - Finding 5 (labels hidden by CSS on mobile) is specific to tt's
LayoutMain, but every tt feature that puts a labelledButtonin the#buttonsslot inherits it — which is all of them. - Findings 1, 2 (no error branch / failure shown as empty) are the tt instance of the cross-project MSG-06 theme, confirmed in all four projects. The tt-specific nuance recorded in PROJECT-LEVEL.md — "admin tables have a
ListErrorState, worker screens have none" — needs amending: the component exists and the admin project page still does not use it, on either of its tables or on its own root query. - Finding 3 (aggregate over a page) is worth checking in customer-portal and playout dashboards, which show similar stat tiles over paginated sources.
- Finding 7 (constraint enforced at create, not at edit) is a cheap grep in all four repos: compare the zod/FormKit schema used by each create modal against what its detail page validates.