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 */}
+
+ {myDisplayName}
+
+ {t("history.classHistory")}
+
+ {name}
+
+ {t("settings.displayNameSaved")} +
+ )} + {displayNameError && ( +