User drawer — Manual Test Protocol
A human-driven, click-through script for the portal's Users page and user
drawer (apps/portal/src/app/dashboard/users/). Work top to bottom; each case
is self-contained. Record Result (✅ pass / ❌ fail / ⚠️ partial) and Notes
(what you actually saw, plus any console/network error).
Scope: who appears in the roster, which tabs a user gets, the Summary / Goals / Schedule / Activity panels, and the caching + invalidation behaviour behind them. Behaviour is described as currently implemented — if something differs, that difference is the finding.
Test environment
| Item | Value |
|---|---|
| App | @enode/portal dev server |
| Start command | NEXT_PUBLIC_API_SERVER=backdev npm run dev (from apps/portal) |
| URL | http://localhost:3001/dashboard/users (or :3000) |
| Accounts needed | (A) trainable owner · (B) non-trainable coach · (C) a coach or admin without writeTrainingGoal |
| Data needed | At least one athlete with a training goal and completed sessions this week; one athlete without a training goal |
| Tools | DevTools open — Console and Network (filter to the API host) |
Before you start: DevTools open throughout. A case passes only if the visible result is correct and the Console is clean and the Network traffic matches what the case says.
1. Roster visibility
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 1.1 | You appear when trainable | Sign in as (A) | Your own row is in the table with a "You" badge next to the name | ||
| 1.2 | Sole member | On an account where you are the only visible user | The table shows your one row — not the "Add your athletes" empty state | ||
| 1.3 | You disappear when not trainable | Open your own drawer → Details → turn Trainable off → Save → reload | Your row is gone from the roster | ||
| 1.4 | Non-trainable coach | Sign in as (B) | Your own row is not in the table | ||
| 1.5 | Another owner stays hidden | As (B) or a coach, on an account whose owner is trainable | That owner is not in the table | ||
| 1.6 | Mobile card | Narrow the window below md | Your card shows the "You" badge too | ||
| 1.7 | Counts | Compare the header "N users in scope" and the table's "Showing x of y" | Both include your own row | ||
| 1.8 | Not bulk-selectable | Tick the select-all checkbox | Your row is not selected and shows a lock glyph instead of a checkbox; the Delete count excludes you | ||
| 1.9 | Empty state survives | Sign in to an account with no users besides a non-trainable owner | The "Add your athletes" first-run empty state renders |
2. Which tabs a user gets
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 2.1 | Athlete | Open an athlete | Tabs: Summary · Goals · Details · Schedule · Activity. Opens on Summary | ||
| 2.2 | Trainable non-athlete | Open a trainable coach (or yourself as (A)) | Same five tabs plus Assignments. Opens on Summary | ||
| 2.3 | Non-trainable coach | Open a non-trainable coach | Details + Assignments only — no training tabs | ||
| 2.4 | Non-trainable facility | Open a non-trainable facility admin | Details + Assignments only | ||
| 2.5 | Toggle mid-edit | On a trainable user, Details → turn Trainable off (don't save) | The tab strip does not change while the drawer is open | ||
| 2.6 | Toggle takes effect | Save that change, reopen the drawer | The training tabs are now gone | ||
| 2.7 | Create mode | Toolbar → Create user | No tab strip at all — the form only |
3. Opening cost and caching
Watch the Network tab filtered to the API host for each of these.
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 3.1 | One profile read | Hard-reload, open an athlete | Exactly one GET /users/{id} — not three | ||
| 3.2 | Open cost | Same open | One POST /history/v2/week, one POST /workout_sessions/search, workouts month + recurring (often cached) | ||
| 3.3 | Reopen is free | Close and reopen the same athlete within ~15 s | No new stats or session-search calls; the panels render immediately | ||
| 3.4 | Tabs are free | Click through all five tabs | No new requests; no loading flash on first click of each | ||
| 3.5 | Lazy DOM | Open a drawer and stay on Details | Requests still fire, but the muscle maps / Lottie icons are not built until you open those tabs | ||
| 3.6 | Focus revalidation | Leave the drawer open, switch to another app for >15 s, come back | Stats + activity quietly refetch; the Details form and any staged Goals edits are untouched | ||
| 3.7 | No profile clobber | Type into a Details field, switch away and back | Your typing is not overwritten |
4. Summary tab
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 4.1 | Week caption | Open Summary | "This week 4.8 – 10.8" (day.month) at the top, matching Schedule and Activity | ||
| 4.2 | No debug controls | Look at the top of the panel | No "Mock data" checkbox, no percentage slider, no "Log data model" button | ||
| 4.3 | Cards render | — | Weekly-goal banner, metric grid, volume completion ring, muscle map, muscle-usage bars | ||
| 4.4 | Muscle-usage rows | — | One row per muscle, category-grouped, with icon, "done / target sets" and a % | ||
| 4.5 | Collapse | With >5 muscle rows | A fade + chevron collapses the list; the chevron expands and collapses it | ||
| 4.6 | No data | Open an athlete with no sessions this week | A muted "No summary data for this athlete yet." — not an error | ||
| 4.7 | Load failure | Throttle to Offline in DevTools, then open an athlete never opened this session | An inline error with Try again + Report — not a bare sentence or a permanent skeleton | ||
| 4.8 | Retry | Restore the network, press Try again | The cards load |
5. Goals tab — no goal yet
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 5.1 | Setup CTA | Open the athlete without a training goal → Goals | A frosted "Set a training goal" card with a Choose a goal button, floating over a faded, static ghost of the populated panel that fades out toward the bottom. No live map, goal card or rows | ||
| 5.1a | Ghost isn't loading | Same view | The ghost does not pulse — a pulsing backdrop reads as "loading" | ||
| 5.2 | First pick writes straight away | Press Choose a goal → pick one | No confirm dialog; the picker rows disable while it writes; the tab then shows map + goal + rows | ||
| 5.3 | No permission | Sign in as (C) and open that athlete's Goals | The same empty state without the button |
6. Goals tab — with a goal
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 6.1 | Layout order | Open Goals on an athlete with a goal | Top to bottom: Target distribution (map + legend) → Training goal → Muscle goals (rows) | ||
| 6.2 | Map header | — | "Target distribution" with the weekly Total: N sets on the right, and a one-line description below | ||
| 6.3 | Legend ranges | — | Legend reads e.g. "High 14+ sets · Medium 7–13 sets · Low 0–6 sets" — real numbers, no gaps between bands | ||
| 6.4 | Ranges are per athlete | Compare an athlete training ~20 sets/muscle with one training ~6 | The thresholds differ — they scale to each athlete's busiest muscle | ||
| 6.5 | Map click | Left-click a muscle on the map | Its target sets +1; the row below updates; the map re-tints | ||
| 6.6 | Map right-click | Right-click a muscle | Its target sets −1 (floor 0) | ||
| 6.7 | Hover tooltip | Hover a muscle | A floating tooltip with the muscle icon, name, target sets and the click hint — anchored on the muscle, not the corner | ||
| 6.8 | Row hover | Hover a muscle row | That muscle outlines on the map above | ||
| 6.9 | Sets input | Type into a row's set field | Clamped to 0–30; the total and map update | ||
| 6.10 | Zone slider | Drag a row's quality split | The three percentages update and sum to 100 | ||
| 6.11 | Save | Change a target → Save | The drawer persists; reopen shows the new value | ||
| 6.12 | Discard | Change a target → close the drawer without saving → reopen | The old values are back |
7. Goals tab — the goal ↔ targets relationship
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 7.1 | No drift, no hint | Open an athlete whose targets match their goal | No "Targets differ…" line and no reset action | ||
| 7.2 | Drift appears | Change one muscle's target sets | "Targets differ from this goal's defaults." + Reset to goal defaults appears immediately | ||
| 7.3 | Zone drift counts | Instead change only a zone slider | The same line appears | ||
| 7.4 | Reset works | Press Reset to goal defaults | Targets snap back and the line disappears in the same interaction | ||
| 7.5 | Reset is local | Reset → close without saving → reopen | The athlete's saved targets are unchanged | ||
| 7.6 | Change confirms | With a goal already set, pick a different goal | A confirm dialog appears; its copy says targets are adjusted, and does not claim they will be lost | ||
| 7.7 | Change applies | Confirm it | Targets update, the drawer stays open, the goal card shows the new goal | ||
| 7.8 | Summary follows | Immediately switch to the Summary tab | The per-muscle targets reflect the new goal. It must not sit on skeletons — this was a real bug | ||
| 7.9 | Cancel | Pick a different goal → Cancel | Nothing changes | ||
| 7.10 | First-goal check | On an athlete you just gave their first goal, look for the drift line | If it appears immediately, the backend adjusted rather than copied the template — note it, it's worth knowing |
8. Schedule and Activity tabs
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 8.1 | Week caption | Open Schedule | Same "This week d.m – d.m" caption as Summary and Activity | ||
| 8.2 | Workouts | — | This athlete's workouts, split "Earlier this week" (dimmed) / "Upcoming", with weekday headings | ||
| 8.3 | Empty | An athlete with no workouts | Dumbbell icon in a platinum tile + "No workouts scheduled…" + a Schedule a workout button, on a frosted card over a static ghost of workout cards | ||
| 8.4 | Schedule one | Press it | The schedule drawer opens over the top with this athlete preselected | ||
| 8.5 | Activity list | Open Activity | Session cards for this week, newest first | ||
| 8.6 | Right athlete | Pick an athlete whose name is a substring of another's | Only their sessions appear (the search is by name, filtered by id) | ||
| 8.7 | Empty week | An athlete with no sessions | Clock icon in a platinum tile + "No completed sessions this week." on a frosted card over a static ghost of session cards | ||
| 8.7a | Consistent height | Compare 5.1, 8.3 and 8.7 side by side, and against a picker (open the coach picker with no coaches) | All four message cards sit at the same height and use the same icon tile | ||
| 8.8 | Inspect Profile | Press "Inspect Profile →" on a card | The performance sub-drawer pushes on top; back returns to Activity | ||
| 8.9 | No debug button | Both tabs | No "Log data model" button anywhere |
9. Write side-effects and invalidation
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 9.1 | Save then reopen | Edit muscle targets → Save → reopen immediately | Summary shows the new targets, not cached old ones | ||
| 9.2 | Fetch-before-edit | Open a drawer with the network throttled | Save is disabled until the profile read lands | ||
| 9.3 | Save failure | Go offline, press Save | An error surface with Try again — the drawer stays open, edits intact | ||
| 9.4 | Archive | Archive a user | The Status pill flips, the row dims, a toast confirms | ||
| 9.5 | Delete | Delete a user | Confirm dialog → row disappears → no stale data if a user with that id reappears | ||
| 9.6 | Roster freshens | Save a name change | The table row behind the drawer shows the new name without a manual reload | ||
| 9.7 | Save your own row as the owner | Open your own drawer (a trainable owner is in their own roster), edit the name and drag muscle targets → Save | Save is present and works — self-editing runs on writeSelf, not a role tier | ||
| 9.8 | No self-archive, no self-delete | On that same drawer, look at the header actions | Neither Archive nor Delete is offered — both stay tier-only, and owner is no tier | ||
| 9.9 | Your access survives the save | After 9.7, check GET /users/{you} | accessToUserIDs is still null — the drawer omits the field when there is no Assignments tab, so an owner's all-access is never narrowed to [] | ||
| 9.10 | A coach saving themselves | As a coach with writeUserTrainer, open your own drawer and save | Unchanged — the tier grant still covers it, with or without writeSelf |
10. Unsaved-changes guard
The drawer never closes on a backdrop tap, but the header X, Escape and hardware Back all can — each must ask before discarding staged edits. Note what counts: a training-goal change is written immediately, so it is not unsaved; an avatar pick is staged until Save, so it is.
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 10.1 | Clean close | Open a user, change nothing, press X | Closes immediately, no dialog | ||
| 10.2 | Keep editing | Change the name, press X → Keep editing | Dialog closes, drawer stays open, the edit is intact | ||
| 10.3 | Discard | Change the name, press X → Discard | Drawer closes; reopen shows the original value | ||
| 10.4 | Escape | Change the name, press Escape | The dialog appears — not a silent close | ||
| 10.5 | Escape dismisses the dialog | With the dialog open, press Escape | The dialog closes and stays closed (both it and the drawer listen on the document — without the guard it reopens instantly) | ||
| 10.6 | Back | Change the name, press browser/hardware Back | The dialog appears | ||
| 10.7 | Save doesn't prompt | Change the name → Save | The drawer closes with no dialog | ||
| 10.8 | Delete / archive don't prompt | Delete a user; archive a user | Both close with no discard dialog | ||
| 10.9 | Goals edits count | Goals tab → drag a set count → press X | The dialog appears | ||
| 10.10 | Training goal does not count | Goals tab → change the training goal → confirm → press X without touching anything else | No dialog. That write already persisted | ||
| 10.11 | Avatar counts | Pick a photo → press X | The dialog appears (the picture is staged, not uploaded) | ||
| 10.12 | Assignments count | Assignments tab → add or remove an assignee → press X | The dialog appears | ||
| 10.13 | Right-click can't cost you the page | With staged muscle-target edits, right-click the muscle map: on a muscle, on the plain silhouette, and in the gap between the two figures | No browser context menu in any of the three spots — its Back entry sits one misclick from discarding the edits | ||
| 10.13 | Sub-views pop first | Open the coach picker → press Escape | The picker pops, the drawer stays open, no discard dialog | ||
| 10.14 | Guard resets per open | Edit → Discard → reopen the same user → press X | Closes immediately — the previous open's dirty flag must not leak | ||
| 10.15 | Roster row editor | Roster upload → open a row → change a field → close | The dialog appears; the wizard's own "Discard this import?" still works separately | ||
| 10.16 | Migration editor | Migration → open a person → change a field → close | The dialog appears |
11. Performance
| # | Case | Steps | Expected | Result | Notes |
|---|---|---|---|---|---|
| 11.1 | No re-render storm | React DevTools Profiler recording → type in a Details field with all tabs previously opened | The hidden Summary / Goals / Schedule / Activity panels show zero renders | ||
| 11.2 | Typing stays smooth | Type quickly into a Details field on an athlete with many sessions | No input lag |
Known gaps
These are expected as of this protocol — record them only if the behaviour differs from what's described here.
- A trainable owner on a brand-new account never sees the onboarding empty state, because their own row makes the roster non-empty. The header's Upload roster / Create user actions still work.
- The activity search filters by athlete NAME, then re-filters by id client-side, because the endpoint has no id filter. An athlete with a blank name loads nothing rather than searching the whole account.
- Past workouts say nothing about completion. Sessions carry no workout id, so the Schedule tab only dims past days.