Appearance
[UX] tt-time-tracker — User dashboard
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 11 Patterns:data-display/chart
Summary
Dashboard.vue (the « Bilan » tab) is the better-built half of the worker-facing app: unlike its siblings it does have an error surface for its summary fetch, with a real « Réessayer » button. The problem is that the honest surface covers only one of the two fetches on the page — the entry list swallows every failure into « Aucune entrée » — and that the headline arithmetic quietly drops refused hours, to the point where a month in which every entry was refused renders as « Aucune heure ce mois-ci ». A worker whose hours were rejected is told they logged none, and is given no way to see or fix them.
On the heading-honesty question raised in the brief: the « Entrées » list is correctly scoped — fetchEntriesPage (Dashboard.vue:355-369) passes the same startAfter/startBefore/memberProfileId as the summary, so the list matches the period in the <h1>. This screen does not repeat the UserInvoices « Factures récentes » mislabelling. « Total » is the label that lies here, and by omission rather than by window (finding 6).
Project-level items that also apply and are not re-filed here: CONTENT-01 — not-applicable, see project-level i18n finding. NAV-03 — fails project-wide (no route sets a title), see PROJECT-LEVEL.md.
Findings
1. A month whose entries were all refused renders as "no hours this month" — High · proposed MSG-EMPTY-TRUTH
Where: services/client/src/views/Dashboard.vue:73-82 and :325-329What: totalMinutes deliberately excludes refused hours when the approval workflow is on (approvedMinutes + pendingMinutes). The empty-state branch is gated on v-else-if="totalMinutes === 0". So a member who logged 35 h and had every entry refused hits totalMinutes === 0 and gets the clock icon plus « Aucune heure ce mois-ci ». That branch is v-else-if on the same chain as the content, so it also suppresses the hero card, the « Refusées » line that would have explained the zero, the per-project breakdown and the whole entry list. Why it matters: The refused-hours case is precisely the one the worker most needs to see — it is the only place in this screen where they would learn that their week was rejected and needs re-entering. Instead the app asserts they logged nothing. The same is true of a partially-refused month whose remaining approved+pending hours happen to be zero. There is no other route to this information from the Bilan tab. Fix: Gate the empty state on the raw aggregate being empty (approvedMinutes + pendingMinutes + rejectedMinutes === 0), not on the headline figure, and keep the entry list rendered whenever entries exist. When the headline is 0 but refused hours are not, show the refused total and a line of copy saying so (e.g. « 35:00 refusées — aucune heure validée ce mois-ci »).
2. The entry list turns every fetch failure into "Aucune entrée" or a silent end-of-list — High · MSG-06 (proposed)
Where: services/client/src/views/Dashboard.vue:376 (catch { entries.value = []; }) and :388 (catch { state.complete(); }) What: Two bare catches with empty bodies. resetEntries swallows the error and assigns [], which renders the v-else-if="!entries.length" branch — « Aucune entrée » (:190-195). handleInfinite swallows its error and calls state.complete(), which tells v3-infinite-loading there is nothing more to load and permanently stops pagination for that identifier. Neither path sets summaryError, so the page's existing error surface (:54-70) never fires: the summary can succeed and show « 142:30 » while the list below it says the member has no entries. Why it matters: This is the PROJECT-LEVEL MSG-06 split showing up inside a single screen — the summary half has a Message + « Réessayer », the list half has nothing. On a flaky mobile connection (this is a PWA with a bottom nav, so mobile is the primary case) the worker sees a total with no entries under it and concludes their entries were lost, or scrolls to what looks like the end of a month and concludes they logged 12 entries when they logged 60. Hours here are the record that becomes an invoice, so "I believe I have seen all my entries" being wrong is real harm, not friction — hence High rather than Medium. Fix: Give the list its own error ref and render a retry row in place of « Aucune entrée » when it is set. In handleInfinite, distinguish "no more pages" from "request failed": on failure call state.error() (or leave the loader in a retryable state) rather than complete(). Also make « Réessayer » (:67) re-run resetEntries() as well as fetchSummary() — today it only retries the summary, so a page where both failed comes back with a total and an empty list.
3. Rapid period stepping can leave another month's totals under the current heading — Medium · proposed DATA-RACE
Where: services/client/src/views/Dashboard.vue:294-317, :371-377, :391-393What: The watcher fires fetchSummary() and resetEntries() on every change to range.start/range.end. Neither has a request-sequence guard: whichever response resolves last writes statusAgg/projectAgg/entries. Tapping « Mois précédent » three times issues three overlapping pairs of requests, and if an earlier one resolves last its data lands. summaryLoading is cleared by the first response to finish, so the spinner is already gone. Why it matters: The <h1> period label is derived from local state and updates instantly, so the wrong figures appear under a confidently correct heading, and they stay wrong until the user changes period again — nothing re-fetches on its own. In a timesheet this is a number a worker may act on. Placed at Medium rather than High because it needs fast repeated taps plus variable latency to trigger, and any further navigation corrects it. Fix: Capture a monotonically increasing request id (or the serialised range) before each fetch and discard the response if it is no longer current; same in resetEntries. AbortController on the previous pair would also do it.
4. The Mois / Année toggle conveys its selected state by colour alone — Medium · A11Y-07(d) (proposed in PROJECT-LEVEL)
Where: services/client/src/views/Dashboard.vue:33-42What: Two plain <button>s in a pill. The active one differs only by bg-surface-0 text-surface-900 versus text-surface-500; there is no aria-pressed, no role="tab"/aria-selected, no radiogroup semantics, and no non-colour affordance beyond the subtle shadow-sm. Why it matters: A screen-reader user hears « Mois, bouton » / « Année, bouton » with no indication of which is in effect, and cannot tell whether pressing one is a no-op. The data-display/chart pattern's accessibility section is explicit that selection state must not be colour-only. The <h1> (« Juillet 2026 » vs « 2026 ») does disambiguate for a sighted user, which is why this is Medium and not High. Fix: Add :aria-pressed="mode === opt.value" to each button, or model the pill as a role="radiogroup" with aria-checked. PrimeVue's SelectButton — already used elsewhere in this client and already themed in tailwind.css — handles this natively and would remove the hand-rolled pill.
5. Period changes, loading and retry are never announced — Medium · MSG-01, CONTENT-04
Where: services/client/src/views/Dashboard.vue:46-70; services/client/src/components/Spinner.vue:1-16What: Pressing « Mois précédent » replaces the entire content region with no live region anywhere on the page, so nothing is announced — not the new period, not that a load started, not that it finished. Spinner.vue is three unlabelled <div>s: no role="status", no aria-label, no visible text, so the loading state is silent and wordless (CONTENT-04 — the wait is not named). Whether the PrimeVue Message at :58 carries role="alert" could not be verified (see Unverified). Why it matters: For a screen-reader user the primary interaction on this screen — stepping between periods — produces no feedback at all; the page simply becomes different if they go looking. WCAG 4.1.3. Fix: Wrap the content region in aria-live="polite" aria-busy="summaryLoading", give Spinner a role="status" and an accessible name defaulting to « Chargement… », and confirm the error Message announces (add role="alert" on a wrapper if not).
6. "Total" silently excludes refused hours while listing them as a component — Medium · proposed CONTENT-SCOPE
Where: services/client/src/views/Dashboard.vue:87-92 and :107-135, with :325-333What: The hero shows « Total » over hours(totalMinutes), which under the approval workflow is approved + pending only. Immediately below, the card lists three legend rows — Approuvées, En attente, Refusées — in identical styling, each with a coloured dot matching the stacked bar above. But the bar has only two segments (:96-104): the amber segment is drawn as 100 - approvedPercent, so refused hours have a legend dot and no bar segment, and the three legend values add up to more than the headline. Why it matters: The card invites exactly one reading — that the three rows decompose the Total — and that reading is wrong whenever anything was refused. A worker reconciling « Total » against their own count will not find the discrepancy explained anywhere on the screen. This is the same class of defect as the sibling audit's « Factures récentes » (a label that does not state its scope), though by omission rather than by time window. Fix: Label the headline for what it is (« Total validé + en attente », or « Total retenu ») and visually separate the refused row from the two that compose it — e.g. below a divider, prefixed « Non comptabilisé : ». Either that or include refused in the bar as a third segment and in the total, and say so.
7. "Par projet" drops refused hours from a total that includes them, when the approval workflow is off — Low · proposed CONTENT-SCOPE
Where: services/client/src/views/Dashboard.vue:325-329 and :337-346; services/api/src/modules/entry/entry.service.ts:209What: When organization.hasApprovalWorkflow is false the client adds refused minutes into totalMinutes. The server's aggregateByProject filters rejected: false unconditionally. So in that configuration the per-project rows sum to less than « Total » and every percent is under-stated, with no note explaining the gap. Why it matters: Visible numeric mismatch between two blocks on the same card. Rated Low because it only manifests for an organisation that has refused entries and has the approval workflow switched off — i.e. one that disabled the workflow after using it — rather than in the default configuration. Fix: Make the two sides agree: either exclude refused from totalMinutes unconditionally (matching the API), or have aggregateByProject accept the same inclusion rule the client is applying.
8. Period bounds are inclusive on the server but exclusive on the client, so a midnight-boundary entry is counted twice — Low · proposed DATA-RANGE
Where: services/client/src/views/Dashboard.vue:273-284 (end = first instant of the next period) versus services/api/src/modules/entry/entry.service.ts:173 and :214 (start: { gte: startAfter, lte: startBefore }) What: The client builds a half-open range and the API applies a closed one. An entry whose start is exactly 00:00 on the 1st of the following month satisfies both lte (this period) and gte (next period). Why it matters: That entry is added to two months' totals and appears in two months' entry lists. Narrow — it needs a start timestamp landing exactly on the boundary minute — but a night shift beginning at 00:00 is a realistic entry in a time tracker, and these totals feed invoicing. The bound is shared, so Overview and Hours use the same range shape. Fix: Use lt for the upper bound in aggregateByStatus / aggregateByProject / findAll, or have callers pass the last instant of the period rather than the first instant of the next.
9. Every failure is reported as a connection problem — Low · MSG-03
Where: services/client/src/views/Dashboard.vue:292What: fetchError maps any thrown value to « Impossible de charger les données. Vérifiez votre connexion. » — a 403, a 500 or a malformed response all tell the user to check their network. The repo already has extractErrorMessage (services/client/src/utils/index.ts) with curated French copy per backend code, and it is not used here. Why it matters: The user is sent to debug the wrong thing, and a genuine permission or server fault is invisible to them and to support. Fix: Route the caught error through extractErrorMessage(err, "Impossible de charger les données.") and keep the connection advice for actual network errors.
10. The page's identity is absent from the heading structure, and there is no main landmark — Low · A11Y-04, CONTENT-02
Where: services/client/src/views/Dashboard.vue:5-7 and :17-19; services/client/src/components/Layout/LayoutApp.vue:10What: The <h1> is the period (« Juillet 2026 »); the page's actual name, « Bilan » — the word on the nav button at LayoutApp.vue:61 — is a styled <p> eyebrow. Heading levels themselves are clean (one h1, two h2s, no skips). Separately, LayoutApp renders <RouterView> into a bare <div>, so there is no <main>; only the bottom <nav> is a landmark. Why it matters: A screen-reader user navigating by heading lands on « Juillet 2026 » with no confirmation they reached the destination the « Bilan » button promised, and has no main to skip to. Fix: Either promote « Bilan » to the h1 and demote the period to an h2 (or an aria-live sub-heading), or give the h1 an accessible name that includes both. The landmark fix belongs in LayoutApp.vue and affects all four worker screens — worth promoting to PROJECT-LEVEL.md rather than fixing here.
Unverified
- A11Y-01 (contrast). Needs computed colours. The candidates worth measuring are
text-surface-400onbg-surface-50(the « Bilan » eyebrow at:5, the twoh2labels at:144/:173, and « Aucune entrée » at:192) andtext-surface-200onbg-surface-50for the disabled next-period chevron (:23). The small-caps 10 pxtracking-[0.2em]labels are the highest risk. - A11Y-06 (short viewport). Not settleable from code.
LayoutAppish-screenwith an absolutely-positioned bottom nav and the content region carriespb-24; the hero card istext-5xl. Worth checking at ~700 px height that the hero plus the first breakdown rows are reachable and the nav does not overlap the last entry card. - MSG-01 on the error block. Whether PrimeVue 4's
Messagerendersrole="alert"could not be confirmed —node_modulesis not installed in this checkout (standing caveat in PROJECT-LEVEL.md). - Whether
v3-infinite-loading'sstate.error()exists in the installed version (suggested in finding 2) — same reason. If it does not, an explicit retry row under the list achieves the same thing.
Baseline additions
Descriptive IDs with definitions; the orchestrator should renumber and merge.
MSG-EMPTY-TRUTH— An empty state may only be shown when the underlying query returned nothing. A view that derives a filtered or partial total must not use that derived total as its emptiness test. (Distinct from the MSG-06 family: this is real data hidden by a filtered predicate, not a failure rendered as emptiness. Both should probably live in one section.)DATA-RACE— Overlapping requests triggered by the same control must be sequenced or aborted, so a stale response can never overwrite fresher data. (Applies to any view whose filter/period control re-fetches; likely a recurring theme across the programme.)CONTENT-SCOPE— A summary label must state the scope of what it aggregates whenever that scope is narrower than the items shown beside it (excluded statuses, a shorter time window, a filtered subset). (This is the same rule theUserInvoices« Factures récentes » finding needs — worth reconciling the two into one entry.)DATA-RANGE— Period bounds must use consistent inclusivity between caller and API, so no record falls into two adjacent periods.
Also note for reconciliation: A11Y-07(d) (toggle state exposed programmatically) is cited in finding 4 and already appears in the PROJECT-LEVEL collision list.
Cross-project note
MSG-EMPTY-TRUTH/ finding 1 — the same shape (a derived total used as the emptiness predicate) is worth checking in playout's dashboards and in members'DonsProgress.vue, which PROJECT-LEVEL already flags for gating a skeleton on a data value rather than a load state. Same root cause: state inferred from data instead of from the request.- Finding 2 (empty-as-error) — already confirmed in all four projects as MSG-06. What is new here is that it now appears within a screen that has a correct error surface for its other fetch, which strengthens the case for the rule over the per-screen finding.
- Finding 3 (stale response) — check customer-portal and playout wherever a period/filter control drives a fetch; this is generic Vue watcher code, not tt-specific.
- Finding 4 (colour-only toggle state) —
A11Y-07(d)was independently proposed by another agent in a different project, so at least two are affected. - Within tt-time-tracker, queue row #15 Admin dashboard (
views/Admin/AdminDashboard.vue) and #16 Overview are the obvious places for findings 3, 4, 5 and 8 to recur — they use the same period-range and aggregate endpoints. Finding 8 in particular is an API-level bound shared with Hours (#1).