Skip to content

[UX] members — My camps & objectives (widget) ​

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

Summary ​

The camps-objectives widget renders three quite different experiences (own objective, family configuration, family progress) off a single mount point, and picks between them from data shape alone — never from who is looking or whether the data actually loaded. The result is that both failure paths and the first-use path are indistinguishable from "you have no objective": an API error either hangs on a skeleton forever or shows a confident "Pas d'objectif", and a parent who has never configured anything lands on a tab showing 0 € / 0 € and nothing else. Separately, the parent-configuration screen — "Coût total pour tes U18", "Ta contribution", sliders over every sibling's target — is shown to any member whose family contains a 13–18-year-old, which includes 13–18-year-olds themselves.

Findings ​

1. An API failure leaves the widget on a loading skeleton forever — High · MSG-03, MSG-04, CONTENT-04 ​

Where: widgets/src/widgets/my-camps/stores/objectifs.store.ts:47-52 and widgets/src/widgets/my-camps/components/MyObjectif.vue:28-32What: initialize() sets loading.value = true, then await getObjectifs() with no try/catch. apiGet throws on any non-2xx (widgets/src/api.ts:22-33), so a 500, a 401 on an expired widget session, or an offline device leaves loading stuck at true. MyObjectif.vue:7 then renders <BccSkeleton> permanently. The onMounted chain that calls it has no error handling either, and there is no retry control anywhere in the widget. Why it matters: A member sees a grey shimmer bar under "Mon objectif 2026" for as long as they leave the page open. Nothing tells them it failed, nothing tells them what to do, and reloading the host page is the only escape — which they have no reason to know. Widgets are embedded in someone else's page, so there is no app shell or route-level error surface to catch this (see the queue's inventory-level observation on widget error surfaces). Fix: Wrap the body of initialize() in try/catch/finally, always clear loading in finally, and add an error ref to the store. Render a BccMessage severity="error" with a "Réessayer" button that re-calls initialize() when it is set. Do the same around the onMounted sequence in MyObjectif.vue so a loginApi or family.get() rejection is also surfaced.

2. Auth and family-lookup failures are presented as "you have no objective" — High · MSG-02, MSG-03 ​

Where: widgets/src/widgets/my-camps/components/MyObjectif.vue:28-32, components/NoObjectif.vue:2-7What: onMounted awaits session.loginApi(...) then useFamily().get() before objectifs.initialize(). If either rejects — invalid/expired token prop, wrong suborg, API down — the promise chain aborts before initialize() ever runs. objectifs.loading is still false and objectifs.list is still [], so MyObjectif.vue:8 falls straight through to NoObjectif, which states as fact: « Pas d'objectif — Tu n'as pas d'objectif pour l'année 2026. » The same happens if the host page embeds the widget with a missing or malformed year attribute: Number(undefined) gives NaN, the title renders "Mon objectif NaN", the request goes out as ?annee=NaN, and the member is told they have no objective. Why it matters: This is worse than an error — it is a wrong answer delivered confidently. A member whose session token expired is told their camp objective does not exist, and the natural response is to contact staff about missing data that is in fact present. It also actively masks integration breakage: a host page embedding the widget wrongly looks like it works. Fix: Distinguish "not loaded" from "loaded and empty". Give the store an explicit loaded flag set only after a successful initialize(), and only render NoObjectif when loaded && list.length === 0. Any other terminal state renders the error surface from finding 1.

3. The parent-configuration view is chosen by family shape, not by who is looking — High · ROLE-01 (proposed) ​

Where: widgets/src/widgets/my-camps/components/MyObjectif.vue:9, components/parents/Configuration.vue:4-47, stores/objectifs.store.ts:18-21What: The branch is v-else-if="objectifs.familyU18.length > 0" — i.e. "does this member's family contain anyone aged roughly 13–18". familyU18 is computed over family.members, which is [...Parents, ...Enfants] of the family record (widgets/src/stores/family.store.ts:11-14) and therefore includes the viewer. Any member aged 13–18 is in their own familyU18 set, so they get the parent view: a banner reading « Coût total pour tes U18 » listing every sibling by name, a « Ta contribution » slider, and sliders that write each sibling's objective. The session store already computes a category (U16 / O16 / O18, widgets/src/stores/session.store.ts:13-21) and it is never consulted for this decision — only for copy inside NoObjectif. Why it matters: A 14-year-old is addressed as the bill-payer and handed controls over their siblings' fundraising targets. The API is scoped to the family (api/src/objectives/widget-objectives.controller.ts:24-39, 78-80, 96-98) so this is not a data breach, but it is the wrong screen for the wrong person, and the child's own screen — the one NoObjectif was written for — becomes unreachable for exactly the members it addresses: an O16 member in a family with U18 children always lands in the parent view, so « Demande à tes parents d'aller sur cette page » (NoObjectif.vue:5) can only ever appear for a member with no family record at all. Fix: Gate the parent branch on session.category === 'O18' (or better, on the viewer being in family.data.Parents) rather than on familyU18.length. Members aged 13–18 should see their own objective and the O16 guidance copy.

4. First-use parents land on an empty "Nos progrès" tab — Medium · MSG-03 ​

Where: widgets/src/widgets/my-camps/components/parents/Parents.vue:29-33, components/Objectifs.vue:2-24, 44-46What: tab defaults to 'progress'. Objectifs.vue renders only visibleObjectifs — entries with Total > 0 — and has no fallback branch. On first use every objective row is a placeholder with Total: 0 (stores/objectifs.store.ts:61-78), so visibleObjectifs is empty and the pane renders either literally nothing (single row) or a banner reading « Progrès total — 0 € / 0 € » with an empty bar and no cards (two or more rows, since the banner's v-if tests objectifs.list.length rather than visibleObjectifs). The intended guard exists but never fires: 'progress' is disabled when objectifs.list.length == 0, and in the parent branch initialize() always pushes at least the family placeholder, so the list is never empty here. Why it matters: The parent's very first view of the feature is a blank card or a meaningless zero. Nothing points at the « Nos objectifs » tab where the actual task lives. Per user-feedback/empty-states, a first-use empty state must carry a message, a supporting line and a primary action; this has none of the three. Fix: Default tab to 'objectif' when no row has a non-zero Total, and give Objectifs.vue an explicit empty branch — « Vos objectifs ne sont pas encore définis » plus a button that switches to the configuration tab. Base the banner's v-if on visibleObjectifs.length > 1 so it cannot render 0 € / 0 €.

5. NoObjectif tells the user nothing to do next — Medium · MSG-03, MSG-04 ​

Where: widgets/src/widgets/my-camps/components/NoObjectif.vue:2-7What: The non-O16 message is « Pas d'objectif — Tu n'as pas d'objectif pour l'année 2026. » That is a statement of absence with no action, no explanation of why an objective might be missing, and no contact route. The O16 variant does better (« Demande à tes parents… ») but, per finding 3, is effectively unreachable. Why it matters: This is the widget's designated empty state and the fallback for several failure paths, so it is the screen most likely to be a member's only information. user-feedback/empty-states calls for a state message, supporting detail and a recovery path; only the first is present, which turns a normal zero-data view into a dead end. Fix: Add supporting copy explaining who sets an objective and when, plus one recovery affordance — a contact link (mailto:) or a link to the camps information page. Keep the copy distinct from the error state added in finding 1.

6. Save confirmation is invisible to screen readers and drops the user's focus context — Medium · MSG-01 ​

Where: widgets/src/widgets/my-camps/components/parents/Configuration.vue:48-55, widgets/src/components/Toast.vue:2-22What: Pressing « Enregistrer » swaps in ConfirmToast, a full-card overlay (absolute inset-0 bg-white/80) carrying a spinner, then a success or error Toast. Neither the overlay nor Toast.vue has role="status", role="alert" or aria-live, and the button carries no aria-busy. Focus stays on « Enregistrer », which is now covered by the overlay. The toast auto-dismisses after 5 s (Toast.vue:41). Why it matters: A screen-reader user presses save and hears nothing — not that it is in progress, not that it succeeded, not that it failed (WCAG 4.1.3). Because the overlay auto-closes, the entire confirmation can come and go without ever being announced, leaving the user unsure whether their family's objectives were recorded. Fix: Put role="status" aria-live="polite" on the ConfirmToast wrapper and role="alert" on the error variant, set aria-busy on the submit button while objectifs.isSubmitting, and do not auto-dismiss the error toast. Toast.vue is shared across widgets, so the announcement fix belongs there.

7. Objective sliders have no programmatic label or value text — Medium · FORM-01, A11Y-05 ​

Where: widgets/src/widgets/my-camps/components/parents/Configuration.vue:22 and :36-42What: Each BccSlider is passed min/max/step/model-value and nothing else — no aria-label, no aria-labelledby pointing at the child's name (Configuration.vue:18) or at the « Ta contribution » heading (Configuration.vue:27), and no aria-valuetext. The visible name sits in a sibling <span> with no association. Why it matters: A screen-reader user moving through the form hears an unnamed slider repeated once per child and cannot tell whose objective they are changing, then hears a bare number ("300") with no currency. On a screen where each control commits a different person's money target, that is not recoverable by guessing. Fix: Give each name span an id and pass aria-labelledby to its slider; add :aria-valuetext="currency(child.Total ?? 0)". Check first whether @bcc-code/component-library-vue forwards these attributes to the underlying PrimeVue handle — if it does not, the fix belongs upstream (per the members note in BASELINE.md).

8. The parent-contribution slider shrinks to a sliver, then silently disappears — Medium · A11Y-02, MSG-01 ​

Where: widgets/src/widgets/my-camps/components/parents/Configuration.vue:36-38 and :78-82What: The « Ta contribution » slider's own width is bound to getWidth(remaining) — 100 * remaining / cost percent of the row — and its max is remaining. As children's totals rise, the control physically shrinks; at remaining === 0 it has width 0% and max 0, so it vanishes with no explanation. Meanwhile the watch at :78-82 silently clamps the already-set parent total downwards whenever remaining falls, with no message. Why it matters: A parent who allocates most of the cost to their children watches their own control become a few pixels wide — well under the 24×24 CSS px floor — and then disappear entirely, with no text saying why. Their previously entered contribution is also reduced underneath them without any notice, so the number they thought they had committed is quietly different at save time. Fix: Keep the slider at a fixed width and represent the split in a separate stacked bar; when remaining === 0, replace the control with an explicit line (« Les objectifs de tes enfants couvrent déjà le coût total »). When the watch clamps the value, surface it — a role="status" line stating the contribution was reduced to the remaining amount.

9. Slider edits are lost without warning and are shown as real progress before saving — Medium · MSG-05 ​

Where: widgets/src/widgets/my-camps/components/parents/Configuration.vue:22, 41-42, components/parents/Parents.vue:12-15, components/Objectifs.vue:16-23What: The sliders mutate objectifs.list entries in place. Switching to « Nos progrès » renders those same in-memory objects, so unsaved targets appear in the progress view — with the year's real Progres beside them — as if they had been committed. There is no dirty-state indicator, no "unsaved changes" warning, and no cancel/reset; closing or navigating the host page discards everything silently. Why it matters: A parent can plausibly set every objective, flip to « Nos progrès » to check the result, see exactly what they expected, and leave without pressing « Enregistrer ». Nothing was saved, and the confirmation they thought they saw was their own unsaved input. Fix: Track a dirty flag against the last loaded server state; show it on the tab and beside the save button, warn on beforeunload while dirty, and either save on tab change or render the progress tab from a pristine copy of the server data.

10. Progress bars carry no progress semantics; "contacter Edward Tombre" is not a route out — Low · MSG-04, A11Y-07 (proposed) ​

Where: widgets/src/widgets/my-camps/components/Objectif.vue:11-13, components/Objectifs.vue:9-11, components/parents/Parents.vue:3What: Both bars are plain <div>s with an animated width and no role="progressbar", aria-valuenow/min/max or accessible name — the numeric text beside them carries the information, so nothing is lost, but the bars themselves are meaningless to assistive tech and to any user relying on zoom reflow where text and bar separate. Separately, Parents.vue:3 offers the only support route in the feature — « merci de contacter Edward Tombre » — as a bare personal name with no address, link or role, and that line is unreachable anyway: it is guarded on family.members.length == 0, while the component only renders when familyU18.length > 0, which requires members. Why it matters: The support sentence is dead code, and the pattern it encodes — naming one staff member as the escalation path — leaves a stuck user with nothing clickable. It also hardcodes a named individual into a member-facing bundle. Fix: Add role="progressbar" with aria-valuenow/aria-valuemin/ aria-valuemax and an aria-label naming the person, to both bars. Delete the unreachable branch in Parents.vue or move its condition somewhere it can fire, and replace the personal name with a mailto: to a role address.

Unverified ​

  • A11Y-01 (contrast). Cannot be settled from code. Needs checking on a rendered page: white-on-brand-800 in both summary banners; the text-neutral-400 amount line in Objectif.vue:9 and Configuration.vue:19; the amber/blue/purple percentage figures set via inline color (Objectif.vue:4, Configuration.vue:23) against white; and the opacity-80 / opacity-60 label text in both banners, which lowers effective contrast further.
  • A11Y-06 (short viewport / mobile). Needs a rendered viewport. Two specific risks to check: the ConfirmToast overlay positions itself with marginTop: (-boundingY + windowHeight/2 - 60)px (widgets/src/components/ConfirmToast.vue:32-34), which can place the confirmation off-screen when the widget sits low in a tall host page or the host scrolls; and the flex-row-reverse slider row in Configuration.vue:31-43 at narrow widths.
  • Whether @bcc-code/component-library-vue's BccSlider forwards aria-label / aria-labelledby to the PrimeVue handle (finding 7) — not read, since it lives in node_modules.
  • CONTENT-01 — not-applicable, see project-level i18n finding.

Baseline additions ​

  • ROLE-01 — A view whose copy or controls address a specific role (guardian, admin, payer) is gated on the viewer holding that role, not inferred from the shape of the data they can see. Server-side scoping that merely permits the write is not sufficient; it decides authorisation, not audience. Seen here as finding 3, where a 13–18-year-old receives the guardian screen because they are themselves in the household's minors list.
  • A11Y-07 — A visual progress indicator carries role="progressbar" with aria-valuenow/aria-valuemin/aria-valuemax and an accessible name, or is explicitly aria-hidden alongside an equivalent text value. Currently the baseline has no rule for progress semantics at all, and the four projects use progress bars in at least five features.

Cross-project note ​

  • Findings 1 and 2 (loading flag never cleared on error; failure rendered as an empty state) are the highest-value cross-project checks. The pattern here — loading = true … await … no catch, plus a template whose "empty" branch is also its de-facto error branch — is a store idiom, not a widget-specific slip. The other five members widgets share useConfirmationState and the same store shape, so check my-events, my-decharges and both memberships widgets first. playout and customer-portal use TanStack Query, which exposes isError separately, so they are structurally less exposed; tt-time-tracker is worth checking.
  • Finding 6 (unannounced confirmation overlay) is in the shared ConfirmToast/Toast pair and therefore affects every members widget that saves anything. One fix in widgets/src/components/Toast.vue closes it everywhere.
  • Finding 10 / proposed A11Y-07 applies wherever a progress bar is hand-rolled from a div — likely members DonsProgress and the admin objectives view, and worth a grep in playout and customer-portal.