Skip to content

[UX] tt-time-tracker — Hours / time entry ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch: develop @ bb3238c · Files reviewed: 33 Patterns: forms/time-input, forms/date-picker (consulted via get_pattern); forms/form-validation and data-display/table not called — the findings they would cover are already rule-backed, and this screen renders cards, not a table. Working tree note: STACK.md is modified locally. It is documentation and does not affect any file cited below.

Summary ​

The screen is visually polished and its layout logic is careful, but the time model underneath it is broken in one specific and consequential way: four different places compute a duration that wraps past midnight, and the single validator that gates saving does not. A shift that crosses midnight is shown as valid, priced as valid, and then refused — and on the punch-clock path the only way out of that dead end is to delete the session. Separately, the manual time control is a custom scroll wheel built from <li> elements, so the app's primary daily action cannot be completed with a keyboard at all, even though an accessible time control (FieldTimePicker, PrimeVue) already exists in this same feature.

Findings ​

1. A time range crossing midnight is displayed as valid, then refused — and a punch-clock session started before midnight can only be discarded — Blocker · MSG-04 + proposed FORM-12 ​

Where:

  • services/client/src/components/TimeRangePicker.vue:141 (minutesBetween wraps: if (diff < 0) diff += 24 * 60)
  • services/client/src/components/TimeRangePicker.vue:133 (writeActive wraps end: % (24 * 60))
  • services/client/src/components/Modals/ModalPunchClockStop.vue:201 (if (e <= s) e = e + 24h)
  • services/client/src/composables/useEntryValidation.ts:33 (hoursAreValid — no wrap)
  • services/client/src/composables/useEntry.ts:37 (the resulting message)

What: The picker, the duration pill and the punch-clock summary all treat 22:00 → 02:00 as a four-hour range. hoursAreValid() compares toDateFromTime(end) > toDateFromTime(start), and toDateFromTime (services/client/src/utils/index.ts:1) pins a bare HH:mm to the same day — so the same range is invalid. The picker actively creates this state without the user asking: dragging Début later keeps the duration by shifting Fin through % (24 * 60), so start 21:00 with a 4 h span silently yields end 01:00. The pill then reads « 4 h » and the save button reads « La fin ne peut pas être avant le début ».

On the punch-clock path this is a trap rather than an annoyance. A session started at 22:00 and stopped at 02:00 shows « Durée effective 4h 00m » (ModalPunchClockStop.vue:197-213), validate() refuses, Enregistrer never succeeds, and the only other control in the footer is Supprimer (ModalPunchClockStop.vue:105-112, handler at :239-243) — which throws the tracked session away.

Why it matters: Anyone working a shift across midnight cannot record it. On the badgeuse path their tracked time is unrecoverable except by discarding it: real, timed work destroyed by a validation rule, with an error message that contradicts the duration shown directly above it. This is also the origin of invoice data, so the loss is billable.

Fix: Make one function own the range. Give toDateFromTime an explicit "end" mode that rolls to the next day when end <= start, and have hoursAreValid, minutesBetween, computeDuration and collections/entries.ts:toIsoFromDayTime all call it. Then the wrap the UI already performs becomes the wrap that is persisted, and the error disappears because the state is legitimate.


2. Quick-duration buttons can emit an out-of-range clock time, and the entry they create can never be edited again — High · proposed FORM-12 ​

Where:

  • services/client/src/components/Modals/ModalUserAddEntry.vue:168-179 (formatMinutes / applyQuickDuration)
  • services/client/src/components/Modals/ModalEntryDetails.vue:264-279 (same code, duplicated)
  • services/client/src/components/Modals/ModalEntryDetails.vue:216-222 (formatEntry)

What: applyQuickDuration adds minutes with no modulo. Start 20:00 + the 8h button gives parseMinutes(1200) + 480 = 1680 → "28:00", which is rendered verbatim in the Fin card (TimeRangePicker.vue:44). It also saves: toDateFromTime("28:00") calls setHours(28), which JS normalises to 04:00 the next day, so the server receives a well-formed ISO pair and stores an 8-hour overnight entry. Reopening it is where it breaks — formatEntry derives day from start only (entry.day = start.slice(0, 10)) and reduces end to "04:00", discarding the end's date. From then on the entry reads 20:00 → 04:00 on the same day, so finding 1 applies and any edit (even fixing a typo in the comment) is blocked forever.

Why it matters: A single tap on a labelled shortcut produces a clock time that does not exist, and the entry it creates becomes permanently read-only with a misleading explanation. Note the wheel disagrees with the card while this is on screen: WheelPicker has no option 28, so indexOf returns -1 and the wheel centres on 00 (WheelPicker.vue:67) while the card says 28:00.

Fix: Clamp or wrap in applyQuickDuration (% 1440) and carry the end date through formatEntry rather than reconstructing it from start. Extract the block duplicated between the two modals while doing so.


3. The primary daily action is not operable by keyboard or screen reader — High · A11Y-03 ​

Where:

  • services/client/src/components/WheelPicker.vue:32-45 (<li @click>, no tabindex, no role, no aria-selected)
  • services/client/src/components/EntryCard.vue:2-13 (<div @click> — the only way to open an entry)
  • services/client/src/components/PickerSheetTrigger.vue:25-33 (<span role="button"> nested inside a <button>)

What: Setting a time goes through WheelPicker, whose options are <li> elements with a click handler inside a scroll container. There is no tabindex, no role="listbox"/option, no aria-selected, no arrow-key handling and no text-input fallback; selection is conveyed only by font size and colour. Opening an existing entry to edit or archive it requires clicking a <div> with no tabindex, role or keydown handler. The picker's clear button is a <span role="button"> inside a <button> — invalid nesting, and unfocusable because it has no tabindex, so a selected project cannot be cleared without a pointer.

Why it matters: A keyboard or switch user cannot log hours, cannot edit an entry, and cannot clear a wrong project. Because EntryCard is not focusable, closing the edit drawer also has nowhere to return focus to — it drops to <body>, which the forms/date-picker corpus names explicitly ("Not Returning Focus After Close"). The forms/time-input corpus requires that time entry be completable by keyboard alone.

Fix: EntryCard → <button type="button"> (or role="button" + tabindex="0" + Enter/Space). Clear button → a sibling <button> outside the trigger, not a nested span. For the wheel, either add role="listbox"/option, aria-selected, roving tabindex and Up/Down/ Home/End handling, or — simpler — reuse FieldTimePicker (services/client/src/components/Forms/Fields/FieldTimePicker.vue), the PrimeVue-based control this feature already uses on the punch-clock stop modal with proper <label for> wiring.


4. A worker can only ever record time for today — High · proposed FORM-13 ​

Where:

  • services/client/src/components/Modals/ModalUserAddEntry.vue:130,144-155 (day hard-set to dayjs(); no date control in the template)
  • services/client/src/components/Modals/ModalEntryDetails.vue:101-112 (DatePicker is inside v-if="authStore.isAdmin")
  • services/client/src/views/Hours.vue:155 (useMyTodayEntries — the list is today-only)

What: « Nouvelle saisie » has project, task, quick duration, hours and a comment — but no date. day is fixed to today on open. The edit drawer exposes a date field only to admins, and the Hours list only contains today's entries, so a non-admin cannot even reach yesterday's entry to correct it.

Why it matters: Forgetting to log a day is the single most common thing that happens in a time tracker, and here it is unrecoverable by the person who owns the data — they must ask an admin. For an app whose whole purpose is capturing hours that become invoices, "yesterday" is not an edge case.

Fix: Add a date field to the create modal defaulting to today (a compact "Aujourd'hui / Hier / autre date" control fits the mobile idiom better than a calendar), and show recent days — or a date-scoped list — on Hours so a worker can open and fix a past entry. Note that the overlap check would need the same treatment (see finding 6).


5. A failed load is indistinguishable from "you logged nothing today" — High · proposed MSG-06 ​

Where:

  • services/client/src/views/Hours.vue:68-92 (only loading and empty branches)
  • services/client/src/collections/createScopedCollection.ts (123 lines; contains no catch, throw or onError — there is no error surface to render)
  • services/client/src/composables/collections/useEntries.ts:51,133 (returns list, loading — no error)

What: Hours has exactly two non-content states. If the entries request fails, nothing in the tanstack-db path reports it, so the user sees either the « Aucune entrée aujourd'hui » empty state or an indefinite spinner. There is no message and no retry. The admin screens do better: Table.vue:67 and InfiniteTable.vue render ListErrorState with a retry.

Why it matters: The screen confidently asserts the user's work is gone. The rational response is to re-enter the day's hours, which is exactly the wrong action. It also violates the reflex the rest of the app has already established.

Fix: Surface an error from createScopedCollection, expose it through useEntries, and render ListErrorState in Hours — the component already exists and takes a retry handler.


6. Overlap validation always runs against today's entries, whatever day is being edited — Medium · proposed FORM-12 ​

Where: services/client/src/composables/useEntryValidation.ts:2,9 (useTodayEntries(organization.id)), used by services/client/src/composables/useEntry.ts:19.

What: The client-side overlap check compares the edited entry against the collection scoped to today, ignoring entry.day. When an admin edits a historical entry through ModalEntryDetails (the only place a date can change), the « Chevauchement avec une autre entrée » warning is computed against the wrong day: it stays silent for a genuine clash and can fire spuriously against an unrelated entry logged today at the same clock time.

Why it matters: The warning is load-bearing — it also gates the save button (ModalEntryDetails.vue:282). A false positive blocks a legitimate correction with no way to override; a false negative sends the user to a server rejection they were told would not happen. The server check (entry.service.ts:265) is correct, so this is purely a client/server disagreement the user experiences as inconsistency.

Fix: Scope the validation collection to entry.day rather than today.


7. Save is disabled while the form is incomplete and again while saving — Medium · FORM-05, FORM-06 ​

Where: services/client/src/components/Modals/ModalUserAddEntry.vue:88, services/client/src/components/Modals/ModalEntryDetails.vue:145, services/client/src/components/Modals/ModalPunchClockStop.vue:125 (:disabled="!canSave || saving" / :disabled="!completed").

What: canSave = completed && !overlapping (and changed on the edit modal). Until a project and a task are chosen, Ajouter is greyed out with no message saying which field is missing — the pickers show only their placeholder, so nothing on screen is marked required. The same attribute also disables the button during submission.

Why it matters: A greyed button gives the user nothing to act on; the mistake has to be guessed. FORM-06 additionally means the button that was just activated is disabled under the pointer/focus, dropping focus to <body> mid-save. Note the inconsistency: hoursAreValid is not in canSave, so the end-before-start case does the right thing (submit stays live and explains itself) while the missing-field case does not.

Fix: Keep the button enabled; on click, run the same validate() and surface "Choisis un chantier" / "Choisis une tâche" next to the offending field. Replace the submit-time disabled with aria-busy plus the existing saving guard in the handler.


8. Validation and overlap messages are not announced — Medium · MSG-01 ​

Where: services/client/src/components/Modals/ModalUserAddEntry.vue:62-71, services/client/src/components/Modals/ModalEntryDetails.vue:63-72, services/client/src/components/Modals/ModalPunchClockStop.vue:30-34.

What: <small v-if="error"> and <p v-if="overlapping"> are inserted with no role="alert" and no aria-live, and neither is connected to the control it describes via aria-describedby. The app's toasts get this right (PebbleToast.vue:5 sets role to alert/status by kind), so the gap is specifically in-form.

Why it matters: A screen-reader user presses Ajouter, nothing is announced, and the button appears to have done nothing (WCAG 4.1.3). The overlap warning is worse: it appears while typing and silently disables save.

Fix: role="alert" on the error, aria-live="polite" on the overlap warning, and aria-describedby from the time control to both.


9. Form fields have no labels; the picker's label vanishes once it has a value — Medium · FORM-01 ​

Where:

  • services/client/src/components/Modals/ModalUserAddEntry.vue:76-81 (textarea, placeholder only)
  • services/client/src/components/Modals/ModalUserAddEntry.vue:13-28 (project/task pickers, placeholder only)
  • services/client/src/components/PickerSheetContent.vue:6-13 (search input, placeholder only, no aria-label)
  • services/client/src/components/TimeRangePicker.vue:63-71 (wheels have no accessible name — « Début »/« Fin » sit on the sibling buttons)

What: No <label> in the create modal. PickerSheetTrigger renders the placeholder as its own text, so once a project is chosen the button reads only the project name — the field's identity disappears entirely. The two wheels are announced as unlabelled scroll regions of numbers; nothing associates them with hours vs. minutes or with start vs. end. Compare ModalPunchClockStop.vue:10-88 (labels at :10, :21, :36, :54, :68, :80), which labels every field with <label :for> and useId() — the correct pattern is already in this feature.

Why it matters: A screen-reader user filling the form hears "Choisir un projet…", then after choosing hears only "Chantier Nord" with no indication of what that control is. Placeholder-only labels also disappear for sighted users the moment they start typing, and the textarea's « Remarque (optionnelle) » vanishes as soon as a character is entered.

Fix: Add visible <label :for> above each control (matching the punch-clock modal), keep a persistent small label inside PickerSheetTrigger above the selected value, and give the wheels aria-label="Heures" / "Minutes" plus a group label naming the active field.


10. 15-minute granularity is never disclosed, and off-grid values are shown as something else — Medium · FORM-04 ​

Where: services/client/src/components/TimeRangePicker.vue:89 (MINUTES = [0, 15, 30, 45]), :105-108 (nearestStep), :118-122.

What: The minute wheel offers only quarter hours, and nothing on screen says so before the user opens it — the forms/time-input anatomy lists a format hint as a required part for exactly this reason. Worse, activeM's getter snaps the displayed value to the nearest step without writing it back, so an entry whose minutes are off-grid — anything produced by the punch clock, which stops at the real wall time — shows e.g. 08:07 on the Début card while the wheel below centres on 00.

Why it matters: Two different times are on screen at once, and the user cannot tell which one will be saved. If they touch the minute wheel at all, the 7 minutes are silently discarded; if they touch only the hour wheel, they are kept (writeActive is passed the true minute). A time tracker that bills by the hour should not round a value invisibly.

Fix: State the granularity next to « Horaires » ("par tranches de 15 min"), and either offer a 1-minute step (as FieldTimePicker already does with :minute-step="1") or snap-and-write on open so displayed and stored values never diverge.


11. Today's total is rounded down, so a full day can read « 7h » — Medium · proposed CONTENT-05 ​

Where: services/client/src/views/Hours.vue:172-176 (formatHoursShort uses Math.floor(d.asHours())), rendered at :24.

What: The greeting card's « {n}h aujourd'hui » floors to whole hours. 7 h 45 logged displays as « 7h »; 55 minutes displays as « 0h ».

Why it matters: This is the only running total a worker sees, and it is the number they will check against their own expectation of the day. It can be almost a full hour short, and « 0h » after a real block of work reads as a failed save. EntryCard (:107) and the duration pill both show h:mm correctly, so the headline figure is the one place that disagrees.

Fix: Reuse useFormat().hours() (already imported in EntryCard) or render 7h45.


12. Dismissing the sheet by tapping outside discards a part-filled entry silently — Medium · proposed FORM-14 ​

Where: services/client/src/components/SheetOrDialog.vue:144,159 (dismissable?: boolean at :144, defaulted true at :159; passed to :dismissable on Drawer and :dismissable-mask on Dialog), used unqualified by ModalUserAddEntry.vue:2-7 and ModalEntryDetails.vue:2-6.

What: A tap on the mask closes the sheet immediately. The create modal then resets every field on next open (ModalUserAddEntry.vue:144-155), and the edit modal drops the tracked copy. Nothing is confirmed and nothing is restored.

Why it matters: This is a full-height bottom sheet on a phone, in gloves, on site — the mask is a large and easily-hit target directly above the content. The edit modal already computes changed (ModalEntryDetails.vue:250), so it knows work would be lost and says nothing.

Fix: When changed (or any field is dirty), intercept dismissal and confirm — the app has useConfirm wired app-wide (App.vue:23) for exactly this. Alternatively keep the draft and restore it on reopen.


13. Left open across midnight, the screen keeps showing yesterday — Medium · proposed NAV-06 ​

Where: services/client/src/views/Hours.vue:166-167 (computed(() => dayjs().format(...)) — no reactive dependency, so it never re-evaluates), services/client/src/composables/collections/useEntries.ts:54-60 and :136-142 (the today window is computed once at setup, not in a computed).

What: Both the date header and the entries query window are frozen at the moment the view mounted. This is an installed PWA (LayoutApp is a phone shell) that is resumed rather than reloaded.

Why it matters: A worker on a late shift resumes the app after midnight and sees yesterday's date as the heading and yesterday's entries as « Aujourd'hui ». A new entry is stamped with the correct day (the modal re-reads dayjs() on open, ModalUserAddEntry.vue:148) but then does not appear in the list, which reads as a failed save.

Fix: Drive "today" from a ref refreshed on visibilitychange/focus and at the next midnight, and key the entries window off it.


14. Every route in the app shares the title « Tim » — Medium · NAV-03 ​

Where: services/client/index.html:17 is the only place a title is set; services/client/src/router/index.ts has no afterEach title hook and no route declares meta.title (routes.ts — grep for document.title/useTitle returns nothing across the client).

What: All 25 routes render as "Tim" in tab titles, history and the PWA task switcher.

Why it matters: Browser history and back-navigation are unusable, and a screen reader announces the same title on every route change. Recording it here because Hours is the default route; the fix is project-wide.

Fix: meta.title per route plus an afterEach that sets document.title = "…· Tim".


15. No <main> landmark — Low · A11Y-04 ​

Where: services/client/src/components/Layout/LayoutApp.vue:12 — the RouterView renders straight into a <div>; Hours.vue:2 is also a <div>. The floating <nav> at :14 is the layout's only landmark.

What: No <main>, so there is no skip target and no way to jump past the navigation.

Why it matters: Screen-reader users cannot navigate by landmark to the content — on this screen, the entry list.

Fix: Wrap RouterView in <main class="flex-1 min-h-0">.


16. Discarding a punch-clock session uses a native confirm() and does not say what is lost — Low · MSG-05 ​

Where: services/client/src/components/Modals/ModalPunchClockStop.vue:241 (if (confirm("Supprimer cette session de pointage ?"))).

What: Every other destructive action in the app goes through the shared PrimeVue dialog (composables/useArchiveConfirm.ts, used at ModalEntryDetails.vue:335). This one uses window.confirm: unstyled, untranslatable in tone, suppressible by the browser, and visually alien inside a bottom sheet. Its copy names the action but not the consequence — that the elapsed time already tracked is destroyed and not recoverable.

Why it matters: This is the most destructive control in the feature, and per finding 1 it is the only exit from the overnight dead end. It deserves the strongest confirmation in the app, not the weakest.

Fix: Route it through useConfirm with copy naming the loss — « La session en cours (4h 12m) sera supprimée. Cette action est irréversible. »


17. Wheel-picker timers are not cleared on unmount — Low · NAV-02 ​

Where: services/client/src/components/WheelPicker.vue:116-152 — settleTimer and the two suppressScrollEmit setTimeouts have no onBeforeUnmount(clearTimeout); only PebbleToast.vue:123 gets this right in this feature.

What: The wheel is inside a <Transition v-if="activeField"> (TimeRangePicker.vue:58), so it mounts and unmounts every time the user toggles between Début and Fin. A scroll settling within ~90 ms of that toggle leaves a timer that fires against a detached element and can write to the parent's model after unmount.

Why it matters: Small, but it writes to entry state after the control is gone — a plausible source of the "time changed by itself" class of bug.

Fix: onBeforeUnmount(() => settleTimer && clearTimeout(settleTimer)).

Unverified ​

  • A11Y-01 (contrast). Not measurable from code. Several combinations look worth checking on a rendered page: text-(--text-secondary)/50 on bg-surface-50 for unselected wheel options (WheelPicker.vue:39), text-white/80 on bg-accent (Hours.vue:43, PunchClockWidget.vue:14), text-surface-700/80 on bg-primary-100 (Hours.vue:23), and the 9–10 px uppercase tracking labels used throughout (TimeRangePicker.vue:15, ModalUserAddEntry.vue:32).
  • A11Y-02 (target size). The « Tout voir » link (Hours.vue:59-65) is text-xs with no padding class, which computes to roughly a 16 px box — likely under the 24 px floor, and it is a standalone control rather than an inline link, so the WCAG 2.5.8 inline exception would not apply. Needs a rendered measurement before being asserted.
  • A11Y-06 (short viewport / mobile keyboard). The sheet is h-[80dvh] (SheetOrDialog.vue:173) and the wheel is a fixed h-[10rem] (WheelPicker.vue:11). Whether the footer save button and the wheel are both reachable with an on-screen keyboard open needs a device.
  • Finding 5's exact failure mode. I verified that no error is surfaced by createScopedCollection and that Hours has no error branch. Whether a failed fetch leaves isLoading true (spinner forever) or false (false empty state) depends on @tanstack/vue-db behaviour I did not exercise. Either outcome is the defect described.
  • Keyboard scroll on the wheel. Recent Chrome makes overflow containers focusable, which might allow arrow-key scrolling to move the value by accident. That does not change finding 3 — there are no roles, no accessible names and no such behaviour in Safari/iOS, this app's primary target.

Baseline additions ​

Six proposed rules. All six are cited in the findings above, and each would apply to at least one other project in the programme.

IDProposed wording
FORM-12A control never offers, computes or displays a value its own validator will reject. Display, derived summaries (durations, totals, previews) and validation share one implementation; validation runs against the same scope the user is editing, not a default scope.
FORM-13Where a record is bound to a date or period, the user who owns that record can choose it and can correct a past one. A create form that hard-codes "now" leaves the owner dependent on an administrator.
FORM-14Dismissing a modal, sheet or drawer that holds unsaved edits confirms first. Mask-tap and swipe-down dismissal are included; "the form resets on reopen" is not a mitigation.
MSG-06Empty and error are distinct states. A list that fails to load says so and offers a retry — it never renders the empty state, which asserts something false about the user's data.
NAV-06A long-lived view (PWA, kiosk, dashboard) recomputes date-dependent state — "today" headers, date-scoped queries — on resume and across the day boundary, rather than freezing it at mount.
CONTENT-05A headline figure derived from precise data preserves its precision, or states the rounding. Silently flooring a total misrepresents it by up to a full unit.

(CONTENT-05 is arguably a CONTENT rule and NAV-06 arguably belongs in a new STATE section — the orchestrator should place them.)

CONTENT-01: not-applicable — see project-level i18n finding.

Cross-project note ​

  • FORM-12 / finding 1 — the wrap-vs-validate split is specific to this repo's time model, but the general shape (two implementations of the same rule, one used for display and one for gating) is worth grepping for in playout and customer-portal wherever a computed preview sits next to a submit guard.
  • A11Y-03 / finding 3 (click handlers on non-interactive elements) — the most likely to generalise. <div @click> rows and <li @click> options are the default failure of card-based mobile UIs; members and customer-portal (mobile app) should both be checked for clickable list rows that are not buttons.
  • MSG-06 / finding 5 — tt-time-tracker itself is internally inconsistent (admin tables have ListErrorState, the worker screen does not), which suggests the same split exists elsewhere. Check every list in all four projects for an error branch.
  • FORM-05/FORM-06 / finding 7 — disabled submit buttons were the founding finding of this baseline in playout's password reset; seeing them again here in a completely different stack (PrimeVue, hand-rolled buttons) makes it near-certain in customer-portal and members.
  • NAV-03 / finding 14 — a single static <title> is a whole-SPA property. Worth one grep per project rather than one finding per feature.
  • FORM-14 / finding 12 — every project in the programme uses modals for editing. None of the four is likely to guard dismissal.