Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions app/public/locales/fa/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@
"reanalyze": "تحلیل مجدد",
"analyzing": "در حال تحلیل…",
"editSession": "ویرایش جلسه",
"shareWithColleagues": "اشتراک با مربیان دیگر",
"shareOn": "اشتراکی",
"shareOff": "خصوصی",
"classHistory": "همکاران در این کلاس",
"classHistoryLoading": "در حال بارگذاری جلسات همکاران…",
"classHistoryError": "بارگذاری جلسات همکاران ممکن نشد",
"classHistoryInstructor": "مربی",
"ownTraining": "تمرین شخصی",
"exerciseCount_one": "{{count}} تمرین",
"exerciseCount_other": "{{count}} تمرین",
Expand Down Expand Up @@ -268,6 +275,11 @@
"darkThemeOn": "روشن",
"account": "حساب کاربری",
"signOut": "خروج",
"displayNameLabel": "نام نمایشی",
"displayNamePlaceholder": "مثلاً کریستوفر",
"displayNameSave": "ذخیره نام",
"displayNameSaved": "نام ذخیره شد",
"displayNameError": "ذخیره نام ممکن نشد",
"about": "درباره برنامه",
"changelog": "نمایش تاریخچه تغییرات",
"contact": "تماس",
Expand Down
12 changes: 12 additions & 0 deletions app/public/locales/nb/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
96 changes: 91 additions & 5 deletions app/src/components/History.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -188,13 +188,16 @@ 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(() => {
fetchSessions()
.then(setSessions)
.catch(e => logDevError("History/fetchSessions", e))
.finally(() => setLoading(false));
fetchDisplayName().then(setMyDisplayName).catch(() => {});
}, []);

useEffect(() => {
Expand Down Expand Up @@ -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;
});
};
Expand Down Expand Up @@ -602,6 +621,23 @@ export default function History({ initialDate }) {
)
)}

{/* Visibility toggle — always visible, auto-saves instantly */}
<Toggle
id={`visibility-${session.id}`}
labelText={t("history.shareWithColleagues")}
labelA={t("history.shareOff")}
labelB={t("history.shareOn")}
toggled={(session.visibility ?? "shared") === "shared"}
onToggle={(checked) => {
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 */}
<BodyPanel
primary={sessionMuscles.primary}
Expand Down Expand Up @@ -683,7 +719,13 @@ export default function History({ initialDate }) {
</Button>
</>
) : (
(session.session_exercises || []).map(ex => {
<>
{myDisplayName && (
<p style={{ fontFamily: "var(--cond)", fontWeight: 700, fontSize: 13, color: "var(--cds-text-primary)", margin: "0 0 8px", borderInlineStart: "3px solid var(--accent)", paddingInlineStart: 8 }}>
{myDisplayName}
</p>
)}
{(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 (
<div key={ex.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "5px 0", fontSize: 13, borderBottom: "1px solid var(--border-subtle-wl)", color: "var(--cds-text-primary)" }}>
Expand All @@ -699,7 +741,8 @@ export default function History({ initialDate }) {
)}
</div>
);
})
})}
</>
)}
</div>

Expand Down Expand Up @@ -740,6 +783,49 @@ export default function History({ initialDate }) {
</div>
)}

{/* 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 (
<div style={{ marginBottom: 12 }}>
<InlineLoading description={t("history.classHistoryLoading")} />
</div>
);
if (ch.error) return (
<InlineNotification kind="error" title={t("history.classHistoryError")} hideCloseButton style={{ marginBottom: 12 }} />
);
if (!ch.sessions.length) return null;
return (
<div style={{ background: "var(--cds-layer-01)", border: "1px solid var(--border-subtle-wl)", padding: 14, marginBottom: 12 }}>
<p style={{ fontSize: 11, color: "var(--text-muted-wl)", letterSpacing: "2px", marginBottom: 10, fontFamily: "var(--cds-font-mono)", textTransform: "uppercase" }}>
{t("history.classHistory")}
</p>
{ch.sessions.map(cs => {
const name = cs.profiles?.display_name || t("history.classHistoryInstructor");
const exs = (cs.session_exercises || []).filter(e => e.name);
return (
<div key={cs.id} style={{ marginBottom: 10, paddingBottom: 10, borderBottom: "1px solid var(--border-subtle-wl)" }}>
<p style={{ fontFamily: "var(--cond)", fontWeight: 700, fontSize: 13, color: "var(--cds-text-primary)", margin: "0 0 6px", borderInlineStart: "3px solid var(--accent)", paddingInlineStart: 8 }}>
{name}
</p>
{exs.map(ex => (
<div key={ex.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "3px 0", fontSize: 13, color: "var(--cds-text-secondary)" }}>
<span>{ex.name}</span>
{(ex.sets || ex.reps) && (
<span style={{ color: "var(--text-muted-wl)", fontFamily: "var(--cds-font-mono)", fontSize: 12 }}>
{[ex.sets && `${ex.sets}×`, ex.reps].filter(Boolean).join("")}
</span>
)}
</div>
))}
</div>
);
})}
</div>
);
})()}

{/* Edit mode actions */}
{isEditing && (
<>
Expand Down
Loading
Loading