Skip to content

[UX] members — Donation progress (widget) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 12 Patterns: user-feedback/progress-indicator

Summary ​

DonsProgress is a single-component embeddable widget: it logs the member in, fetches { totalDons, donsObjective } and draws one progress bar. The happy path is fine, but the state machine collapses everything that is not a successful non-zero result into the same permanent loading skeleton — a failed API call, a failed widget login, and a legitimately zero objective all render an amber-less grey bar that never resolves, and the BccMessage error branch on line 7 is unreachable code. The sibling admin tile (admin/src/client/components/app/dashboard/TileDonsProgress.vue) gets this right, so the correct shape already exists in the repo.

Findings ​

1. Failures render as a permanent loading skeleton; the error branch is unreachable — Blocker · MSG-03 ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:6 (and :54-58) What: The skeleton's condition is v-if="(data?.donsObjective ?? 0) <= 0 || loading". On any fetch failure data stays null, so data?.donsObjective ?? 0 is 0, <= 0 is true, and the skeleton wins — the v-else-if="error" BccMessage on lines 7–13 can never render. Two failure paths reach this state:

  • useDonsTotals.fetchTotals sets error and re-throws (useDonsTotals.ts:37); the error is set but never displayed, and the re-throw is unhandled in the onMounted async callback.
  • session.loginApi() (DonsProgress.vue:56) is not wrapped at all, and its rejection never touches error — a bad/expired token attribute produces no error state even in principle, only an unhandled promise rejection.

Why it matters: This is an embedded widget with no app shell around it. A member whose session token expired, or who loads the page while the API is down, sees a card titled « Objectif dons 2026 » with a grey bar that pulses forever inside someone else's page. There is no message, no retry, and nothing that distinguishes "still loading" from "broken" — the widget's only remaining instruction is "wait", indefinitely.

Fix: Split the conditions into real states, in the order loading → error → empty → data, and never gate the loading state on the value of the payload:

vue
<BccSkeleton v-if="loading" … />
<BccMessage v-else-if="error" … />
<EmptyState v-else-if="!data || data.donsObjective <= 0" … />
<div v-else …>

Set loading = true before loginApi, wrap the whole onMounted body in try/catch that assigns to a single shared error ref, and drop the re-throw in fetchTotals (or catch it at the call site).

2. A zero donation objective is indistinguishable from loading — High · CONTENT-04 ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:6What: donsObjective is Math.max(campTotalCost - o16Total - parentsTotal, 0) (api/src/dashboard/dashboard.module.ts:60), so 0 is a normal, expected response — a year with no Total_U18 configured in the sub-org settings, or one where the objectives already cover the camp cost. The template treats 0 as "not loaded yet" and shows the skeleton permanently. Why it matters: For any year without a configured objective, every member who loads the host page sees a widget that appears to be stuck loading. Nobody can tell the difference between "there is no donation target this year" and "this is broken", and the second reading generates support requests. Fix: Give the zero case its own copy inside the same card — e.g. « Aucun objectif de dons défini pour 2026 » — plus the raw total if it is non-zero, so a member who donated still sees their contribution acknowledged. The pattern's own testing guidance is explicit here: verify the default, loading, error and empty states, not just the one that works.

3. The suborg attribute is silently ignored — the widget always authenticates as b-active — High · DATA-01 (proposed) ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:38, 56What: defineProps<{ personid: string, token: string, year?: string }>() does not declare suborg, so an undeclared suborg attribute lands in attrs, not props. Line 56 reads it as (props as any).suborg || "b-active" — the as any cast is exactly what stops vue-tsc from reporting that this is always undefined. The fallback therefore fires unconditionally and the widget logs in against b-active no matter what the host passes. components/admin/AdminPanel.vue:27 explicitly binds :suborg="selectedSuborg", and my-decharges/components/MyDecharges.vue:93 declares the prop properly, so the parameter is intended to work. Why it matters: Embedded on a BCC page, the widget shows B-Active donation totals — a money figure attributed to the wrong organisation with no visual tell — or fails login outright for a member with no B-Active membership, which by finding 1 then renders as an infinite skeleton. Fix: Add suborg?: string to defineProps and drop the as any. The same defect is present verbatim in my-events/components/MyEvent.vue:41, my-memberships/MyMembershipsBActive.vue:101, my-memberships/MyMembershipsBCC.vue:83 and my-camps/components/MyObjectif.vue:29 — it is worth one repo-wide fix rather than five, and may already be raised by the queue #1/#3 drafts.

4. Donation amounts are rounded to whole euros — Medium · CONTENT-05 (proposed) ​

Where: widgets/src/composables/useFormat.ts:2, used at widgets/src/widgets/dons-progress/DonsProgress.vue:21What: The shared formatter is new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0, style: 'currency', currency: 'EUR' }). Because minimumFractionDigits is left to default and is clamped down to the supplied maximum, every amount renders with no cents and half-up rounding: 12,50 € displays as « 13 € », 0,40 € as « 0 € ». Both sides of the {{ format.currency(data.totalDons) }} / {{ format.currency(data.donsObjective) }} pair are affected, and totalDons is a SUM(Montant) over real donation transactions (dashboard.module.ts:54), so fractional values are normal. Why it matters: This is money shown back to the person who gave it, and the widget is the only place a member sees the figure — there is no drill-down to an exact amount. A total that does not match what the donor knows they paid undermines trust in the number and, at small values, can read as « 0 € » for a donation that was actually made. Fix: Keep useFormat().number as-is for counts, but let currency render cents (drop maximumFractionDigits, or set minimumFractionDigits: 2). This is a shared composable — confirm the other widgets that display currency want the same change, and make it once.

5. Error copy is a raw HTTP string and offers no way out — Medium · MSG-02 ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:12; widgets/src/widgets/dons-progress/composables/useDonsTotals.ts:30What: :message="error.message" renders whatever useDonsTotals threw, and that is body.error ?? \HTTP ${response.status}` — so the member-facing string is « HTTP 401 », « HTTP 500 », or a raw NestJS error payload. There is also no retry control anywhere in the component (MSG-03): the only recovery is reloading the host page, which the widget never suggests. **Why it matters:** Latent today because finding 1 makes the branch unreachable, but it becomes live the moment finding 1 is fixed — and it will then be the first thing members see when the API has a bad day. « HTTP 500 » tells a non-technical member nothing and implies nothing about whether their donations were recorded. **Fix:** Map the statuses that matter (401/403 → session expired, everything else → one generic French sentence) and add a « Réessayer » button that re-runs fetchTotals. error.messagecan stay inconsole.error` for support.

6. The loading → loaded swap is silent for screen readers — Medium · MSG-01 ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:6-14What: The skeleton, the error message and the result block replace one another with no live region: no role="status", no aria-live="polite" on the container, and (unverified — see below) no confirmation that BccMessage sets role="alert" itself. Why it matters: A screen-reader user who lands on the host page hears the « Objectif dons 2026 » heading and then nothing; the arrival of the figures, and the arrival of an error, are both unannounced. The progress-indicator pattern calls a missing announcement strategy one of its three named anti-patterns. Fix: Wrap the three branches in a single aria-live="polite" container (or put role="status" on the result block and role="alert" on the error block), and give the skeleton an accessible label naming what is being waited on — « Chargement de l'objectif dons ».

7. The progress bar has no accessible semantics — Medium · A11Y-07 (proposed) ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:23-25What: The bar is two nested <div>s with an inline width — no <progress>, no role="progressbar", no aria-valuenow / aria-valuemin / aria-valuemax, and no accessible name. The {{ percentage }}% span on line 16 is a separate, unassociated node, and it is the only non-visual carrier of the value. Colour (bg-amber-400) is the sole encoding of fill. Why it matters: Assistive tech reads three disconnected fragments — « 45 % », « Dons », « 450 € / 1 000 € » — with nothing tying them to a measurement. The pattern's guidance is semantics first, ARIA second; a native <progress> would carry the value, the bounds and the role for free. Fix: Use <progress :value="data.totalDons" :max="data.donsObjective"> (or role="progressbar" with the three aria-value* attributes) and an aria-label="Objectif dons". Keep the text figures as the visible label.

8. The percentage rounds up to 100 % and hides over-achievement — Low · CONTENT-06 (proposed) ​

Where: widgets/src/widgets/dons-progress/DonsProgress.vue:49-52What: Math.min(100, Math.round((totalDons / donsObjective) * 100)) does two lossy things. Math.round turns 99,6 % into « 100 % » with a visually full bar while the target has not been met; Math.min(100, …) clamps everything above target to « 100 % », which then sits next to text reading e.g. « 1 500 € / 1 000 € ». Why it matters: The first case falsely announces a goal as reached — for a fundraising target, the difference between "done" and "nearly done" is the entire call to action. The second makes the widget contradict itself: the badge says 100 % while the figures beside it say 150 %. Fix: Use Math.floor for the displayed percentage so a milestone is never claimed early, clamp only the bar width rather than the number, and let the label show the real figure (e.g. « 150 % ») with a distinct "objectif atteint" treatment above 100 %.

Unverified ​

  • A11Y-01 (contrast). text-amber-400 on the card background (line 16), text-neutral-400 for the « x € / y € » figures (line 21) and the bg-amber-400 fill on bg-neutral-300 (lines 23–24) all look likely to be under 4.5:1 / 3:1, but this needs a contrast tool against the resolved BCC OKLch tokens. Worth checking first. Separately, the repo's own CLAUDE.md says not to use raw amber in templates — semantic tokens exist — so the fix may be a token swap rather than a colour tweak.
  • A11Y-06 (responsive / short viewport). The widget is max-w-lg inside an unknown host page; needs a rendered viewport, and specifically a narrow one where the truncate on « Dons » and the shrink-0 figures compete.
  • BccMessage / BccSkeleton semantics. @bcc-code/component-library-vue is not installed in this checkout (node_modules absent), so I could not confirm whether BccMessage severity="error" emits role="alert" or whether BccSkeleton is aria-hidden. Finding 6 assumes it does not; verify upstream before filing, since the fix may belong in the shared library.
  • Host-page token exposure. personid and token are passed as HTML attributes on the host page (DonsProgress.ts / defineWidget.ts). That is the shared design for all six widgets and the host pages live outside this repo, so it is out of scope here — but it deserves one look at programme level.

Baseline additions ​

  • CONTENT-05 — Currency is rendered with the precision it is stored at. A formatter that drops cents (maximumFractionDigits: 0) misstates amounts the user has personally paid or received. (Independently proposed by a sibling audit; widgets/src/composables/useFormat.ts:2 is the same instance.)
  • CONTENT-06 — A derived figure must not round to a milestone it has not reached (99,6 % is not « 100 % »), and must not silently clamp a value that exceeds its scale while the underlying numbers remain visible beside it.
  • A11Y-07 — A progress or meter visual exposes <progress> or role="progressbar" with aria-valuenow / min / max and an accessible name. A styled <div> with an inline width conveys nothing without sight.
  • DATA-01 — A component must not silently ignore a scope parameter (organisation, account, period). A parameter read through a cast that defeats the type checker, and therefore always falls back to a default, shows correct- looking figures for the wrong scope.
  • MSG-06 (supports finding 1) — A loading state must terminate on every outcome: success, empty and failure. A skeleton whose exit condition depends on data a failed request never returns is an infinite spinner, and it makes the error branch below it unreachable.

Cross-project note ​

  • MSG-06 / finding 1 is the most portable defect here: any v-if="!data"-style loading gate that does not test error first has the same hole. Check playout's and customer-portal's dashboard tiles and tt-time-tracker's report views. Within members, queue #18 B-Active dashboard, #19 Home and #20 Objectives all render progress/stat tiles and should be checked against findings 1, 7 and 8 specifically — TileDonsProgress.vue handles the error branch correctly, so the two implementations of the same tile currently disagree.
  • CONTENT-05 (currency precision) is a one-line grep in each project: look for maximumFractionDigits: 0 next to style: 'currency' in the shared formatter. customer-portal and tt-time-tracker both display invoiced amounts.
  • A11Y-07 applies wherever a hand-rolled bar exists — members queue #5 (camps objectives) uses the same visual, and playout's upload/export progress is a candidate.
  • DATA-01 is members-specific in this instance ((props as any).suborg in four widgets) but the rule generalises to any multi-tenant scope prop.