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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to Workout Lens are documented here.

## [1.2.8] — 2026-05-14

### Added
- **Gym-wide shared templates and exercise library** — `session_templates` and `exercise_library` are now fully shared across co-instructors at the same gym. Any instructor can create, edit, rename, and delete any template or exercise. `user_id` is retained as "created by" for attribution only. Creator name ("Av [name]") is shown on template cards and exercise rows in Bibliotek when the item was created by a colleague. Bibliotek "Mine maler" tab renamed to "Maler".
- **RLS migration (`gym_wide_templates_and_exercises`)** — replaced `auth.uid() = user_id` all-ops policies on `session_templates`, `session_template_exercises`, and `exercise_library` with gym-aware policies using the same-gym `user_gyms` EXISTS subquery pattern already used for sessions. INSERT still requires `auth.uid() = user_id`; SELECT/UPDATE/DELETE allow any co-instructor at the same gym.

### Note
Editing an exercise's muscle mapping does **not** retroactively update historical session data. `muscle_activations` rows are permanent snapshots written at log time with no FK to `exercise_library`.

## [1.2.7] — 2026-05-13

### Developer
Expand Down
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Fully migrated to IBM Carbon Design System (issue #8, resolved 2026-04-29).
- `Login.jsx` → Carbon `TextInput`, `Button`, `InlineNotification`, `Email` icon; `getDailyQuote()` renders a date-aware motivational quote below the subtitle — English only (hardcoded; language preference is unknown before login); keyed by `MM-DD` for special dates (`01-01`, `12-24`), falls back to a per-weekday quote; 13px italic `var(--cds-text-secondary)`
- `MuscleMap.jsx` → Carbon `Header` + `HeaderGlobalBar` (with `RecentlyViewed` history nav, `Book` library nav, light/dark toggle), `ProgressIndicator` (horizontal stepper with step labels), `Button`, `Tag`, `InlineLoading`, `InlineNotification`; dashed-border dropzone on upload step; sticky action bar on confirm step; exercise rows delegated to `ExerciseRow`
- `History.jsx` → `SectionLabel` + `PageHeading` hero (context-aware: default shows month count; filter active + date selected shows "N av total økter den dato"; filter active + no date shows month count with "med disse filtrene"); `PageHeading` has `minHeight: 72` to prevent layout shift; muscle filter chips use `flexWrap: wrap` (all always visible); `borderBottom` separator below chip section; session rows always have 3px left strip (accent when filter-matched); session title in Cond 700; custom `MonthGrid` calendar; expanded sessions are always editable — per-session edit state in a `Map<sessionId, editState>` (no global `editMode` boolean); a dirty-state Save / Discard bar appears when changes are detected; "Legg til øvelse manuelt" (`Add` icon) and "Last opp nytt bilde" (`Camera` icon) rendered as sibling `Button kind="ghost"` on one row below the exercise list; session header chips capped at 2 visible with `+N` overflow span; library exercises pre-fetched on mount (not on first expand) to ensure autocomplete is ready when user adds first exercise to a session with 0 exercises; exercise rows delegated to `ExerciseRowWithAutocomplete`; all date formatting via `Intl.DateTimeFormat` driven by `i18n.language`
- `Bibliotek.jsx` → custom pill tab strip (replaces Carbon `Tabs`; keyboard ArrowLeft/ArrowRight); tabs: "Øvelser" and "Mine maler"; `PageHeading` hero; live search input on exercises tab with load-more (batches of 20 when >20 shown); "Ny øvelse" button below search input; no Snarveier carousel; search input on templates tab with load-more (batches of 12 when >12); template cards show exercise count + muscle count only (no `used_at` date); exercise rows use `AccentChip` for primary muscles + Cond 700 name + 3px accent left strip; template cards use `borderRadius: var(--r-card)`; exercise form via `ExerciseForm`
- `Bibliotek.jsx` → custom pill tab strip (replaces Carbon `Tabs`; keyboard ArrowLeft/ArrowRight); tabs: "Øvelser" and "Maler"; `PageHeading` hero; live search input on exercises tab with load-more (batches of 20 when >20 shown); "Ny øvelse" button below search input; no Snarveier carousel; search input on templates tab with load-more (batches of 12 when >12); template cards show exercise count + muscle count only (no `used_at` date); exercise rows use `AccentChip` for primary muscles + Cond 700 name + 3px accent left strip; template cards use `borderRadius: var(--r-card)`; exercise form via `ExerciseForm`; creator attribution ("Av [name]") shown on exercise rows and template cards when the item was created by a different co-instructor
- `TemplatePicker.jsx` → Carbon `Button`, `InlineLoading`, `InlineNotification`
- `TemplateSessionEditor.jsx` → `layer-02` + 2px accent top border container; `SectionLabel renderIcon={Edit}` header; Carbon `TextInput` for template name (inline rename); step indicator in use mode ("Steg 2 av 3"); no "Lagre mal" in use mode; body map via `BodyPanel`; exercise rows via `ExerciseRowWithAutocomplete`; library search via `LibraryPicker`
- `MuscleMap.jsx` confirm step → wrapped in `layer-02` + 2px accent top border container; `SectionLabel renderIcon={Edit}` header; Carbon `DatePicker`/`DatePickerInput` for session date (defaults to today, max = today)
Expand Down Expand Up @@ -209,6 +209,20 @@ week_plan_days

`week_plan_days.template_id` nullable — an empty slot is a valid row with `template_id = null`. RLS on both tables restricts all operations to the owning user (`auth.uid() = user_id` / exists check via join).

## Gym-wide shared templates and exercise library (2026-05-14)

`session_templates` and `exercise_library` are **gym-wide**: any co-instructor at the same gym (via `user_gyms` join) can SELECT, INSERT, UPDATE, and DELETE. `user_id` is retained on both tables as "created by" for attribution display only — it is no longer an ownership gate.

RLS policies replaced (migration `gym_wide_templates_and_exercises`):
- Old: `auth.uid() = user_id` (ALL ops) on all three tables
- New: separate INSERT policy (`auth.uid() = user_id`) + SELECT/UPDATE/DELETE policies using the same-gym EXISTS subquery already used for sessions; `session_template_exercises` uses a JOIN via `session_templates.user_id`

`db.js` changes: removed `.eq("user_id", user.id)` defensive filters from `updateTemplateName`, `deleteTemplate`, `touchTemplate`, `updateLibraryExercise`, `deleteLibraryExercise`; added `profiles!user_id(display_name)` join to `fetchTemplates` and `fetchLibraryExercises`.

**FK pitfall (migration `rewire_user_id_fk_to_profiles`):** `session_templates.user_id` and `exercise_library.user_id` originally referenced `auth.users(id)`. PostgREST cannot traverse `auth.users → profiles` so the `profiles!user_id(display_name)` join failed at runtime. Both FKs were rewired to reference `profiles(id)` instead — matching the pattern used by `sessions.trainer_id`. Do not change these back to `auth.users`.

**Editing an exercise does NOT rewrite historical sessions.** `muscle_activations` rows are permanent snapshots written at log time with no FK to `exercise_library`. Correcting a muscle mapping in the library only affects future sessions.

`db.js` functions:
| Function | Description |
|---|---|
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus
5. **Recommendations** — ask Claude what to train next based on untrained muscle groups
6. **Save** — session is persisted to Supabase with full exercise and muscle activation data
7. **History** — custom month grid calendar with heat colors per day (darker = more exercises); click a day to see that session's muscle map and exercise list; sessions are always editable when expanded — a Save / Discard bar appears automatically when changes are detected; add exercises with library autocomplete and AI muscle inference; upload a new photo at any time to re-analyse
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
8. **Library** — shared gym-wide exercise library and session templates: any co-instructor can create, edit, or delete exercises and templates; AI muscle inference fires when you type an exercise name; creator attribution ("Av [name]") shown on items created by colleagues
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) + 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)
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@
"sectionLabel": "LIBRARY",
"heading": "Your building blocks.",
"tabExercises": "Exercises",
"tabTemplates": "My templates",
"tabTemplates": "Templates",
"createdBy": "By {{name}}",
"newExercise": "New exercise",
"searchPlaceholder": "Search exercise…",
"loadingExercises": "Loading exercises…",
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/fa/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@
"sectionLabel": "کتابخانه",
"heading": "بلوک‌های سازنده شما.",
"tabExercises": "تمرین‌ها",
"tabTemplates": "قالب‌های من",
"tabTemplates": "قالب‌ها",
"createdBy": "توسط {{name}}",
"newExercise": "تمرین جدید",
"searchPlaceholder": "جستجوی تمرین…",
"loadingExercises": "در حال بارگذاری تمرین‌ها…",
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/nb/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@
"sectionLabel": "BIBLIOTEK",
"heading": "Dine byggeklosser.",
"tabExercises": "Øvelser",
"tabTemplates": "Mine maler",
"tabTemplates": "Maler",
"createdBy": "Av {{name}}",
"newExercise": "Ny øvelse",
"searchPlaceholder": "Søk øvelse…",
"loadingExercises": "Laster øvelser…",
Expand Down
13 changes: 13 additions & 0 deletions app/src/components/Bibliotek.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { Add, TrashCan, Edit as EditIcon, ChevronRight, Search } from "@carbon/icons-react";
import { useTranslation } from "react-i18next";
import { logDevError } from "../lib/utils";
import { supabase } from "../lib/supabase";
import PageShell, { SectionLabel, PageHeading, AccentChip } from "./PageShell";
import {
fetchLibraryExercises, saveLibraryExercise, updateLibraryExercise, deleteLibraryExercise,
Expand Down Expand Up @@ -44,8 +45,10 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
const [exVisible, setExVisible] = useState(20);
const [tplSearch, setTplSearch] = useState("");
const [tplVisible, setTplVisible] = useState(12);
const [currentUserId, setCurrentUserId] = useState(null);

useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => setCurrentUserId(user?.id ?? null));
fetchLibraryExercises()
.then(setExercises)
.catch(e => { logDevError("Bibliotek/fetchExercises", e); setExError(e.message); })
Expand Down Expand Up @@ -280,6 +283,11 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
<span style={{ fontSize: 11, color: "var(--text-muted-wl)" }}>{t("bibliotek.noMuscles")}</span>
)}
</div>
{currentUserId && ex.user_id !== currentUserId && ex.profiles?.display_name && (
<div style={{ fontSize: 11, color: "var(--cds-text-secondary)", fontFamily: "var(--cds-font-mono)", marginTop: 3 }}>
{t("bibliotek.createdBy", { name: ex.profiles.display_name })}
</div>
)}
</div>
{(ex.default_sets && ex.default_reps) && (
<span style={{ fontSize: 11, color: "var(--text-muted-wl)", flexShrink: 0, fontFamily: "var(--cds-font-mono)" }}>
Expand Down Expand Up @@ -408,6 +416,11 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
<div style={{ fontSize: 11, color: "var(--text-muted-wl)", fontFamily: "var(--cds-font-mono)", letterSpacing: "0.06em", textTransform: "uppercase" }}>
{t("bibliotek.exerciseCount", { count: exCount })} · {muscleCount} MUS
</div>
{currentUserId && tpl.user_id !== currentUserId && tpl.profiles?.display_name && (
<div style={{ fontSize: 11, color: "var(--cds-text-secondary)", fontFamily: "var(--cds-font-mono)", marginTop: 3 }}>
{t("bibliotek.createdBy", { name: tpl.profiles.display_name })}
</div>
)}
</div>
<Button kind="ghost" hasIconOnly renderIcon={ChevronRight}
iconDescription={t("bibliotek.deleteTemplateTitle")} size="sm"
Expand Down
21 changes: 6 additions & 15 deletions app/src/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { toIsoDate, weekIsoToMonday, toWeekIso } from "./utils";
export async function fetchLibraryExercises() {
const { data, error } = await supabase
.from("exercise_library")
.select("*")
.select("*, profiles!user_id(display_name)")
.order("name", { ascending: true });
if (error) throw error;
return data;
Expand All @@ -24,12 +24,10 @@ export async function saveLibraryExercise({ name, primary_muscles, secondary_mus
}

export async function updateLibraryExercise(id, { name, primary_muscles, secondary_muscles, default_sets, default_reps }) {
const { data: { user } } = await supabase.auth.getUser();
const { data, error } = await supabase
.from("exercise_library")
.update({ name, primary_muscles, secondary_muscles, default_sets, default_reps })
.eq("id", id)
.eq("user_id", user.id)
.select()
.single();
if (error) throw error;
Expand All @@ -46,14 +44,12 @@ export async function fetchTemplateNamesUsingExercise(exerciseId) {
}

export async function deleteLibraryExercise(id) {
const { data: { user } } = await supabase.auth.getUser();
// Remove from any templates that reference this exercise
await supabase.from("session_template_exercises").delete().eq("library_exercise_id", id);
const { error } = await supabase
.from("exercise_library")
.delete()
.eq("id", id)
.eq("user_id", user.id);
.eq("id", id);
if (error) throw error;
}

Expand All @@ -63,7 +59,8 @@ export async function fetchTemplates() {
const { data, error } = await supabase
.from("session_templates")
.select(`
id, name, sort_order, used_at, created_at,
id, name, sort_order, used_at, created_at, user_id,
profiles!user_id(display_name),
session_template_exercises(
id, library_exercise_id, name, primary_muscles, secondary_muscles, sets, reps, sort_order
)
Expand All @@ -90,35 +87,29 @@ export async function saveTemplate(name) {
}

export async function updateTemplateName(id, name) {
const { data: { user } } = await supabase.auth.getUser();
const { data, error } = await supabase
.from("session_templates")
.update({ name })
.eq("id", id)
.eq("user_id", user.id)
.select()
.single();
if (error) throw error;
return data;
}

export async function deleteTemplate(id) {
const { data: { user } } = await supabase.auth.getUser();
const { error } = await supabase
.from("session_templates")
.delete()
.eq("id", id)
.eq("user_id", user.id);
.eq("id", id);
if (error) throw error;
}

export async function touchTemplate(id) {
const { data: { user } } = await supabase.auth.getUser();
const { error } = await supabase
.from("session_templates")
.update({ used_at: new Date().toISOString() })
.eq("id", id)
.eq("user_id", user.id);
.eq("id", id);
if (error) throw error;
}

Expand Down
Loading