Appearance
[UX] tt-time-tracker — Task lists
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 29 Patterns:content-management/drag-and-drop
Summary
Editing an existing task list cannot be saved at all: ModalTaskList echoes the API's own response object back as the PATCH body, and the API runs a global ValidationPipe with forbidNonWhitelisted: true, so every save is rejected with a 400 and the user is shown the raw English validator prose. Beyond that, the feature is entirely mouse-only — the card that opens a list is a bare <div @click> and the drag handle is a 10×10 px aria-hidden dot with no keyboard fallback — and removing a task hard-deletes it server-side, silently detaching every time entry booked against it. Tasks are the unit hours are booked to, so both the broken save and the silent task deletion propagate into the core time-entry and invoicing path.
Findings
1. Saving an edited task list always fails with a 400 — Blocker · DATA-RESPONSE-ECHO (proposed) · MSG-02
Where: services/client/src/components/Modals/ModalTaskList.vue:184-189 (with services/api/src/main.ts:42-46, services/api/src/modules/task-list/dto/update-task-list.dto.ts)
What: updateTaskList sends the whole entity as the request body:
ts
const updateTaskList = async (tl: TaskList) => {
if (!organization.id || !tl.id) return;
tl.tasks = cleanedTasks();
await updateTaskListMutation({ path: { organizationId: organization.id, id: tl.id }, body: tl });tl is a spread copy of props.taskList, which is a raw Prisma row. The API returns this.prisma.taskList.findMany({ ..., include: { tasks } }) unmodified (task-list.service.ts:9-14) — there is no ClassSerializerInterceptor anywhere in services/api/src, and TaskListResponseDto is Swagger metadata only, so it does not filter the response. The client then casts without parsing (collections/taskLists.ts:12: transform: data => (data ?? []) as unknown as TaskList[]). So tl carries id, organizationId, createdAt, updatedAt at runtime, and each element of tl.tasks carries taskListId.
The API's global pipe is new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }). UpdateTaskListDto declares only name, archived, tasks; UpdateTaskItemDto declares only id, name, order. Every one of the extra properties therefore triggers property X should not exist and the request is rejected before reaching the service. The generated SDK passes body straight through (api/sdk.gen.ts:403-413) and taskListControllerUpdateMutation sets throwOnError: true (api/@tanstack/vue-query.gen.ts:587-599), so handleSave lands in its catch.
TypeScript does not catch this because tl is a variable, not an object literal — excess-property checking does not apply — and the declared TaskList type does not mention the extra fields, which exist only at runtime because the response is cast rather than parsed. The only test in the module is task-list.service.spec.ts, which calls the service directly and so never exercises the pipe.
Why it matters: Renaming a list, renaming a task, adding a task, removing a task and reordering tasks are the entire purpose of this screen, and none of them can be saved. Only archive/restore work, because those go through createScopedCollection's buildPayload, which emits { name?, archived? } only (collections/taskLists.ts:22-28). Creating a list also works, for the same reason. Secondarily, extractErrorMessage (utils/index.ts:52+) joins NestJS's message array verbatim, so the French toast reads e.g. « property organizationId should not exist, property createdAt should not exist » — untranslated validator internals shown to an admin (MSG-02).
Fix: Build an explicit payload instead of echoing the entity: { name: tl.name, tasks: cleanedTasks().map(({ id, name, order }) => ({ id, name, order })) }. Better, move the task-list update through the collection layer like every other write and extend its update.buildPayload to carry tasks, which also restores offline queuing (see finding 8). Add a controller-level (pipe-exercising) test for PATCH so this class of failure is caught in CI.
2. Task reordering is mouse-only — no keyboard path exists — High · A11Y-03
Where: services/client/src/components/Modals/ModalTaskList.vue:30-43
What: Reordering is a vuedraggable list whose handle=".task-drag-handle" resolves to:
html
<span aria-hidden="true" class="task-drag-handle size-2.5 rounded-full bg-accent shrink-0 cursor-grab active:cursor-grabbing" />A <span> with no tabindex, no role, no aria-* state and aria-hidden="true". It cannot receive focus and is not exposed to assistive technology at all. There are no move-up / move-down buttons, no cut-and-place mode, and no aria-live region announcing a new position after reorderTasks runs (:191-193). The content-management/drag-and-drop pattern names "Fallback controls" as a required part of the anatomy and states plainly: "Verify that drag and drop can be completed using keyboard alone."
Why it matters: Task order is the order tasks appear to every worker booking hours (previewTasks in TaskLists.vue:213-216 and the task pickers both sort by order). A keyboard or screen-reader admin cannot set it at all, and gets no feedback that a reorder happened.
Fix: Add a per-row "Monter" / "Descendre" button pair (or a roving-tabindex role="listbox" with Ctrl+Arrow move), give the handle tabindex="0" and an accessible name, drop aria-hidden, and announce the result in a polite live region (« Tâche X déplacée en position 3 sur 5 »). Note vuedraggable/SortableJS internals could not be read — node_modules is not installed — but the fallback controls are absent from this repo's own source regardless.
3. Opening a task list is a click-only <div> — High · A11Y-03
Where: services/client/src/views/Admin/TaskLists.vue:100-105
What: The card grid rows are <div … class="group … cursor-pointer" @click="onSelect(tl)"> with no tabindex, no role="button", no @keydown.enter/space, and no focus style (only hover: variants are declared). The card is the only route into ModalTaskList — there is no per-row menu, no detail route, and task-lists has no :id child route.
Why it matters: A keyboard-only or screen-reader admin can reach the search field, the Add button and the overflow menu, but can never open an existing task list — so viewing, renaming, archiving and editing tasks are all unreachable. The whole feature is inoperable without a pointer.
Fix: Make the card a <button type="button"> (or add role="button", tabindex="0" and Enter/Space handlers) and add a visible focus-visible: ring. This is the tt-time-tracker instance of the cross-project A11Y-03 click-only-row theme in PROJECT-LEVEL.md, but the concrete card here is feature-local and worth fixing with the rest of this list.
4. Removing a task silently detaches every hour booked against it — High · MSG-05
Where: services/client/src/components/Modals/ModalTaskList.vue:178-181, 195-198 (server side: services/api/src/modules/task-list/task-list.service.ts:50-56)
What: Two paths delete a task with no confirmation and no warning:
removeTask(index)splices the row out immediately on a single click of the ✕.cleanedTasks()drops any row whosenameis blank — so clearing a task's text box deletes that task on save, without the user ever pressing delete.
On the server, update computes toDelete and calls tx.task.deleteMany({ where: { id: { in: toDelete } } }) — a hard delete, not an archive (contrast the task list itself, which is soft-archived at :74-76 and is confirmed via useArchiveConfirm). Entry.task is declared onDelete: SetNull (packages/database/prisma/models/entry.prisma), so every existing time entry booked against that task keeps its hours but loses its task reference, permanently and unrecoverably.
Why it matters: Hours are what invoices are built from. An admin tidying a task list has no way to know that removing « Coffrage » will blank the task on months of historical entries, and no confirmation step says so. Rated High rather than Blocker because the feature still does what it appears to do — the harm is unannounced data loss, not a broken control. (It is currently masked by finding 1: the save fails, so nothing is deleted. Fixing finding 1 makes this live.)
Fix: Confirm before removing a task, and state the consequence with a count — « Cette tâche est utilisée par N pointages. Les supprimer la retirera de ces pointages. » Require the API to report usage counts, and consider soft-deleting tasks (an archived flag, as TaskList already has) so historical entries keep their label. At minimum, do not treat "the user blanked the text" as "delete this task".
5. addTask produces duplicate "NaN" row keys when editing an existing list — Medium · DATA-KEY-COLLISION (proposed)
Where: services/client/src/components/Modals/ModalTaskList.vue:200-204 with services/client/src/utils/index.ts:18-21
What:
ts
export const nextId = (startId: number, ...args: Array<Array<Entity>>) => {
const getMaxId = (l: Array<Entity>) => (l || []).reduce((a, c) => Math.max(a, parseInt(c.id || "0")), startId);
return Math.max(...args.map(getMaxId)) + 1;
};addTask calls nextId(0, tasksCopy.value). In create mode the seeded rows have ids "1" "2" "3" (seedBlankTasks, :166-167) so this works. In edit mode the existing tasks carry Prisma cuids (Task.id String @id @default(cuid())), so parseInt("cm3x…") is NaN, the reduce poisons to NaN, and the new task gets id: String(NaN) → "NaN" and order: NaN. reorderTasks() repairs order but never id. Add two tasks and both rows have id === "NaN", which is exactly the key draggable is told to use: item-key="id" (:36).
Why it matters: Two rows sharing a Vue key means Vue's keyed diff may reuse the wrong vnode. Concretely: after adding two tasks, dragging or deleting one of them can update the other — the text typed into the second new task ends up on the first, or the ✕ removes the visually wrong row — plus a "Duplicate keys found during update" console warning. This is the same class of defect as the playout skipped[] bug the brief flags: an identity that is derived rather than owned, and that stops tracking its source. (The server tolerates it — update treats any id not in currentIds as a create, task-list.service.ts:58-63 — so the damage is confined to the client editor, hence Medium rather than High.)
Fix: Generate a genuinely unique client-side key (crypto.randomUUID(), or an _key field kept separate from the server id) and strip client-only ids in cleanedTasks() before sending. Do not reuse nextId on cuid-keyed collections.
6. Form labels are not associated; task rows have only a placeholder — Medium · FORM-01
Where: services/client/src/components/Modals/ModalTaskList.vue:13-25, 44-49
What: <label class="…">Nom de la liste</label> has no for, and the <InputText> beneath it has no id — nothing links them. Each task row's input has no label at all, only :placeholder="\Tâche ${index + 1}`"`.
Why it matters: A screen-reader user tabbing into the name field hears "edit text" with no name. In the task rows the placeholder is the only label, so it disappears the moment the user types — a user who tabs back cannot tell which row they are in, and this is the field that names the thing hours get booked to.
Fix: Give each input an id and point the <label for> at it (or wrap the input in the label). For task rows use a visually-hidden label or aria-label (« Nom de la tâche 1 ») rather than relying on the placeholder.
7. Save button is disabled while saving, and disabled with no explanation when unchanged — Medium · FORM-06, FORM-05
Where: services/client/src/components/Modals/ModalTaskList.vue:101-106
What: :disabled="(isEditing && !changesHaveBeenMade) || saving" on a hand-rolled <button> — native disabled, so this is asserted from source, not a PrimeVue internal. Activating it sets saving = true, which disables the button the user is standing on. Separately, in edit mode the button starts disabled and offers no message saying why.
Why it matters: FORM-06 — disabling a just-activated button drops focus to <body>, so a keyboard user loses their place mid-save and, given finding 1, lands nowhere when the error toast arrives. FORM-05 — a greyed-out « Enregistrer » with no accompanying text gives the user nothing to act on; the reason ("no changes yet") is invisible.
Fix: Keep the button enabled, set aria-busy="true" while saving and guard re-entry inside handleSave (the guard at :212 already exists). Replace the unchanged-disable with an always-enabled button that no-ops, or a visible « Aucune modification » hint.
8. Task-list edits bypass the offline queue that every other write uses — Medium · MSG-04
Where: services/client/src/components/Modals/ModalTaskList.vue:183-189 vs services/client/src/collections/createScopedCollection.ts:66-100
What: Every other mutation in the app runs through createScopedCollection, which wires withOfflineSupport onInsert/onUpdate/onDelete hooks that persist the payload to an offline queue. ModalTaskList is the only .vue file in the client that imports a generated *ControllerUpdateMutation directly (grep over services/client/src returns exactly one hit), so it calls the network unconditionally.
Why it matters: This is a PWA used on job sites. An admin who edits a task list with no signal gets a generic error toast and their typed changes are discarded when the modal closes — the same edit made through any other screen would have been queued and replayed. Inconsistent recovery behaviour between screens that look identical.
Fix: Route the update through the collection (extend update.buildPayload in collections/taskLists.ts to include tasks). This fixes findings 1 and 8 together.
9. Unsaved edits are discarded with no warning — Medium · FORM-DISCARD-WARN (proposed; PROJECT-LEVEL.md lists this as proposed FORM-12(d))
Where: services/client/src/components/Modals/ModalTaskList.vue:94-100 with services/client/src/components/SheetOrDialog.vue:81-82, 159
What: « Annuler » sets visible = false immediately. SheetOrDialog defaults dismissable: true, which sets both :closable and :dismissable-mask on the desktop Dialog and :dismissable on the mobile Drawer — so the X, the Escape key and a tap on the backdrop all close it too. None of the four consults changesHaveBeenMade, which the component already computes (:175).
Why it matters: On mobile this is a bottom sheet; a stray tap outside destroys a list of freshly typed task names with no prompt and no undo.
Fix: Intercept close when changesHaveBeenMade is true and confirm (« Abandonner les modifications ? »). The signal is already there — it is just not consulted.
10. The create modal keeps the previously typed list name — Medium · FORM-09
Where: services/client/src/components/Modals/ModalTaskList.vue:151-154, 169-172 with services/client/src/composables/useChangeTracker.ts:22-28
What: <ModalTaskList v-model:visible="showModal" /> (TaskLists.vue:156) has no v-if, so the create modal stays mounted for the life of the page. Its state is reset by two mechanisms, and only one of them covers the name:
watch(visible, v => { if (v && !props.taskList) tasksCopy.value = seedBlankTasks(3); })resets the tasks.useChangeTracker's watcher is on() => props.taskList ?? blank(). Its only reactive dependency isprops.taskList, which is permanentlyundefinedfor the create modal, so the watcher never re-fires andcopy.value— i.e.taskList.name— is never cleared.
Why it matters: Create « Phase 1 · Gros œuvre », then click + again: the name field is prefilled with « Phase 1 · Gros œuvre ». The save button is enabled (the !changesHaveBeenMade guard applies only in edit mode), so a hurried admin creates a duplicate list. Task-list names are what projects are matched against, so duplicates are not cosmetic.
Fix: Reset copy explicitly in the same watch(visible, …) that reseeds the tasks, or give the create modal a :key that changes on open so it remounts.
11. The page has no <h1>; headings start at <h2> — Medium · A11Y-04
Where: services/client/src/views/Admin/TaskLists.vue:2-9, 107 with services/client/src/components/Layout/LayoutMain.vue:6, 74
What: LayoutMain renders the page title into an <h2> in both the mobile (:6) and desktop (:74) headers. Nothing on the route renders an <h1> — a grep for <h1 across services/client/src returns 14 hits, none of them in views/Admin/ list views or in any layout shell. Card titles are <h3> (TaskLists.vue:107), so the document outline for this page is h2 → h3 with no h1.
Why it matters: Screen-reader users navigating by heading get no top-level landmark for the page, and "jump to main heading" finds nothing.
Fix: Promote LayoutMain's title slot to <h1>. Since LayoutMain wraps every admin list view, this is one fix for the whole set — worth filing once at the LayoutMain level rather than per feature, alongside the existing project-level NAV-03 gap.
12. The drag handle is a 10×10 px dot that reads as decoration — Low · A11Y-02
Where: services/client/src/components/Modals/ModalTaskList.vue:40-43
What: class="task-drag-handle size-2.5 …" — Tailwind size-2.5 is 0.625rem, i.e. 10 × 10 CSS px, against the WCAG 2.5.8 floor of 24 × 24. It is also styled as a plain filled circle, visually identical to the decorative bullets used in the card preview (TaskLists.vue:132), with no grip glyph — nothing marks it as draggable except a cursor-grab that does not exist on touch.
Why it matters: On a phone (the primary form factor for this PWA) a 10 px target is effectively unhittable, and users have no cue that reordering is possible at all. Rated Low rather than High only because finding 2 already covers the total absence of a non-pointer path; if the fallback controls from finding 2 are added, this becomes cosmetic.
Fix: Use a real grip icon in a ≥ 24 px hit area (size-6 with a smaller glyph inside), and once it is focusable per finding 2 give it a title/accessible name.
13. Inline validation error is not announced — Low · MSG-01
Where: services/client/src/components/Modals/ModalTaskList.vue:21-24
What: <small v-if="errors.name" class="text-(--danger)">{{ errors.name }}</small> — appears via v-if with no role="alert" and no aria-describedby linking it to the input. :invalid="!!errors.name" on the PrimeVue InputText most likely sets aria-invalid, but that is a library internal that could not be verified (see Unverified).
Why it matters: A screen-reader user who submits with an empty name gets no announcement — the modal simply does not close, with no stated reason.
Fix: role="alert" on the <small>, plus an id on it referenced by the input's aria-describedby.
Rules checked and passing / not applicable
CONTENT-01— not-applicable, see project-level i18n finding.NAV-03— fails project-wide, see PROJECT-LEVEL.md (index.html:17« Tim » everywhere). Not re-filed.MSG-06(failed load rendered as empty state) — passes here.TaskLists.vue:46-51renders a realListErrorStatewith a « Réessayer » button gated ontaskLists.error, ordered before the empty state. This is the admin-table half of the split PROJECT-LEVEL.md describes, and it is the correct half.MSG-05for the task list — passes. Archiving is confirmed viauseArchiveConfirmand is a soft archive with a « Réactiver » path (ModalTaskList.vue:75-91,task-list.service.ts:74-76). Only task removal (finding 4) is unguarded.MSG-01for toasts — passes.PebbleToastHost.vuewraps the stack inrole="region" aria-live="polite". (Error toasts arguably wantassertive, but the host is app-wide, not feature-local, so not filed here.)A11Y-05— passes. The overflow trigger hasaria-label="Plus d'actions", the task delete button hasaria-label="Supprimer la tâche".NAV-02— passes; no timers, intervals or manual subscriptions in this feature.FORM-04— the only constraint is "name required", which is inherently self-evident; not filed.- Currency check requested by PROJECT-LEVEL.md: no money surface in this feature.
grepforIntl.NumberFormat/maximumFractionDigitsreturns nothing under the task-list files. Nothing to report for the four-project currency table from here. SEC-*— no credential, token or auth surface in this feature; not applicable.
Unverified
- A11Y-01 (contrast) —
text-surface-400onbg-surface-0for the task-count line (TaskLists.vue:118), the "+N autres" row (:137) and the empty-state description (ListEmptyState.vue:15) all look like contrast risks at small sizes, but this needs computed colour values from a rendered page. Not asserted. - A11Y-06 (short viewport / mobile keyboard) — the task editor is
max-h-80 overflow-y-autoinside anh-[80dvh]bottom sheet (ModalTaskList.vue:33,SheetOrDialog.vue:166-170); whether the footer save button stays reachable with a mobile keyboard open needs a rendered viewport. - PrimeVue internals —
node_modulesis not installed in this checkout, soInputText's handling of:invalid(→aria-invalid),Menu's popup keyboard model,Button's:loading→disabledbehaviour, andDrawer/Dialogfocus trapping and Escape handling could not be read from source. Findings 6, 7 and 13 are written against this repo's own markup only. - vuedraggable / SortableJS — likewise unreadable. Finding 2 rests on this repo's own source (an
aria-hiddennon-focusable handle and no fallback controls), not on an assertion about the library.
Baseline additions
Proposed with definitions; the orchestrator should renumber (PROJECT-LEVEL.md notes heavy ID collisions across this batch).
DATA-RESPONSE-ECHO— A write request sends only the fields its endpoint declares. A response object is never echoed back verbatim as a request body, and a response is parsed into its declared type rather than cast, so server-only fields cannot leak into a later write. Finding 1 is the strong case: the client's type system says the call is valid while every request 400s at runtime. Related to, but distinct from, theMSG-06family — this is the request side, not the response side.DATA-KEY-COLLISION— A client-generated identifier used as a render key must be unique by construction, never derived by parsing a server id whose format it does not control. Finding 5. Same family as the playoutskipped[]parallel-array bug: an identity that silently stops tracking its source.FORM-DISCARD-WARN— A surface holding unsaved user input warns before every path that closes it, including backdrop click, Escape and the platform back gesture. Finding 9. Already proposed in this batch asFORM-12(d); recording the same rule under a descriptive name rather than adding a fifth meaning toFORM-12.
Cross-project note
- Finding 1 (response echoed as request body) — likely in customer-portal, which is the other NestJS-shaped stack with generated SDK types; grep each repo for
body: <entity>passed straight to a generated*ControllerUpdateand forforbidNonWhitelisted. The tell is atransformthat casts the list response (as unknown as T[]) instead of parsing it through the shared Zod schema — tt-time-tracker has@tt/schemasTaskListSchemaavailable and does not use it at the boundary. Also worth checking members, whose widgets share raw-response handling. - Finding 3 (click-only rows) — confirmed in all four per PROJECT-LEVEL.md's alignment table.
- Finding 9 (silent discard of unsaved input) — proposed independently in another audit this batch, so at least two projects.
- Finding 11 (no
<h1>) — playout has the mirror-image defect (VTitlehardcodes<h1>, producing two per page); customer-portal was corrected to passing on its main shells. tt-time-tracker'sLayoutMainis the "none at all" case. Worth one alignment item covering all three. - Finding 8 (one screen bypassing the shared data layer) — a repo-specific consistency defect; no cross-project signal.