diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5ea8d..836a74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Workout Lens are documented here. +## [1.2.5] — 2026-05-13 + +### Fixed +- **Image analysis broken (400 error)** — `CLAUDE_MODEL_VISION` was set to `claude-opus-4-5`, which has been retired by Anthropic. Switched vision to `claude-sonnet-4-6` (same model as text recommendations) — sufficient for OCR + JSON extraction and significantly cheaper than Opus. API allowlist simplified to a single entry. + +### Added +- **Instructor filter on Report** — the report page now includes a fourth filter row (instructor display names) when sessions from more than one co-instructor are present in the selected period. Default is all instructors (empty selection = no filter), consistent with the existing weekday and session-type filter pattern. `fetchSessionsForReport` now joins `trainer_id` and `profiles(display_name)` so instructor identity is available client-side without an extra query. +- **Auto-set display name on login** — `ensureDisplayName()` in `db.js` runs alongside `ensureGymMembership()` on every auth state change. If the user's `profiles.display_name` is null, it is automatically set to the prefix before `@` in their email address. This ensures the instructor filter always has a meaningful label for every user without requiring manual action in Settings. + ## [1.2.4] — 2026-05-12 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d9ff1c3..d2a805b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,51 @@ All GitHub issues follow this structure: - **`## Out of scope`** — explicit exclusions to prevent scope creep (optional but recommended for larger issues) +## Glossary + +Canonical definitions for domain terms. When a term is ambiguous in an issue or conversation, refer here — or ask for clarification before implementing. + +### People & roles + +| Term | Definition | +|---|---| +| **User** | The person logged into Workout Lens. A gym instructor employed at a sporty.no gym. Maps to `auth.uid()`, `sessions.trainer_id`, `user_id` across all tables. | +| **Trainer** | Avoid this term. It is ambiguous — could mean the app user or the gym class instructor. If someone says "trainer" in an issue or conversation, ask: do you mean the app user, or the instructor who led the class? In code, `trainer_id` is a legacy DB column name that refers to the app user. | +| **Instructor** | The person who *leads* a gym class, sourced from sporty.no. Stored in `gym_calendar.instructor`. Has no account in the app. Example: "Linda Hatlevik." When unqualified, "instructor" always means this — the class leader, not the app user. | +| **Co-instructor** | Another app user registered at the same gym (`sporty_business_unit_id`). Their sessions are cross-readable via RLS. | +| **Display name** | A user's visible name in the app. Stored in `profiles.display_name`. Auto-set to email prefix on first login. | + +### Training concepts + +| Term | Definition | +|---|---| +| **Session** | One logged workout. One row in `sessions`. Logged by a user (`trainer_id`). Optionally linked to a gym class (`gym_calendar_id`). | +| **Gym class** | A scheduled class from sporty.no. Stored in `gym_calendar`. Has a name, instructor, start/end time. Synced by `sportySync.js`. | +| **Session exercise** | One exercise performed within a session. Stored in `session_exercises`. Has name, sets, reps, and muscle activations. | +| **Library exercise** | A saved, reusable exercise with a standardised name and default muscle map. Stored in `exercise_library`. Can be referenced by templates. | +| **Template** | A named, reusable workout skeleton owned by a user. Stored in `session_templates`. Contains ordered template exercises. | +| **Template exercise** | An exercise slot inside a template. Stored in `session_template_exercises`. Name and muscles are a denormalised snapshot — renaming the library source doesn't affect it. | +| **Week plan** | An assignment of templates to days of a specific ISO week. Stored in `week_plans` + `week_plan_days`. | + +### Muscle concepts + +| Term | Definition | +|---|---| +| **Muscle ID** | One of 17 fixed string keys (e.g. `chest`, `lats`, `quads`). The canonical identifier used in the DB, prompts, and bodymap. Full list in `MUSCLES` in `bodymap.jsx`. | +| **Primary muscle** | A muscle directly targeted by an exercise. `muscle_activations.activation_type = 'primary'`. Shown as solid green on the body map. | +| **Secondary muscle** | A muscle engaged in a supporting/stabilising role. `activation_type = 'secondary'`. Shown as blue diagonal hatch on the body map. | +| **Muscle activation** | A DB record linking a session exercise to a muscle ID with a type. Stored in `muscle_activations`. | + +### System concepts + +| Term | Definition | +|---|---| +| **Business unit** | A gym location in sporty.no. Identified by `sporty_business_unit_id` (hardcoded as `8`). Used to scope RLS policies and the sporty sync. | +| **Gym calendar** | The sporty.no schedule mirrored in the `gym_calendar` table. Populated by `sportySync.js` three times daily. | +| **Recommendation** | A Claude-generated exercise suggestion based on untrained muscle gap analysis for a period. Cached in `recommendation_cache` keyed by prompt version + period + muscle coverage. | +| **Period** | A filter duration on the Report page — 7, 30, or 90 days back from today. | +| **View** | Front or back side of the body SVG. Not to be confused with React "views" (the full-page components). | + ## Project overview **Workout Lens** — a workout-logging app. User photographs a handwritten training program from a gym whiteboard (sporty.no format), the app analyses the image via Claude Vision, displays which muscles were trained on a body figure, and gives next-session recommendations. @@ -197,7 +242,7 @@ recommendation_cache - **i18n:** `app/src/lib/i18n.js` initialises `i18next` with `fallbackLng: "nb"` and three resource bundles (`nb`, `en`, `fa`). All components use `useTranslation()` for strings. All locale-aware date/time rendering uses `Intl.DateTimeFormat` with a `getIntlLocale()` helper that maps `"nb" → "no"` (the IETF tag `Intl` expects). Never use hardcoded locale strings like `"no-NO"` or `date-fns` locale objects — they break when the user switches language. The `i18n` singleton can be imported directly (`import i18n from "../lib/i18n"`) for `i18n.language` access outside hooks. RTL (`dir="rtl"`) is applied to `` automatically on language change. - **Shared muscle/SVG module:** `app/src/lib/bodymap.jsx` exports `MUSCLES`, `SHAPES`, `EX_DB`, color constants (`PRIMARY_FILL`, `PRIMARY_HOVER`, `PRIMARY_STROKE`, heat vars), `calcMuscles`, `BodySVG`, `HeatmapBodySVG` (accepts `onHover(id|null)` and `hovered` props — when `onHover` is set the internal tooltip is suppressed), and `useIsMobile`. Do not duplicate these in component files. - **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — returns raw `Response`; always call `await res.json()` to read the body), `inferMusclesFromName(name)` (calls Claude Sonnet text API to infer muscle IDs for a single exercise name — returns `{ primary, secondary }` or `null`; handles markdown code fences defensively), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`), `isoWeekMonday(date)` (Date → Monday `Date` of that ISO week, local time), `toIsoDate(date)` (Date → `"yyyy-MM-dd"` string using local time getters — replaces `date-fns` `format`), `getIntlLocale()` (maps `i18n.language` to the IETF tag `Intl` expects, e.g. `"nb" → "no"`). Do not redefine these locally in component files. -- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (opus, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference). All model IDs and prompt text live here; update in one place. +- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; also keep `RECS_PROMPT_VERSION` in `app/api/recsCacheCleanup.js` in sync), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference). All model IDs and prompt text live here; update in one place. - Claude returns muscle IDs directly in JSON — local keyword matching (EX_DB) was abandoned because Norwegian abbreviations and whiteboard variants didn't match reliably. EX_DB is kept only as fallback for manually added exercises. - SVG body uses `BODY_PATH` (bezier curves, viewBox `0 0 160 360`) — improved silhouette with curved shoulders, arms, waist and hips. Still simplified, not anatomically precise. `SHAPES` entries are either ellipses (`{ cx, cy, rx, ry }`) or SVG paths (`{ d }`); the render loop handles both. Key muscles with path shapes: `traps` (trapezoid with neck notch), `lats` (wing paths). `BodySVG` renders primary muscles as solid green glow, secondary as diagonal blue stripes (``). - `useIsMobile(breakpoint=500)` — exported hook from `bodymap.jsx`. Below breakpoint: single body view with Front/Bak toggle. Above: side-by-side. Consumed via `BodyPanel` — do not use directly in page components. @@ -210,7 +255,7 @@ recommendation_cache - **ExerciseForm:** `app/src/components/ExerciseForm.jsx` — form for creating/editing a library exercise (name, default sets/reps, MusclePicker). Props: `initial`, `onSave(fields)`, `onCancel()`, `saving`. On name field blur, fires `inferMusclesFromName` if no muscles are set yet — shows `InlineLoading` spinner → finished flourish → static AI label. Shows a red warning when name is filled but muscles are still empty. Extracted from inline definition in `Bibliotek.jsx`. - **LibraryPicker:** `app/src/components/LibraryPicker.jsx` — searchable list of library exercises for adding to a template. Props: `libraryExercises[]`, `onAdd(exercise)`, `onClose()`. Extracted from inline definition in `TemplateSessionEditor.jsx`. - **ExerciseRowWithAutocomplete:** `app/src/components/ExerciseRowWithAutocomplete.jsx` — wrapper around `ExerciseRow` that adds an inline autocomplete dropdown and AI muscle inference when a new exercise name is typed. Only activates when `isNew` prop is true (IDs added during the current edit session, tracked via `newExerciseIds` Set in History). Props: all `ExerciseRow` props + `libraryExercises[]` + `isNew`. On name field blur (including tab-to-sets/reps), fires `inferMusclesFromName` if no muscles are set — shows spinner → finished flourish → static AI label; library autocomplete selection clears any AI inference. Library is fetched once when edit mode opens; failure degrades silently to manual entry. Uses `onMouseDown + e.preventDefault()` on suggestions to prevent input blur from closing the dropdown before the click fires. Used in `History.jsx` edit mode only — `ExerciseRow` is unchanged for `MuscleMap` and `TemplateSessionEditor`. -- **API security:** `app/api/claude.js` requires a valid Supabase JWT on every request (`Authorization: Bearer `). Verifies via `GET /auth/v1/user`. Also enforces a model allowlist (`claude-opus-4-5`, `claude-sonnet-4-6`) and caps `max_tokens` at 2000. The `callClaude(body)` helper in `utils.js` injects the token automatically — all Claude calls must go through it. +- **API security:** `app/api/claude.js` requires a valid Supabase JWT on every request (`Authorization: Bearer `). Verifies via `GET /auth/v1/user`. Also enforces a model allowlist (`claude-sonnet-4-6`) and caps `max_tokens` at 2000. The `callClaude(body)` helper in `utils.js` injects the token automatically — all Claude calls must go through it. - **Template navigation:** `App.jsx` manages views `"bibliotek"`, `"template-picker"`, `"template-editor"`, `"settings"`, `"planlegger"` alongside existing views. `App.jsx` also accumulates cross-cutting state as features land (`bibliotekInitialTab`, `pendingTemplateExercises`, history context state). This is acceptable at current scale — if more than 2–3 further pieces of cross-component state are needed, extract navigation and shared state to a React Context rather than continuing to lift into `App.jsx`. `bibliotekInitialTab` state ensures returning from template edit lands on the "Mal for gymtime" tab. When "Bruk økt" is pressed in `TemplateSessionEditor` (mode="use"), exercises pass to `MuscleMap` via `templatePreload` prop, triggering a `useEffect` that pre-fills the list and jumps to the confirm step. - Supabase Auth uses magic links (`emailRedirectTo: window.location.origin`) - Anthropic API calls go through `app/api/claude.js` — Azure Function v4 model, browser hits `/api/claude` @@ -222,8 +267,9 @@ recommendation_cache - **Supabase client explicit apikey header:** `createClient` is called with `global: { headers: { apikey: supabaseKey } }` in `app/src/lib/supabase.js`. The Supabase JS v2 fetch interceptor should add this automatically, but it was not reaching browser requests — passing it in `global.headers` puts it directly on `PostgrestClient`'s base headers, bypassing the interceptor. Do not remove this option. - **Multi-instruktør gym membership:** `user_gyms` table links each user to a Sporty business unit (`sporty_business_unit_id`). Primary users are instruktører; sharing default is opt-out scoped to the same gym. `ensureGymMembership(buId)` in `db.js` does an idempotent upsert on sign-in (called in `App.jsx`). `DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8` mirrors the hardcoded BU in `sportySync.js`; both must move to a DB config when multi-gym support lands. Backfilled rows exist for both current users. - **Roles (temporal):** `roles` table stores instruktør tenure — `user_id`, `sporty_business_unit_id`, `name` (default `'instruktor'`), `title`, `valid_from` (date), `valid_to` (nullable date). Active roles = `valid_from <= today AND (valid_to IS NULL OR valid_to >= today)`. `fetchActiveRoles(buId)` in `db.js` returns all active roles for the current user at the given gym. Existing placeholder rows were migrated from `user_gyms.role` (issue #140). RLS: users can only read/write their own rows. -- **Display name:** `profiles` has `display_name text CHECK (char_length(display_name) <= 50)`. RLS: existing "Brukere ser sin egen profil" / "Brukere oppdaterer sin egen profil" policies cover self-reads and writes; new "Same-gym users can read profiles" SELECT policy exposes `display_name` to co-instructors at the same gym. `fetchDisplayName()` / `updateDisplayName(name)` in `db.js`. Settings → Konto exposes a TextInput. +- **Display name:** `profiles` has `display_name text CHECK (char_length(display_name) <= 50)`. RLS: existing "Brukere ser sin egen profil" / "Brukere oppdaterer sin egen profil" policies cover self-reads and writes; new "Same-gym users can read profiles" SELECT policy exposes `display_name` to co-instructors at the same gym. `fetchDisplayName()` / `updateDisplayName(name)` in `db.js`. Settings → Konto exposes a TextInput. `ensureDisplayName()` runs on every login alongside `ensureGymMembership()` — if `display_name` is null it sets it to the email prefix (`user.email.split('@')[0]`); fire-and-forget, errors are silent. - **Session visibility (removed):** The `sessions.visibility` column exists in the DB but is no longer used. The "Same-gym users can read sessions" RLS policy was updated to remove the `visibility = 'shared'` filter — all sessions are cross-readable by co-instructors at the same gym. `updateSessionVisibility` is removed from `db.js`; the History visibility Toggle is gone. Settings → Konto shows an informational GDPR paragraph. +- **Report instructor filter:** `fetchSessionsForReport` joins `trainer_id, profiles(display_name)` on every call. `Report.jsx` derives `availableInstructors` (unique `{ id, label }` pairs, sorted alphabetically, label falls back to `"Unnamed"`) from the fetched sessions and renders a fourth filter chip row only when `availableInstructors.length > 1`. `selectedInstructors` is a `Set` — empty means all instructors shown (same pattern as `selectedDays`/`selectedTypes`). Reset button clears all three Sets. Recs cache key is unaffected — `sessionCount` already encodes the filtered result naturally. - **Joint class history:** `fetchClassHistory(gymCalendarId)` in `db.js` returns co-instructor sessions for a given gym class instance (excludes own), with joined `profiles(display_name)` and `session_exercises`. History lazy-fetches on first expand of a gym-linked session; cached in `classHistory` Map state (key: `gym_calendar_id`). Panel always renders in the expanded session view, showing display name + exercise list per colleague. ## Known limitations diff --git a/README.md b/README.md index f28096c..105cf85 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus 11. **Settings** — language selector (top), theme toggle (dark/light) + nav hints toggle with live body map preview, contact, Om appen section (version + "Vis introduksjonsguide" replay button + changelog accordion), and account section: display name input + sign-out (bottom) 12. **First-login intro guide** — a 5-slide modal appears automatically on first login (gated by `localStorage` key `wl-intro-seen`); walks through Upload → History → Report → Planner → Library; skippable and replayable from Settings 13. **Joint class history** — when a gym-linked session is expanded in History, a "Kolleger i denne klassen" panel shows co-instructor sessions for the same class slot (display name + exercise list). All sessions are always visible to co-instructors at the same gym — this cross-instructor transparency is the core value of the shared view +14. **Report instructor filter** — when sessions from multiple co-instructors appear in the selected period, a fourth filter row with instructor name chips appears on the Report page; default is all instructors visible; display names are auto-set to the email prefix on first login so the filter always shows a meaningful label 14. **Polished dark/light theme** — IBM Carbon g100 (dark) and g10 (light) themes with no flash-of-unstyled-content on page load or view navigation; theme persists across sessions and respects `prefers-color-scheme` on first visit ## Tech stack diff --git a/app/api/__tests__/claudeUtils.test.js b/app/api/__tests__/claudeUtils.test.js index a88db50..0d185ee 100644 --- a/app/api/__tests__/claudeUtils.test.js +++ b/app/api/__tests__/claudeUtils.test.js @@ -9,7 +9,7 @@ import { describe('ALLOWED_MODELS', () => { it('contains exactly the two production model IDs', () => { - expect([...ALLOWED_MODELS].sort()).toEqual(['claude-opus-4-5', 'claude-sonnet-4-6']); + expect([...ALLOWED_MODELS].sort()).toEqual(['claude-sonnet-4-6']); }); it('caps max_tokens at 2000', () => { diff --git a/app/api/claude.js b/app/api/claude.js index 668689e..c9fc80f 100644 --- a/app/api/claude.js +++ b/app/api/claude.js @@ -75,9 +75,11 @@ app.http('claude', { const data = await upstream.json(); if (!upstream.ok) { - context.error('Anthropic error:', JSON.stringify(data)); + const detail = data?.error?.message || 'Unknown error'; + const errorType = data?.error?.type || 'unknown'; + context.error(`Anthropic error [${errorType}]: ${detail}`); return new Response( - JSON.stringify({ error: 'Claude request failed' }), + JSON.stringify({ error: 'Claude request failed', detail }), { status: upstream.status, headers: { 'Content-Type': 'application/json' } } ); } diff --git a/app/api/claudeUtils.js b/app/api/claudeUtils.js index f6e68e2..0022884 100644 --- a/app/api/claudeUtils.js +++ b/app/api/claudeUtils.js @@ -1,5 +1,4 @@ export const ALLOWED_MODELS = new Set([ - 'claude-opus-4-5', 'claude-sonnet-4-6', ]); export const MAX_TOKENS_LIMIT = 2000; diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index a4dad6a..62da788 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -361,7 +361,8 @@ "7": "7 days", "30": "30 days", "90": "90 days" - } + }, + "unnamed": "Unknown" }, "exerciseRow": { "namePlaceholder": "Click to enter exercise…", diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index 01e95b0..dfd8c96 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -361,7 +361,8 @@ "7": "۷ روز", "30": "۳۰ روز", "90": "۹۰ روز" - } + }, + "unnamed": "ناشناس" }, "exerciseRow": { "namePlaceholder": "برای نوشتن تمرین کلیک کنید…", diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index 4cefd5c..fa5cd52 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -361,7 +361,8 @@ "7": "7 dager", "30": "30 dager", "90": "90 dager" - } + }, + "unnamed": "Ukjent" }, "exerciseRow": { "namePlaceholder": "Klikk for å skrive øvelse…", diff --git a/app/public/staticwebapp.config.json b/app/public/staticwebapp.config.json index d259de1..14c4a10 100644 --- a/app/public/staticwebapp.config.json +++ b/app/public/staticwebapp.config.json @@ -14,7 +14,7 @@ "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", - "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://*.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'" + "Content-Security-Policy": "default-src 'self'; script-src 'self' 'sha256-S1NwxpfinBiP8uiGmiz+HYOp4lnKrfbe0hf7PcCP3Nk='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://*.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'" }, "platform": { "apiRuntime": "node:22" diff --git a/app/src/App.jsx b/app/src/App.jsx index 75cc0d8..1c9be12 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { supabase } from "./lib/supabase"; -import { ensureGymMembership } from "./lib/db"; +import { ensureGymMembership, ensureDisplayName } from "./lib/db"; import { NavContext } from "./lib/NavContext"; import Login from "./components/Login"; import Home from "./components/Home"; @@ -27,11 +27,11 @@ function App() { useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => { setSession(session); - if (session) ensureGymMembership().catch(() => {}); + if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } }); const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session); - if (session) ensureGymMembership().catch(() => {}); + if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } }); return () => subscription.unsubscribe(); }, []); diff --git a/app/src/components/Report.jsx b/app/src/components/Report.jsx index 950bf86..10654e9 100644 --- a/app/src/components/Report.jsx +++ b/app/src/components/Report.jsx @@ -58,6 +58,7 @@ export default function Report({ prefill, onPrefillConsumed }) { const [periodDays, setPeriodDays] = useState(30); const [selectedDays, setSelectedDays] = useState(new Set()); const [selectedTypes, setSelectedTypes] = useState(new Set()); + const [selectedInstructors, setSelectedInstructors] = useState(new Set()); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -191,6 +192,14 @@ export default function Report({ prefill, onPrefillConsumed }) { return [...names].sort(); }, [sessions]); + const availableInstructors = useMemo(() => { + const names = new Set(); + sessions.forEach(s => { + if (s.gym_calendar?.instructor) names.add(s.gym_calendar.instructor); + }); + return [...names].sort((a, b) => a.localeCompare(b)); + }, [sessions]); + const filteredSessions = useMemo(() => { return sessions.filter(s => { if (selectedDays.size > 0) { @@ -201,9 +210,12 @@ export default function Report({ prefill, onPrefillConsumed }) { const name = s.gym_calendar?.name || null; if (!name || !selectedTypes.has(name)) return false; } + if (selectedInstructors.size > 0) { + if (!selectedInstructors.has(s.gym_calendar?.instructor)) return false; + } return true; }); - }, [sessions, selectedDays, selectedTypes]); + }, [sessions, selectedDays, selectedTypes, selectedInstructors]); const { muscleCounts, maxPrimaryCount, muscleExercises, muscleVolume, muscleLastDate } = useMemo(() => { const primarySessions = {}; @@ -262,6 +274,14 @@ export default function Report({ prefill, onPrefillConsumed }) { }); }; + const toggleInstructor = (id) => { + setSelectedInstructors(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + const sessionCount = filteredSessions.length; const musclesCovered = Object.values(muscleCounts).filter(c => c.primary > 0).length; const avgPerWeek = (sessionCount / (periodDays / 7)).toFixed(1); @@ -291,7 +311,7 @@ export default function Report({ prefill, onPrefillConsumed }) { return () => { cancelled = true; }; // muscleCounts, sessionCount, untrainedMuscles are derived from the state values already in deps // eslint-disable-next-line react-hooks/exhaustive-deps - }, [periodDays, selectedDays, selectedTypes, sessions]); + }, [periodDays, selectedDays, selectedTypes, selectedInstructors, sessions]); const dayLabel = selectedDays.size > 0 ? DAYS.filter(d => selectedDays.has(d.day)).map(d => d.label.toUpperCase()).join(" · ") @@ -337,14 +357,22 @@ export default function Report({ prefill, onPrefillConsumed }) { ))} )} + {/* Row 4: instructors — only when >1 instructor present */} + {availableInstructors.length > 1 && ( +
+ {availableInstructors.map(name => ( + toggleInstructor(name)} /> + ))} +
+ )}