diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c5e2a..f1ee92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Workout Lens are documented here. ## [Unreleased] +### Added +- **Joint class history (#138)** — expanding a gym-linked session in History now shows a "Kolleger i denne klassen" panel listing co-instructor sessions for the same class slot. Display name (or "Instruktør" fallback) is shown as a header per colleague, with their exercise list below. Fetched lazily on first expand and cached per `gym_calendar_id`. New RLS policy on `sessions` allows same-gym users to read each other's shared sessions. `fetchClassHistory(gymCalendarId)` added to `db.js`. +- **Session privacy (#139)** — `visibility` column added to `sessions` (default `'shared'`). History edit mode gains a Carbon `Toggle` ("Del med andre instruktører") that persists to `visibility = 'private'` on save. Private sessions are excluded from the cross-gym RLS policy and from `fetchClassHistory`. `updateSession` accepts a `visibility` option. +- **Display name (#141)** — `display_name text` column (max 50 chars) added to `profiles`. Settings → Konto section now has a `TextInput` to set/update a display name, with success/error feedback. Same-gym RLS policy on `profiles` allows co-instructors to read each other's `display_name`. `fetchDisplayName()` and `updateDisplayName()` added to `db.js`. Display name is shown next to colleague sessions in the joint class history view. + ### Changed - **Test suite — better coverage, less noise** — replaced low-value assertions (one-line constant checks, per-model `it`s, a duplicated prompt assertion) with behavioural tests, and filled the largest gaps in `utils.js` (date helpers `toIsoDate`/`toWeekIso`/`weekIsoToMonday`/`isoWeekMonday`, `isInvalidNum`, `extractMuscles`, `getIntlLocale`, `inferMusclesFromName`) and `prompts.js` (`buildMuscleInferencePrompt`). Added a fake-timer test for `checkRateLimit` window expiry. Net: 60 → 82 tests; `utils.js` line coverage ~30% → ~80%, `prompts.js` to 100% statements. diff --git a/CLAUDE.md b/CLAUDE.md index 7f523d3..40f4e7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,6 +199,9 @@ week_plan_days - **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. +- **Session visibility:** `sessions.visibility text NOT NULL DEFAULT 'shared' CHECK (visibility IN ('shared', 'private'))`. Cross-gym SELECT policy "Same-gym users can read sessions" requires `visibility = 'shared'`; own sessions are always accessible via the existing ALL policy. `updateSession` accepts `{ visibility }` option (default `'shared'`). History edit mode shows a Carbon `Toggle` ("Del med andre instruktører") that persists the flag on save. +- **Joint class history:** `fetchClassHistory(gymCalendarId)` in `db.js` returns shared co-instructor sessions for a given gym class instance (excludes own, requires `visibility = 'shared'`), 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 renders in read mode only, after the muscle-groups section, showing display name + exercise list per colleague. ## Known limitations - SVG body is improved but still geometrically simplified — not anatomically precise; key muscles (traps, lats) use path shapes, rest are ellipses diff --git a/README.md b/README.md index a9e4919..3cc0c45 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus 8. **Library** — build a named exercise library with click-to-toggle muscle selection; AI muscle inference fires when you type an exercise name and leave the field — muscles are filled in automatically and marked "Muskler satt av AI"; create session templates (e.g. "CrossFit - Anna - mandag") as reusable collections of library exercises 9. **Weekly planner** — assign templates to each day of the week; an "Ikke trent denne uken" chip row lists the muscles you have not yet trained in logged sessions for the visible ISO week (History-style mono pills); a live "Projisert dekning" heatmap body map shows projected cumulative muscle coverage from the assigned templates; a Forslag card flags muscle groups with no planned coverage; plan is saved to Supabase and reloaded on next visit 10. **Language** — switch between Norsk, English and فارسی (RTL) at any time from Settings; all UI strings, date formats, and month names update instantly -11. **Settings** — language selector (top), theme toggle (dark/light) with live body map preview, contact, changelog, and account/sign-out (bottom) +11. **Settings** — language selector (top), theme toggle (dark/light) with live body map preview, contact, changelog, and account section: display name input + sign-out (bottom) +12. **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). Sessions default to shared; an edit-mode toggle marks them private to exclude them from the shared view ## Tech stack diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index 539e555..00b7e4a 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -165,6 +165,13 @@ "reanalyze": "Re-analyze", "analyzing": "Analyzing…", "editSession": "Edit session", + "shareWithColleagues": "Share with other instructors", + "shareOn": "Shared", + "shareOff": "Private", + "classHistory": "Colleagues in this class", + "classHistoryLoading": "Loading colleague sessions…", + "classHistoryError": "Could not load colleague sessions", + "classHistoryInstructor": "Instructor", "ownTraining": "Personal training", "exerciseCount_one": "{{count}} exercise", "exerciseCount_other": "{{count}} exercises", @@ -268,6 +275,11 @@ "darkThemeOn": "On", "account": "Account", "signOut": "Sign out", + "displayNameLabel": "Display name", + "displayNamePlaceholder": "E.g. Christopher", + "displayNameSave": "Save name", + "displayNameSaved": "Name saved", + "displayNameError": "Could not save name", "about": "About the app", "changelog": "Show changelog", "contact": "Contact", diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index ea5fccc..7bc63d7 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -165,6 +165,13 @@ "reanalyze": "تحلیل مجدد", "analyzing": "در حال تحلیل…", "editSession": "ویرایش جلسه", + "shareWithColleagues": "اشتراک با مربیان دیگر", + "shareOn": "اشتراکی", + "shareOff": "خصوصی", + "classHistory": "همکاران در این کلاس", + "classHistoryLoading": "در حال بارگذاری جلسات همکاران…", + "classHistoryError": "بارگذاری جلسات همکاران ممکن نشد", + "classHistoryInstructor": "مربی", "ownTraining": "تمرین شخصی", "exerciseCount_one": "{{count}} تمرین", "exerciseCount_other": "{{count}} تمرین", @@ -268,6 +275,11 @@ "darkThemeOn": "روشن", "account": "حساب کاربری", "signOut": "خروج", + "displayNameLabel": "نام نمایشی", + "displayNamePlaceholder": "مثلاً کریستوفر", + "displayNameSave": "ذخیره نام", + "displayNameSaved": "نام ذخیره شد", + "displayNameError": "ذخیره نام ممکن نشد", "about": "درباره برنامه", "changelog": "نمایش تاریخچه تغییرات", "contact": "تماس", diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index 99269d5..ccc0546 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -165,6 +165,13 @@ "reanalyze": "Re-analyser", "analyzing": "Analyserer…", "editSession": "Rediger økt", + "shareWithColleagues": "Del med andre instruktører", + "shareOn": "Delt", + "shareOff": "Privat", + "classHistory": "Kolleger i denne klassen", + "classHistoryLoading": "Henter kollegaøkter…", + "classHistoryError": "Kunne ikke hente kollegaøkter", + "classHistoryInstructor": "Instruktør", "ownTraining": "Egentrening", "exerciseCount_one": "{{count}} øvelse", "exerciseCount_other": "{{count}} øvelser", @@ -268,6 +275,11 @@ "darkThemeOn": "På", "account": "Konto", "signOut": "Logg ut", + "displayNameLabel": "Visningsnavn", + "displayNamePlaceholder": "F.eks. Christopher", + "displayNameSave": "Lagre navn", + "displayNameSaved": "Navn lagret", + "displayNameError": "Kunne ikke lagre navn", "about": "Om appen", "changelog": "Vis endringslogg", "contact": "Kontakt", diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 4bdedcc..bb22b95 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -1,10 +1,10 @@ import { useState, useEffect, useRef, useMemo } from "react"; -import { fetchSessions, fetchSessionsByDate, fetchGymSessionsByDate, updateSession, checkGymCalendarConflict, fetchLibraryExercises } from "../lib/db"; +import { fetchSessions, fetchSessionsByDate, fetchGymSessionsByDate, updateSession, updateSessionVisibility, checkGymCalendarConflict, fetchLibraryExercises, fetchClassHistory, fetchDisplayName } from "../lib/db"; import { MUSCLES, PRIMARY_FILL, SEC_FILL, calcMuscles } from "../lib/bodymap.jsx"; import { toBase64, detectMediaType, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale, toIsoDate } from "../lib/utils"; import { CLAUDE_MODEL_VISION, ANALYZE_PROMPT } from "../lib/prompts"; import { - Button, Tag, InlineNotification, DefinitionTooltip, + Button, Tag, Toggle, InlineNotification, InlineLoading, DefinitionTooltip, Select, SelectItem, AccordionSkeleton, SkeletonPlaceholder, } from "@carbon/react"; import { Camera, Add, Edit as EditIcon, Renew, ChevronDown, ChevronLeft, ChevronRight } from "@carbon/icons-react"; @@ -188,6 +188,8 @@ export default function History({ initialDate }) { const libraryCache = useRef(null); const [newExerciseIds, setNewExerciseIds] = useState(new Set()); const [hoveredMuscle, setHoveredMuscle] = useState(null); + const [myDisplayName, setMyDisplayName] = useState(null); + const [classHistory, setClassHistory] = useState(new Map()); const fileRef = useRef(); useEffect(() => { @@ -195,6 +197,7 @@ export default function History({ initialDate }) { .then(setSessions) .catch(e => logDevError("History/fetchSessions", e)) .finally(() => setLoading(false)); + fetchDisplayName().then(setMyDisplayName).catch(() => {}); }, []); useEffect(() => { @@ -236,11 +239,27 @@ export default function History({ initialDate }) { } }, [daySessions]); + const loadClassHistory = async (gymCalendarId) => { + setClassHistory(prev => new Map(prev).set(gymCalendarId, { loading: true, sessions: [], error: null })); + try { + const data = await fetchClassHistory(gymCalendarId); + setClassHistory(prev => new Map(prev).set(gymCalendarId, { loading: false, sessions: data, error: null })); + } catch { + setClassHistory(prev => new Map(prev).set(gymCalendarId, { loading: false, sessions: [], error: true })); + } + }; + const toggleExpand = (id) => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(id)) { next.delete(id); setHoveredMuscle(null); } - else next.add(id); + else { + next.add(id); + const session = daySessions.find(s => s.id === id); + if (session?.gym_calendar_id && !classHistory.has(session.gym_calendar_id)) { + loadClassHistory(session.gym_calendar_id); + } + } return next; }); }; @@ -602,6 +621,23 @@ export default function History({ initialDate }) { ) )} + {/* Visibility toggle — always visible, auto-saves instantly */} + { + const vis = checked ? "shared" : "private"; + setDaySessions(prev => prev.map(s => s.id === session.id ? { ...s, visibility: vis } : s)); + updateSessionVisibility(session.id, vis).catch(() => { + setDaySessions(prev => prev.map(s => s.id === session.id ? { ...s, visibility: session.visibility ?? "shared" } : s)); + }); + }} + style={{ marginBottom: 24 }} + /> + {/* Body map */} ) : ( - (session.session_exercises || []).map(ex => { + <> + {myDisplayName && ( +

+ {myDisplayName} +

+ )} + {(session.session_exercises || []).map(ex => { const muscleLabels = (ex.muscle_activations || []).map(ma => t(`muscles.${ma.muscle_id}`, { defaultValue: MUSCLES[ma.muscle_id]?.label || ma.muscle_id })).join(", "); return (
@@ -699,7 +741,8 @@ export default function History({ initialDate }) { )}
); - }) + })} + )} @@ -740,6 +783,49 @@ export default function History({ initialDate }) { )} + {/* Class history (read mode, gym-linked sessions only) */} + {!isEditing && session.gym_calendar_id && (() => { + const ch = classHistory.get(session.gym_calendar_id); + if (!ch) return null; + if (ch.loading) return ( +
+ +
+ ); + if (ch.error) return ( + + ); + if (!ch.sessions.length) return null; + return ( +
+

+ {t("history.classHistory")} +

+ {ch.sessions.map(cs => { + const name = cs.profiles?.display_name || t("history.classHistoryInstructor"); + const exs = (cs.session_exercises || []).filter(e => e.name); + return ( +
+

+ {name} +

+ {exs.map(ex => ( +
+ {ex.name} + {(ex.sets || ex.reps) && ( + + {[ex.sets && `${ex.sets}×`, ex.reps].filter(Boolean).join("")} + + )} +
+ ))} +
+ ); + })} +
+ ); + })()} + {/* Edit mode actions */} {isEditing && ( <> diff --git a/app/src/components/Settings.jsx b/app/src/components/Settings.jsx index 3ba4be5..5cbc05c 100644 --- a/app/src/components/Settings.jsx +++ b/app/src/components/Settings.jsx @@ -1,11 +1,12 @@ import { useEffect, useState } from "react"; -import { Toggle, Button, RadioButtonGroup, RadioButton, Tag } from "@carbon/react"; +import { Toggle, Button, RadioButtonGroup, RadioButton, Tag, TextInput, InlineNotification } from "@carbon/react"; import { useTranslation } from "react-i18next"; import PageShell, { SectionLabel, PageHeading } from "./PageShell"; import BodyPanel from "./BodyPanel"; import ChangelogModal from "./ChangelogModal"; import { useTheme } from "../theme"; import { supabase } from "../lib/supabase"; +import { fetchDisplayName, updateDisplayName } from "../lib/db"; import i18n from "../lib/i18n"; import { version } from "../../package.json"; @@ -26,13 +27,32 @@ export default function Settings() { const [changelogOpen, setChangelogOpen] = useState(false); const [userEmail, setUserEmail] = useState(""); const [lang, setLang] = useState(() => localStorage.getItem("wl-lang") || "nb"); + const [displayName, setDisplayName] = useState(""); + const [displayNameSaving, setDisplayNameSaving] = useState(false); + const [displayNameSaved, setDisplayNameSaved] = useState(false); + const [displayNameError, setDisplayNameError] = useState(null); useEffect(() => { supabase.auth.getUser().then(({ data: { user } }) => { if (user?.email) setUserEmail(user.email); }); + fetchDisplayName().then(name => { if (name) setDisplayName(name); }).catch(() => {}); }, []); + async function handleDisplayNameSave() { + setDisplayNameSaving(true); + setDisplayNameSaved(false); + setDisplayNameError(null); + try { + await updateDisplayName(displayName); + setDisplayNameSaved(true); + } catch { + setDisplayNameError(t("settings.displayNameError")); + } finally { + setDisplayNameSaving(false); + } + } + function handleLangChange(val) { setLang(val); i18n.changeLanguage(val); @@ -153,6 +173,33 @@ export default function Settings() { }}> {userEmail}

+
+ { setDisplayName(e.target.value); setDisplayNameSaved(false); }} + /> + {displayNameSaved && ( +

+ {t("settings.displayNameSaved")} +

+ )} + {displayNameError && ( + + )} + +
diff --git a/app/src/lib/db.js b/app/src/lib/db.js index 1502143..425a812 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -262,7 +262,7 @@ export async function fetchSessionsByDate(dateStr) { const { data, error } = await supabase .from("sessions") .select(` - id, session_date, created_at, + id, session_date, created_at, visibility, gym_calendar_id, gym_calendar(name, start_time), session_exercises( id, name, standard_name, sets, reps, position, @@ -376,6 +376,61 @@ export async function updateSession(sessionId, exercises, gymCalendarId, { repla if (error) throw error; } +export async function updateSessionVisibility(sessionId, visibility) { + const { error } = await supabase + .from("sessions") + .update({ visibility }) + .eq("id", sessionId); + if (error) throw error; +} + +// ── CLASS HISTORY ───────────────────────────────────────────────────── + +export async function fetchClassHistory(gymCalendarId) { + const { data: { user } } = await supabase.auth.getUser(); + const { data, error } = await supabase + .from("sessions") + .select(` + id, session_date, trainer_id, + profiles(display_name), + session_exercises( + id, name, sets, reps, + muscle_activations(muscle_id, activation_type) + ) + `) + .eq("gym_calendar_id", gymCalendarId) + .eq("visibility", "shared") + .neq("trainer_id", user?.id ?? "") + .order("session_date", { ascending: false }); + if (error) throw error; + return data ?? []; +} + +// ── PROFILES ────────────────────────────────────────────────────────── + +export async function fetchDisplayName() { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return null; + const { data, error } = await supabase + .from("profiles") + .select("display_name") + .eq("id", user.id) + .maybeSingle(); + if (error) throw error; + return data?.display_name ?? null; +} + +export async function updateDisplayName(displayName) { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("Not authenticated"); + const trimmed = displayName.trim(); + const { error } = await supabase + .from("profiles") + .update({ display_name: trimmed || null }) + .eq("id", user.id); + if (error) throw error; +} + // ── USER GYMS ───────────────────────────────────────────────────────── export const DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8;