From 279b852eac26bff3a4922334d1396ab80f9c84a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 18:03:30 +0000 Subject: [PATCH 1/7] Add instructor filter to Report and auto-set display name on login - fetchSessionsForReport now joins trainer_id + profiles(display_name) - Report shows a 4th filter chip row (instructor names) when >1 instructor present in the period; empty selection = all instructors (default) - Reset filter clears instructor selection alongside days/types - ensureDisplayName() sets profiles.display_name to email prefix on first login if not yet set, ensuring the filter always has a meaningful label - Docs updated: CHANGELOG, README, CLAUDE.md https://claude.ai/code/session_01Ks23ragnyERfPjjqHpfyjp --- CHANGELOG.md | 6 +++++ CLAUDE.md | 3 ++- README.md | 1 + app/src/App.jsx | 6 ++--- app/src/components/Report.jsx | 42 ++++++++++++++++++++++++++++++----- app/src/lib/db.js | 13 ++++++++++- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5ea8d..72cac00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Workout Lens are documented here. +## [1.2.5] — 2026-05-13 + +### 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..28b519d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,8 +222,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/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..15dde97 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,18 @@ export default function Report({ prefill, onPrefillConsumed }) { return [...names].sort(); }, [sessions]); + const availableInstructors = useMemo(() => { + const map = new Map(); + sessions.forEach(s => { + if (s.trainer_id && !map.has(s.trainer_id)) { + map.set(s.trainer_id, s.profiles?.display_name || "Unnamed"); + } + }); + return [...map.entries()] + .map(([id, label]) => ({ id, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [sessions]); + const filteredSessions = useMemo(() => { return sessions.filter(s => { if (selectedDays.size > 0) { @@ -201,9 +214,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.trainer_id)) return false; + } return true; }); - }, [sessions, selectedDays, selectedTypes]); + }, [sessions, selectedDays, selectedTypes, selectedInstructors]); const { muscleCounts, maxPrimaryCount, muscleExercises, muscleVolume, muscleLastDate } = useMemo(() => { const primarySessions = {}; @@ -262,6 +278,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 +315,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 +361,22 @@ export default function Report({ prefill, onPrefillConsumed }) { ))} )} + {/* Row 4: instructors — only when >1 instructor present */} + {availableInstructors.length > 1 && ( +
+ {availableInstructors.map(({ id, label }) => ( + toggleInstructor(id)} /> + ))} +
+ )}