From 3e99edb81cbe0e25d52e95acec65bee013d1c053 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 01:05:20 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20UI/UX=20overhaul=20=E2=80=94=20ed?= =?UTF-8?q?it=20consistency,=20always-on=20history=20editing,=20visibility?= =?UTF-8?q?=20removal,=20report=20restructure,=20library=20scaling=20(#147?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 +- CLAUDE.md | 16 +- README.md | 4 +- app/public/locales/en/translation.json | 28 +- app/public/locales/fa/translation.json | 28 +- app/public/locales/nb/translation.json | 28 +- app/src/components/Bibliotek.jsx | 208 +++++--- app/src/components/ExerciseForm.jsx | 20 +- app/src/components/History.jsx | 482 +++++++++---------- app/src/components/MuscleMap.jsx | 7 +- app/src/components/PageShell.jsx | 10 +- app/src/components/Report.jsx | 249 ++++------ app/src/components/Settings.jsx | 9 + app/src/components/TemplateSessionEditor.jsx | 114 ++--- app/src/lib/db.js | 11 +- 15 files changed, 625 insertions(+), 599 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ee92b..78e2562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,17 @@ 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. +- **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 sessions. `fetchClassHistory(gymCalendarId)` added to `db.js`. - **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. +- **GDPR transparency note** — Settings → Konto now shows an informational paragraph explaining that all logged sessions are visible to co-instructors at the same gym, in line with the app's purpose. ### Changed +- **Session visibility removed** — the `visibility` / "Del med andre instruktører" toggle has been removed entirely. All sessions logged under a gym are now always visible to co-instructors at the same gym (the intended behaviour). The Supabase RLS policy on `sessions` was updated to remove the `visibility = 'shared'` filter; all existing private sessions were backfilled to shared. `updateSessionVisibility` removed from `db.js`. +- **History — always-on inline editing** — sessions are always editable when expanded; the "Rediger økt" button and locked read state are gone. A sticky Save / Discard / Reupload bar appears automatically when any change is detected (dirty state). Fixes the filter+edit bug where an active muscle filter prevented entering edit mode. The muscle groups section (redundant with the body map) is removed from the expanded view. "Re-analyser" renamed to "Last opp nytt bilde". +- **Edit panel visual consistency (#147)** — all edit/entry containers now share the same surface treatment: `var(--cds-layer-02)` background + 2px `var(--accent)` top border + `SectionLabel` with icon header. Applies to `ExerciseForm`, `TemplateSessionEditor`, and the MuscleMap confirm step. Cancel buttons changed to `kind="ghost"`, errors shown as `InlineNotification kind="error"` above the button bar. `SectionLabel` now accepts a `renderIcon` prop. +- **Template use flow** — "Lagre mal" is no longer shown in the template use flow (Planlegger → Bruk økt). A step indicator ("Steg 2 av 3 — Tilpass øvelser") is shown instead. Template name input replaced with Carbon `TextInput`. +- **Report — restructured layout** — the "Ikke trent" gap card is now positioned after the muscle frequency table, directly above the recommendation button, acting as a visual header for the recommendation section. The post-recommendation body map (`BodySVG`) is removed. Fallback messages added: if all primary muscles are trained the gap section shows a success message; if some are secondary-only only those are listed. +- **Library — scaling** — the Snarveier carousel is capped at 6 items with a "Se alle →" link to the templates tab. Load-more buttons (20 exercises / 12 templates per batch) appear when lists exceed their threshold. A search input is added to the templates tab. - **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 40f4e7f..0dd5e5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,16 +51,16 @@ Fully migrated to IBM Carbon Design System (issue #8, resolved 2026-04-29). - `app/src/theme.jsx` — `ThemeProvider` sets `data-theme="g10"` or `data-theme="g100"` on ``, persists to `localStorage`, respects `prefers-color-scheme`, defaults to g100 (dark) - `Login.jsx` → Carbon `TextInput`, `Button`, `InlineNotification`, `Email` icon; `getDailyQuote()` renders a date-aware motivational quote below the subtitle — keyed by `MM-DD` for special dates (`01-01`, `05-05`, `05-17`, `12-24`), falls back to a per-weekday quote (mandag–søndag); 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; edit mode uses `Edit`, `Camera`, `Add`, `Renew` icons; exercise rows delegated to `ExerciseRow`; all date formatting via `Intl.DateTimeFormat` driven by `i18n.language` -- `Bibliotek.jsx` → custom pill tab strip (replaces Carbon `Tabs`; keyboard ArrowLeft/ArrowRight); `PageHeading` hero; live search input on exercises tab; Snarvei template carousel; exercise rows use `AccentChip` for primary muscles + Cond 700 name + 3px accent left strip; template cards use `borderRadius: var(--r-card)`; "Ny øvelse" button renders above Snarveier carousel to prevent tab-switch layout shift; exercise form via `ExerciseForm` +- `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` (no global `editMode` boolean); a dirty-state Save / Discard / Reupload bar appears when changes are detected; `Camera`, `Add`, `Renew` icons; 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); `PageHeading` hero; live search input on exercises tab with load-more (batches of 20 when >20 shown); Snarvei template carousel (capped at 6, "Se alle →" link to templates tab); search input on templates tab with load-more (batches of 12 when >12); exercise rows use `AccentChip` for primary muscles + Cond 700 name + 3px accent left strip; template cards use `borderRadius: var(--r-card)`; "Ny øvelse" button renders above Snarveier carousel to prevent tab-switch layout shift; exercise form via `ExerciseForm` - `TemplatePicker.jsx` → Carbon `Button`, `InlineLoading`, `InlineNotification` -- `TemplateSessionEditor.jsx` → Carbon `Button`, `Tag`, `InlineNotification`, `InlineLoading`; body map via `BodyPanel`; exercise rows via `ExerciseRow`; library search via `LibraryPicker` -- `MuscleMap.jsx` confirm step → Carbon `DatePicker`/`DatePickerInput` for session date (defaults to today, max = today) +- `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) - `BodySVG` / `HeatmapBodySVG` muscle highlights: primary → `var(--heat-4)` solid green, secondary → diagonal blue hatch (`#001d6c` base + `#4589ff` lines). `HeatmapBodySVG` accepts `onHover(id|null)` and `hovered` props — when `onHover` is provided the internal floating tooltip is suppressed and the caller manages the detail card. - `Home.jsx` → `SectionLabel` + `PageHeading` headings; last session card with gym-class identity hero; 7-day weekly strip with heat colors — clicking a day that has a session navigates to History pre-selected on that date; `fetchThisWeekSessions` in `db.js` -- `Report.jsx` → `SectionLabel` eyebrow with period + active day filters on two separate `display:block` spans; two-line Cond 700 hero (untrained count in magenta + "aldri trent."); three separate `flexWrap: wrap` filter rows (period / weekdays / session types) with `1px solid var(--border-subtle-wl)` top borders between groups; "Nullstill filter" always rendered (opacity-toggled); gap callout card uses `var(--accent-bg-08)` with `AccentChip` per untrained muscle; recommendation rows have 3px accent left strip + round `+` button that saves the exercise inline via `saveLibraryExercise` (no navigation away); on success button becomes a disabled `Checkmark` icon (grayed out, stays that way); Postgres 23505 duplicate treated as success; save errors show an `InlineNotification kind="error"` above the recs list; `savedRecs` (Set), `savingRec`, `saveRecError` state tracks per-row state; `StickyCta` "Disse bør du legge inn i programmet →"; prefill prop applied on mount via `useRef` — supports `periodDays`, `selectedDays`, `selectedTypes`, `weekday` (pre-selects the weekday chip), and `sessionType` (pre-selects the session type chip); `KpiTile` (42px Plex Light value); `muscleLastDate` in useMemo +- `Report.jsx` → `SectionLabel` eyebrow with period + active day filters on two separate `display:block` spans; three separate `flexWrap: wrap` filter rows (period / weekdays / session types) with `1px solid var(--border-subtle-wl)` top borders between groups; "Nullstill filter" always rendered (opacity-toggled); KPI tiles → heatmap body → hover detail → heat legend → frequency table → gap callout card (with `AccentChip` per untrained muscle) → recommendation button → recs list (no post-rec body map); when all primary muscles trained shows positive fallback message; when some muscles secondary-only shows those as blue tags; recommendation rows have 3px accent left strip + round `+` button that saves the exercise inline via `saveLibraryExercise`; `StickyCta` "Disse bør du legge inn i programmet →"; prefill prop applied on mount via `useRef` — supports `periodDays`, `selectedDays`, `selectedTypes`, `weekday`, `sessionType`; `KpiTile` (42px Plex Light value); `muscleLastDate` in useMemo - `History.jsx` → custom `MonthGrid` (7-column CSS grid, heat fill, today/selected outlines, month nav); `sessionCountMap` useMemo; `SectionLabel` + `PageHeading` at top; removed `react-day-picker` dependency entirely -- `PageShell.jsx` → exports: `SectionLabel` (mono 12px, 0.16em tracking, 3px `var(--accent)` left border), `PageHeading` (Cond 700 28px), `PageTitle` (alias for SectionLabel), `AccentChip` (magenta pill: `var(--accent-bg-14)` bg, `var(--accent-soft)` text), `StickyCta` (sticky bottom bar with top border), `BackButton`; `NavBtn` active state: 2px `var(--accent)` bottom border + `var(--cds-layer-01)` background; nav icons in order: Camera → RecentlyViewed → Analytics → EventSchedule (Planlegger) → Book (Bibliotek) → Settings — 6 icons, each 48px wide; theme toggle and logout removed from header (now in Settings view); `ChangelogModal` no longer rendered here +- `PageShell.jsx` → exports: `SectionLabel` (mono 12px, 0.16em tracking, 3px `var(--accent)` left border; accepts optional `renderIcon` prop — renders the Carbon icon at 14px before the label text), `PageHeading` (Cond 700 28px), `PageTitle` (alias for SectionLabel), `AccentChip` (magenta pill: `var(--accent-bg-14)` bg, `var(--accent-soft)` text), `StickyCta` (sticky bottom bar with top border), `BackButton`; `NavBtn` active state: 2px `var(--accent)` bottom border + `var(--cds-layer-01)` background; nav icons in order: Camera → RecentlyViewed → Analytics → EventSchedule (Planlegger) → Book (Bibliotek) → Settings — 6 icons, each 48px wide; theme toggle and logout removed from header (now in Settings view); `ChangelogModal` no longer rendered here - `carbon-tokens.css` → added `--heat-1..5` green scale (#044317 → #42be65); WL custom tokens: `--accent` (#ee2c80 magenta), `--surface-card`, `--border-subtle-wl`, `--text-muted-wl`, `--accent-bg-08/14/30`, `--accent-soft`, `--r-card` (16px), `--r-pill` (999px), `--r-tile` (10px), `--cond` (IBM Plex Sans Condensed); g10 light-mode overrides for all WL tokens - `app.css` → global `html, body { overflow-x: hidden }` to prevent horizontal viewport bleed from chip rows; do not use `overflow: hidden` on direct parents of `flexWrap: wrap` chip containers — it clips instead of scrolling - Removed: Bebas Neue, DM Sans, Google Fonts import, custom `C` token objects, all raw hex colors, rounded corners, `react-day-picker`, `date-fns` @@ -200,8 +200,8 @@ week_plan_days - **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. +- **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. +- **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 - 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 3cc0c45..bb91f90 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus 4. **Muscle map** — front and back body SVG; primary muscles glow solid green, secondary muscles show as blue diagonal stripes; hover for exercise names 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; edit or re-analyse any saved session; edit mode supports library autocomplete — type an exercise name to get suggestions from your library; AI muscle inference fires automatically when you add a new exercise with no muscles assigned (tab or click away from the name field) +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 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 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 +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). All sessions are always visible to co-instructors at the same gym — this cross-instructor transparency is the core value of the shared view ## Tech stack diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index 00b7e4a..53a6b29 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -23,7 +23,8 @@ "front": "Front", "back_view": "Back", "saveFailed": "Saving failed. Try again.", - "add": "Add" + "add": "Add", + "discard": "Discard" }, "muscles": { "chest": "Chest", @@ -151,7 +152,8 @@ "savingError": "Saving failed", "progressLabel": "Progress", "primaryTag": "Primary", - "secondaryTag": "Secondary" + "secondaryTag": "Secondary", + "confirmLabel": "Confirm session" }, "history": { "sectionLabel": "HISTORY", @@ -163,11 +165,9 @@ "primaryCount": "Primary ({{count}})", "secondaryCount": "Secondary ({{count}})", "reanalyze": "Re-analyze", + "reuploadPhoto": "Upload new photo", "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", @@ -228,7 +228,10 @@ "usedInTemplates_one": "The exercise is used in the template", "usedInTemplates_other": "The exercise is used in the templates", "exerciseRemovedWarning": "and will be removed from it.", - "exerciseCount": "{{count}} EX" + "exerciseCount": "{{count}} EX", + "searchTemplates": "Search templates…", + "showMore": "Show {{count}} more", + "seeAll": "See all" }, "planlegger": { "heading": "Plan the week", @@ -292,7 +295,8 @@ "languagePersian": "فارسی", "myGym": "My gym", "myGymMembership": "Sporty Thon Senter Ski", - "myGymFutureHint": "Coming soon: choose your gym and see shared session history with other instructors." + "myGymFutureHint": "Coming soon: choose your gym and see shared session history with other instructors.", + "dataSharingNote": "All sessions you log are visible to other instructors at the same gym. This is necessary to give you insight into what your colleagues are training, and is part of the service's purpose." }, "report": { "heroMuscles_one": "{{count}} muscle", @@ -319,6 +323,8 @@ "noSessions": "No sessions found for selected filter.", "saveRecError": "Could not save exercise. Try again.", "fetchRecError": "Could not fetch recommendations. Try again.", + "allMusclesPrimary": "All 17 muscle groups trained this period. Great work!", + "allMusclesSecondaryNote": "All primary muscles trained. These are secondary only:", "toCta": "Add these to your program →", "period": "PERIOD", "activeDays": "ACTIVE DAYS", @@ -356,7 +362,9 @@ "defaultSets": "Default sets", "defaultReps": "Default reps", "saveExercise": "Save exercise", - "noMusclesWarning": "No muscles selected — click the figure to register" + "noMusclesWarning": "No muscles selected — click the figure to register", + "headerNew": "New exercise", + "headerEdit": "Edit exercise" }, "libraryPicker": { "searchLabel": "Search exercise library", @@ -398,6 +406,8 @@ "manual": "Manually", "saveChanges": "Save template changes", "useSession": "Use session", - "saveTemplate": "Save template" + "saveTemplate": "Save template", + "stepIndicator": "Step 2 of 3 — Adjust exercises", + "nameLabel": "Template name" } } diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index 7bc63d7..aeeb8d7 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -23,7 +23,8 @@ "front": "جلو", "back_view": "پشت", "saveFailed": "ذخیره ناموفق بود. دوباره امتحان کنید.", - "add": "افزودن" + "add": "افزودن", + "discard": "لغو تغییرات" }, "muscles": { "chest": "سینه", @@ -151,7 +152,8 @@ "savingError": "ذخیره ناموفق بود", "progressLabel": "پیشرفت", "primaryTag": "اولیه", - "secondaryTag": "ثانویه" + "secondaryTag": "ثانویه", + "confirmLabel": "تأیید جلسه" }, "history": { "sectionLabel": "تاریخچه", @@ -163,11 +165,9 @@ "primaryCount": "اولیه ({{count}})", "secondaryCount": "ثانویه ({{count}})", "reanalyze": "تحلیل مجدد", + "reuploadPhoto": "آپلود عکس جدید", "analyzing": "در حال تحلیل…", "editSession": "ویرایش جلسه", - "shareWithColleagues": "اشتراک با مربیان دیگر", - "shareOn": "اشتراکی", - "shareOff": "خصوصی", "classHistory": "همکاران در این کلاس", "classHistoryLoading": "در حال بارگذاری جلسات همکاران…", "classHistoryError": "بارگذاری جلسات همکاران ممکن نشد", @@ -228,7 +228,10 @@ "usedInTemplates_one": "این تمرین در قالب استفاده شده", "usedInTemplates_other": "این تمرین در قالب‌ها استفاده شده", "exerciseRemovedWarning": "و از آن حذف خواهد شد.", - "exerciseCount": "{{count}} تمرین" + "exerciseCount": "{{count}} تمرین", + "searchTemplates": "جستجو در قالب‌ها…", + "showMore": "نمایش {{count}} بیشتر", + "seeAll": "مشاهده همه" }, "planlegger": { "heading": "برنامه‌ریزی هفته", @@ -292,7 +295,8 @@ "languagePersian": "فارسی", "myGym": "باشگاه من", "myGymMembership": "Sporty Thon Senter Ski", - "myGymFutureHint": "به زودی: انتخاب باشگاه و مشاهده تاریخچه مشترک با سایر مربیان." + "myGymFutureHint": "به زودی: انتخاب باشگاه و مشاهده تاریخچه مشترک با سایر مربیان.", + "dataSharingNote": "تمام جلسات ثبت‌شده برای سایر مربیان در همان مرکز ورزشی قابل مشاهده است. این برای ارائه بینش درباره تمرین همکاران ضروری است." }, "report": { "heroMuscles_one": "{{count}} عضله", @@ -319,6 +323,8 @@ "noSessions": "جلسه‌ای برای فیلتر انتخاب‌شده یافت نشد.", "saveRecError": "ذخیره تمرین ناموفق بود. دوباره امتحان کنید.", "fetchRecError": "دریافت پیشنهادها ناموفق بود. دوباره امتحان کنید.", + "allMusclesPrimary": "همه ۱۷ گروه عضلانی در این دوره تمرین شده. آفرین!", + "allMusclesSecondaryNote": "همه عضلات اولیه تمرین شده. اینها فقط ثانویه تمرین شده‌اند:", "toCta": "این‌ها را به برنامه‌ات اضافه کن →", "period": "دوره", "activeDays": "روزهای فعال", @@ -356,7 +362,9 @@ "defaultSets": "ست پیش‌فرض", "defaultReps": "تکرار پیش‌فرض", "saveExercise": "ذخیره تمرین", - "noMusclesWarning": "هیچ عضله‌ای انتخاب نشده — روی شکل کلیک کنید" + "noMusclesWarning": "هیچ عضله‌ای انتخاب نشده — روی شکل کلیک کنید", + "headerNew": "تمرین جدید", + "headerEdit": "ویرایش تمرین" }, "libraryPicker": { "searchLabel": "جستجو در کتابخانه تمرین‌ها", @@ -398,6 +406,8 @@ "manual": "دستی", "saveChanges": "ذخیره تغییرات قالب", "useSession": "شروع جلسه", - "saveTemplate": "ذخیره قالب" + "saveTemplate": "ذخیره قالب", + "stepIndicator": "مرحله ۲ از ۳ — تنظیم تمرین‌ها", + "nameLabel": "نام قالب" } } diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index ccc0546..3f5370a 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -23,7 +23,8 @@ "front": "Front", "back_view": "Bak", "saveFailed": "Lagring feilet. Prøv igjen.", - "add": "Legg til" + "add": "Legg til", + "discard": "Forkast" }, "muscles": { "chest": "Bryst", @@ -151,7 +152,8 @@ "savingError": "Lagring feilet", "progressLabel": "Fremgang", "primaryTag": "Primær", - "secondaryTag": "Sekundær" + "secondaryTag": "Sekundær", + "confirmLabel": "Bekreft økt" }, "history": { "sectionLabel": "HISTORIKK", @@ -163,11 +165,9 @@ "primaryCount": "Primær ({{count}})", "secondaryCount": "Sekundær ({{count}})", "reanalyze": "Re-analyser", + "reuploadPhoto": "Last opp nytt bilde", "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", @@ -228,7 +228,10 @@ "usedInTemplates_one": "Øvelsen brukes i malen", "usedInTemplates_other": "Øvelsen brukes i malene", "exerciseRemovedWarning": "og vil bli fjernet derfra.", - "exerciseCount": "{{count}} ØV" + "exerciseCount": "{{count}} ØV", + "searchTemplates": "Søk i maler…", + "showMore": "Vis {{count}} til", + "seeAll": "Se alle" }, "planlegger": { "heading": "Planlegg uken", @@ -292,7 +295,8 @@ "languagePersian": "فارسی", "myGym": "Min gym", "myGymMembership": "Sporty Thon Senter Ski", - "myGymFutureHint": "Fremtidig: velg gym og se felles økthistorikk med andre instruktører." + "myGymFutureHint": "Fremtidig: velg gym og se felles økthistorikk med andre instruktører.", + "dataSharingNote": "Alle økter du logger er synlige for andre instruktører ved samme treningssenter. Dette er nødvendig for å gi deg innsikt i hva kollegene dine trener, og er en del av tjenestens formål." }, "report": { "heroMuscles_one": "{{count}} muskel", @@ -319,6 +323,8 @@ "noSessions": "Ingen økter funnet for valgte filter.", "saveRecError": "Kunne ikke lagre øvelsen. Prøv igjen.", "fetchRecError": "Kunne ikke hente anbefalinger. Prøv igjen.", + "allMusclesPrimary": "Alle 17 muskelgrupper er trent i perioden. Bra jobba!", + "allMusclesSecondaryNote": "Alle primærmuskelgrupper er trent. Disse er bare trent sekundært:", "toCta": "Disse bør du legge inn i programmet →", "period": "PERIODE", "activeDays": "AKTIVE DAGER", @@ -356,7 +362,9 @@ "defaultSets": "Standard sett", "defaultReps": "Standard reps", "saveExercise": "Lagre øvelse", - "noMusclesWarning": "Ingen muskler valgt — klikk på figuren for å registrere" + "noMusclesWarning": "Ingen muskler valgt — klikk på figuren for å registrere", + "headerNew": "Ny øvelse", + "headerEdit": "Rediger øvelse" }, "libraryPicker": { "searchLabel": "Søk i øvelsesbiblioteket", @@ -398,6 +406,8 @@ "manual": "Manuelt", "saveChanges": "Lagre endringer i malen", "useSession": "Bruk økt", - "saveTemplate": "Lagre mal" + "saveTemplate": "Lagre mal", + "stepIndicator": "Steg 2 av 3 — Tilpass øvelser", + "nameLabel": "Navn på mal" } } diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index ef1373c..9686bde 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -42,6 +42,9 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { const [newTplName, setNewTplName] = useState(""); const [savingTpl, setSavingTpl] = useState(false); const [showNewTpl, setShowNewTpl] = useState(false); + const [exVisible, setExVisible] = useState(20); + const [tplSearch, setTplSearch] = useState(""); + const [tplVisible, setTplVisible] = useState(12); useEffect(() => { fetchLibraryExercises() @@ -54,12 +57,18 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { .finally(() => setTplLoading(false)); }, []); - const filteredExercises = useMemo( - () => debouncedSearch.trim() + const filteredExercises = useMemo(() => { + const result = debouncedSearch.trim() ? exercises.filter(e => e.name.toLowerCase().includes(debouncedSearch.toLowerCase().trim())) - : exercises, - [exercises, debouncedSearch] - ); + : exercises; + setExVisible(20); + return result; + }, [exercises, debouncedSearch]); + + const filteredTemplates = useMemo(() => { + const q = tplSearch.trim().toLowerCase(); + return q ? templates.filter(t => t.name.toLowerCase().includes(q)) : templates; + }, [templates, tplSearch]); const handleSaveNewExercise = async (fields) => { setSavingEx(true); @@ -194,7 +203,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {t("bibliotek.shortcuts")}

- {templates.map(tpl => { + {templates.slice(0, 6).map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; return ( + )}
)} @@ -263,64 +285,80 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {exSearch.trim() ? t("bibliotek.noSearchResults") : t("bibliotek.noExercises")}

) : ( -
- {filteredExercises.map(ex => ( -
- {editingEx?.id === ex.id ? ( - handleUpdateExercise(ex.id, fields)} - onCancel={() => setEditingEx(null)} - saving={savingEx} - /> - ) : ( -
-
-
- {ex.name} -
-
- {(ex.primary_muscles || []).slice(0, 4).map(id => ( - {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - ))} - {(ex.secondary_muscles || []).slice(0, 3).map(id => ( - - {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - - ))} - {!(ex.primary_muscles?.length) && !(ex.secondary_muscles?.length) && ( - {t("bibliotek.noMuscles")} - )} + <> +
+ {filteredExercises.slice(0, exVisible).map(ex => ( +
+ {editingEx?.id === ex.id ? ( + handleUpdateExercise(ex.id, fields)} + onCancel={() => setEditingEx(null)} + saving={savingEx} + /> + ) : ( +
+
+
+ {ex.name} +
+
+ {(ex.primary_muscles || []).slice(0, 4).map(id => ( + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + ))} + {(ex.secondary_muscles || []).slice(0, 3).map(id => ( + + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + + ))} + {!(ex.primary_muscles?.length) && !(ex.secondary_muscles?.length) && ( + {t("bibliotek.noMuscles")} + )} +
+ {(ex.default_sets && ex.default_reps) && ( + + {ex.default_sets}×{ex.default_reps} + + )} +
- {(ex.default_sets && ex.default_reps) && ( - - {ex.default_sets}×{ex.default_reps} - - )} -
- )} -
- ))} -
+ )} +
+ ))} +
+ {filteredExercises.length > exVisible && ( + + )} + )}
)} @@ -360,15 +398,40 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
)} + {!tplLoading && templates.length > 0 && ( +
+ + { setTplSearch(e.target.value); setTplVisible(12); }} + style={{ + width: "100%", boxSizing: "border-box", + padding: "8px 12px 8px 34px", + background: "var(--surface-card)", + border: "1px solid var(--border-subtle-wl)", + borderRadius: 8, + color: "var(--cds-text-primary)", + fontFamily: "var(--cds-font-sans)", fontSize: 14, + outline: "none", + }} + /> +
+ )} + {tplLoading ? ( - ) : templates.length === 0 && !showNewTpl ? ( + ) : filteredTemplates.length === 0 && !showNewTpl ? (

- {t("bibliotek.noTemplates")} + {tplSearch.trim() ? t("bibliotek.noSearchResults") : t("bibliotek.noTemplates")}

) : ( + <>
- {templates.map(tpl => { + {filteredTemplates.slice(0, tplVisible).map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; const usedAt = tpl.used_at ? new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(new Date(tpl.used_at)) @@ -408,6 +471,21 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { ); })}
+ {filteredTemplates.length > tplVisible && ( + + )} + )} )} diff --git a/app/src/components/ExerciseForm.jsx b/app/src/components/ExerciseForm.jsx index b032594..1d1b7ab 100644 --- a/app/src/components/ExerciseForm.jsx +++ b/app/src/components/ExerciseForm.jsx @@ -1,7 +1,9 @@ import { useState } from "react"; -import { Button, TextInput, InlineLoading } from "@carbon/react"; +import { Button, TextInput, InlineLoading, InlineNotification } from "@carbon/react"; +import { Add, Edit } from "@carbon/icons-react"; import { useTranslation } from "react-i18next"; import MusclePicker from "./MusclePicker"; +import { SectionLabel } from "./PageShell"; import { inferMusclesFromName } from "../lib/utils"; export default function ExerciseForm({ initial, onSave, onCancel, saving }) { @@ -39,7 +41,10 @@ export default function ExerciseForm({ initial, onSave, onCancel, saving }) { const noMuscles = !primary.length && !secondary.length; return ( -
+
+ + {initial?.id ? t("exerciseForm.headerEdit") : t("exerciseForm.headerNew")} + )} {!inferStatus && !aiInferred && noMuscles && name.trim() && ( -

- {t("exerciseForm.noMusclesWarning")} -

+ )}
- +
- {isExpanded && ( + {isExpanded && (() => { + const isDirty = edit.dirty || false; + const isSaving = edit.saving || false; + const gymSessions = edit.gymSessions || []; + const gymSessionId = edit.gymSessionId ?? (session.gym_calendar_id || ""); + const gymConflict = edit.gymConflict; + const isAnalyzing = edit.analyzing || false; + const hasErrors = workExercises && ( + workExercises.some(e => e.enabled && !e.name?.trim()) || + workExercises.some(e => isInvalidNum(e.sets) || isInvalidNum(e.reps)) + ); + return (
- {/* Gym class tag (read) or selector (edit) */} - {isEditing ? ( - editGymSessions.length > 0 && ( - <> - - {editGymCalendarConflict && ( - - )} - - ) - ) : ( - session.gym_calendar && ( -
- {session.gym_calendar.name} -
- ) + {/* Gym class selector (always visible when options exist) */} + {gymSessions.length > 0 ? ( + <> + + {gymConflict && ( + + )} + + ) : session.gym_calendar && ( +
+ {session.gym_calendar.name} +
)} - {/* 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 */} {t("history.secondaryCount", { count: sessionMuscles.secondary.length })}
- {/* Exercise list */} + {/* Exercise list — always editable */}
-

- {t("common.exercises")} -

- - {isEditing ? ( - <> -
- {editExercises.map((ex) => ( - setEditExercises(p => p.map(e => e.id === ex.id ? { ...e, ...updates } : e))} - onDelete={() => setEditExercises(p => p.filter(e => e.id !== ex.id))} - layer="layer-02" - validateNumbers - libraryExercises={libraryExercises} - isNew={newExerciseIds.has(ex.id)} - /> - ))} -
- - - ) : ( - <> - {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 ( -
- - {muscleLabels ? ( - {ex.name} - ) : ex.name} - - {(ex.sets || ex.reps) && ( - - {[ex.sets && `${ex.sets}×`, ex.reps].filter(Boolean).join("")} - - )} -
- ); - })} - + {myDisplayName && !isDirty && ( +

+ {myDisplayName} +

)} + {workExercises && ( +
+ {workExercises.map((ex) => ( + patchSessionEdit(session.id, { + exercises: workExercises.map(e => e.id === ex.id ? { ...e, ...updates } : e), + dirty: true, + })} + onDelete={() => patchSessionEdit(session.id, { + exercises: workExercises.filter(e => e.id !== ex.id), + dirty: true, + })} + layer="layer-02" + validateNumbers + libraryExercises={libraryExercises} + isNew={edit.newExIds?.has(ex.id)} + /> + ))} +
+ )} +
- {/* Muscle groups (read mode only) */} - {!isEditing && ( -
-

- {t("history.muscleGroups")} -

- {sessionMuscles.primary.map(id => { - const exNames = (sessionMuscleMap[id] || []).join(", "); - return ( -
-
- - {exNames ? ( - {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - ) : t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - - {t("common.primary")} -
- ); - })} - {sessionMuscles.secondary.map(id => { - const exNames = (sessionMuscleMap[id] || []).join(", "); - return ( -
-
- - {exNames ? ( - {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - ) : t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - - {t("common.secondary")} -
- ); - })} -
- )} - - {/* Class history (read mode, gym-linked sessions only) */} - {!isEditing && session.gym_calendar_id && (() => { + {/* Class history (gym-linked sessions only) */} + {session.gym_calendar_id && (() => { const ch = classHistory.get(session.gym_calendar_id); if (!ch) return null; if (ch.loading) return ( @@ -826,42 +767,51 @@ export default function History({ initialDate }) { ); })()} - {/* Edit mode actions */} - {isEditing && ( + {/* Hidden file input for photo re-upload */} + { + if (e.target.files[0] && uploadingForSession.current) { + reanalyze(uploadingForSession.current, e.target.files[0]); + } + e.target.value = ""; + uploadingForSession.current = null; + }} /> + + {/* Upload new photo button (always shown) */} + {!isDirty && ( +
+ +
+ )} + + {/* Dirty state: error notifications + save bar */} + {isDirty && ( <> - {analyzeError && ( - + {edit.analyzeError && ( + )} - {editError && ( - + {edit.saveError && ( + )} - { if (e.target.files[0]) reanalyze(e.target.files[0]); e.target.value = ""; }} /> -
- + - -
)} - - {/* Read mode: edit button (hidden when any session is in edit mode) */} - {!editMode && ( - - )}
- )} + ); + })()}
); })} diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index c59228d..708d840 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -9,7 +9,7 @@ import { InlineNotification, InlineLoading, Tag, DefinitionTooltip, } from "@carbon/react"; -import { Add, ArrowLeft, ArrowRight, Renew, Camera, AiRecommend, Close } from "@carbon/icons-react"; +import { Add, ArrowLeft, ArrowRight, Renew, Camera, AiRecommend, Close, Edit } from "@carbon/icons-react"; import ExerciseRowWithAutocomplete from "./ExerciseRowWithAutocomplete"; import BodyPanel from "./BodyPanel"; import PageShell, { SectionLabel, AccentChip, StickyCta } from "./PageShell"; @@ -497,6 +497,10 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {/* ── CONFIRM ── */} {step === "confirm" && ( +
+ + {t("muscleMap.confirmLabel")} +
{/* Hero */} @@ -666,6 +670,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }
+
)} {/* ── RESULTAT ── */} diff --git a/app/src/components/PageShell.jsx b/app/src/components/PageShell.jsx index 16b3a6f..84c825f 100644 --- a/app/src/components/PageShell.jsx +++ b/app/src/components/PageShell.jsx @@ -28,7 +28,7 @@ function NavBtn({ onClick, ariaLabel, active, children }) { ); } -export function SectionLabel({ children, style }) { +export function SectionLabel({ children, style, renderIcon: Icon }) { return (

+ {Icon && } {children}

); } -export function PageTitle({ children }) { - return {children}; +export function PageTitle({ children, renderIcon }) { + return {children}; } export function PageHeading({ children, style }) { diff --git a/app/src/components/Report.jsx b/app/src/components/Report.jsx index b8f94e6..d3189d3 100644 --- a/app/src/components/Report.jsx +++ b/app/src/components/Report.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { fetchSessionsForReport, saveLibraryExercise } from "../lib/db"; -import { HeatmapBodySVG, BodySVG, MUSCLES, useIsMobile } from "../lib/bodymap.jsx"; -import { buildRecMuscleMap, callClaude, logDevError, getIntlLocale } from "../lib/utils"; +import { HeatmapBodySVG, MUSCLES } from "../lib/bodymap.jsx"; +import { callClaude, logDevError, getIntlLocale } from "../lib/utils"; import { CLAUDE_MODEL_TEXT, buildPeriodRecommendPrompt } from "../lib/prompts"; import { Tag, InlineLoading, DefinitionTooltip, Button, InlineNotification, @@ -52,9 +52,7 @@ function KpiTile({ label, value }) { export default function Report({ prefill, onPrefillConsumed }) { const { t } = useTranslation(); - const isMobile = useIsMobile(); const { onShowBibliotek } = useNav(); - const [mobileRecView, setMobileRecView] = useState("front"); const [periodDays, setPeriodDays] = useState(30); const [selectedDays, setSelectedDays] = useState(new Set()); const [selectedTypes, setSelectedTypes] = useState(new Set()); @@ -269,6 +267,10 @@ export default function Report({ prefill, onPrefillConsumed }) { .filter(([, c]) => c.primary === 0) .map(([id]) => id); + const secondaryOnlyMuscles = Object.entries(muscleCounts) + .filter(([, c]) => c.primary === 0 && c.secondary > 0) + .map(([id]) => id); + const frequencyTable = Object.entries(muscleCounts) .map(([id, c]) => ({ id, ...c })) .sort((a, b) => b.primary - a.primary || b.secondary - a.secondary); @@ -295,16 +297,6 @@ export default function Report({ prefill, onPrefillConsumed }) { {dayLabel} - {/* Hero */} -
-

- {t("report.heroMuscles", { count: untrainedMuscles.length })} -

-

- {t("report.heroNeverTrained")} -

-
- {/* Filters */}
{/* Row 1: period */} @@ -424,24 +416,8 @@ export default function Report({ prefill, onPrefillConsumed }) {
- {/* Gap callout card */} - {untrainedMuscles.length > 0 && ( -
-

- {t("report.gapHeading")} -

-
- {untrainedMuscles.map(id => ( - - {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - - ))} -
-
- )} - {/* Frequency table */} -
+

{t("report.frequencyTable")}

@@ -494,8 +470,42 @@ export default function Report({ prefill, onPrefillConsumed }) {
+ {/* Untrained section — acts as recommendation header */} + {untrainedMuscles.length > 0 ? ( +
+

+ {t("report.gapHeading")} +

+
+ {untrainedMuscles.map(id => ( + + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + + ))} +
+
+ ) : secondaryOnlyMuscles.length > 0 ? ( +
+

+ {t("report.allMusclesSecondaryNote")} +

+
+ {secondaryOnlyMuscles.map(id => ( + + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + + ))} +
+
+ ) : sessionCount > 0 ? ( +

+ {t("report.allMusclesPrimary")} +

+ ) : null} + + {/* Recommendation button + results */} {sessionCount > 0 && ( -
+
- ) : ( - - )} -
- ))} -
- - {isMobile ? ( - <> -
- {["front", "back"].map(v => ( - - ))} -
-
- + {recs && recs.length > 0 && ( +
+
+

{t("report.recommendedExercises")}

+ {saveRecError && ( + + )} + {recs.map((r, i) => ( +
+
+

{r.name}

+

+ {[ + (r.primary || []).map(id => t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })).join(", "), + (r.secondary || []).length > 0 && `(${(r.secondary || []).map(id => t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })).join(", ")})` + ].filter(Boolean).join(" · ")} +

+ {r.tip &&

{r.tip}

}
- - ) : ( -
- {["front", "back"].map(view => ( -
- -
- ))} + {savedRecs.has(r.name) ? ( + + ) : ( + + )}
- )} - -
- {t("report.legendPrimary")} - {t("report.legendSecondary")} -
+ ))}
- ); - })()} +
+ )} {recs && recs.length === 0 && (

diff --git a/app/src/components/Settings.jsx b/app/src/components/Settings.jsx index 5cbc05c..5e07656 100644 --- a/app/src/components/Settings.jsx +++ b/app/src/components/Settings.jsx @@ -173,6 +173,15 @@ export default function Settings() { }}> {userEmail}

+

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

- {mode === "edit" ? t("templateEditor.titleEdit") : t("templateEditor.titleUse")} - {/* Editable template name */} -
- {editingTitle ? ( - setTemplateName(e.target.value)} - onBlur={() => setEditingTitle(false)} - onKeyDown={e => e.key === "Enter" && setEditingTitle(false)} - style={{ - background: "transparent", - border: "none", - borderBottom: "2px solid var(--cds-interactive)", - color: "var(--cds-text-primary)", - fontFamily: "var(--cds-font-sans)", - fontSize: 18, - fontWeight: 600, - padding: "2px 0", - outline: "none", - width: "100%", - }} - /> - ) : ( - setEditingTitle(true)} - style={{ cursor: "text", fontSize: 18, fontWeight: 600, color: "var(--cds-text-primary)" }} - title={t("templateEditor.clickToRename")} - > - {templateName} - +
+ + {mode === "edit" ? t("templateEditor.titleEdit") : t("templateEditor.titleUse")} + + + {mode === "use" && ( +

+ {t("templateEditor.stepIndicator")} +

)} -
+ + {/* Editable template name */} +
+ {editingTitle ? ( + setTemplateName(e.target.value)} + onBlur={() => setEditingTitle(false)} + onKeyDown={e => e.key === "Enter" && setEditingTitle(false)} + /> + ) : ( + setEditingTitle(true)} + style={{ cursor: "text", fontSize: 18, fontWeight: 600, color: "var(--cds-text-primary)" }} + title={t("templateEditor.clickToRename")} + > + {templateName} + + )} +
{/* ─── rest of TemplateSessionEditor content ─── */}
@@ -229,40 +227,21 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem {/* Action bar */}
{mode === "use" && ( - <> - -
- - -
- +
+ + +
)} {mode === "edit" && (
-
+
diff --git a/app/src/lib/db.js b/app/src/lib/db.js index 425a812..fc73489 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, visibility, + id, session_date, created_at, gym_calendar_id, gym_calendar(name, start_time), session_exercises( id, name, standard_name, sets, reps, position, @@ -376,14 +376,6 @@ 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) { @@ -399,7 +391,6 @@ export async function fetchClassHistory(gymCalendarId) { ) `) .eq("gym_calendar_id", gymCalendarId) - .eq("visibility", "shared") .neq("trainer_id", user?.id ?? "") .order("session_date", { ascending: false }); if (error) throw error; From 0364ea25db1975f587488d499a1e5ff520057dd9 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 01:18:29 +0200 Subject: [PATCH 02/10] fix: resolve CI lint errors and add branded email templates (#148) - Fix react-hooks/refs: track wrapWidth via ResizeObserver in BodySVG and HeatmapBodySVG instead of reading ref during render - Fix setState-in-useMemo in Bibliotek: move setExVisible reset to useEffect - Remove unused imports/state: DefinitionTooltip, EditIcon (History), selectedSession (History), saved (TemplateSessionEditor) - Add supabase/templates/ with branded magic_link, invite, and confirmation HTML emails - Add supabase/config.toml referencing templates for version-controlled config push - Gitignore supabase/.temp/ (ephemeral CLI state) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CHANGELOG.md | 1 + CLAUDE.md | 19 ++++++++++++++ app/src/components/Bibliotek.jsx | 6 ++--- app/src/components/History.jsx | 7 +++--- app/src/components/TemplateSessionEditor.jsx | 3 --- app/src/lib/bodymap.jsx | 18 ++++++++++++-- supabase/config.toml | 13 ++++++++++ supabase/templates/confirmation.html | 26 ++++++++++++++++++++ supabase/templates/invite.html | 26 ++++++++++++++++++++ supabase/templates/magic_link.html | 26 ++++++++++++++++++++ 11 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 supabase/config.toml create mode 100644 supabase/templates/confirmation.html create mode 100644 supabase/templates/invite.html create mode 100644 supabase/templates/magic_link.html diff --git a/.gitignore b/.gitignore index b8b2d13..df9fbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ files.zip .env.local .claude/ design_handoff_workout_lens/ +supabase/.temp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e2562..e44daa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Workout Lens are documented here. ## [Unreleased] ### Added +- **Email templates (#148)** — Supabase auth emails (magic link, invite, email confirmation) are now version-controlled in `supabase/templates/`. Branded with Workout Lens name, `workout.umulig.org` domain, magenta CTA button, and Carbon-matching dark colour scheme. Apply to the remote project with `supabase link` + `supabase config push`. - **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 sessions. `fetchClassHistory(gymCalendarId)` added to `db.js`. - **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. - **GDPR transparency note** — Settings → Konto now shows an informational paragraph explaining that all logged sessions are visible to co-instructors at the same gym, in line with the app's purpose. diff --git a/CLAUDE.md b/CLAUDE.md index 0dd5e5c..c64f57b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,25 @@ week_plan_days - History edit mode re-analyse uses a single image only (the new photo replaces the full exercise list); multi-image re-analysis is not supported in edit mode - Carbon `DatePicker` uses US date format (`MM/DD/YYYY`) in the confirm step — no Norwegian locale override applied yet +## Email templates + +Supabase auth email templates are version-controlled in `supabase/templates/`. Three templates are defined: + +| File | Email type | Subject | +|---|---|---| +| `magic_link.html` | Magic link login | Sign in to Workout Lens | +| `invite.html` | User invite | You have been invited to Workout Lens | +| `confirmation.html` | Email confirmation | Confirm your Workout Lens account | + +Templates are referenced in `supabase/config.toml`. To apply them to the remote Supabase project: + +```powershell +supabase link --project-ref kyolnraqudwrjjbtxhwx +supabase config push +``` + +All templates use inline CSS only (no external stylesheets — email clients strip them). Colours match the app: `#161616` background, `#ee2c80` accent, `#262626` header. The `{{ .ConfirmationURL }}` and `{{ .SiteURL }}` variables are Supabase Go template syntax — do not change them. + ## Local development ```powershell diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index 9686bde..e08c099 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -58,13 +58,13 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { }, []); const filteredExercises = useMemo(() => { - const result = debouncedSearch.trim() + return debouncedSearch.trim() ? exercises.filter(e => e.name.toLowerCase().includes(debouncedSearch.toLowerCase().trim())) : exercises; - setExVisible(20); - return result; }, [exercises, debouncedSearch]); + useEffect(() => { setExVisible(20); }, [filteredExercises]); + const filteredTemplates = useMemo(() => { const q = tplSearch.trim().toLowerCase(); return q ? templates.filter(t => t.name.toLowerCase().includes(q)) : templates; diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 43a7c45..ade13d4 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -4,10 +4,10 @@ import { MUSCLES, 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, InlineLoading, DefinitionTooltip, + Button, Tag, InlineNotification, InlineLoading, Select, SelectItem, AccordionSkeleton, SkeletonPlaceholder, } from "@carbon/react"; -import { Camera, Add, Edit as EditIcon, Renew, ChevronDown, ChevronLeft, ChevronRight } from "@carbon/icons-react"; +import { Camera, Add, Renew, ChevronDown, ChevronLeft, ChevronRight } from "@carbon/icons-react"; import ExerciseRowWithAutocomplete from "./ExerciseRowWithAutocomplete"; import BodyPanel from "./BodyPanel"; import PageShell, { SectionLabel, PageHeading } from "./PageShell"; @@ -146,7 +146,7 @@ export default function History({ initialDate }) { ); const [daySessions, setDaySessions] = useState([]); const [expandedIds, setExpandedIds] = useState(new Set()); - const [selectedSession, setSelectedSession] = useState(null); + const [loadingSession, setLoadingSession] = useState(false); const [today, setToday] = useState(() => new Date()); @@ -271,7 +271,6 @@ export default function History({ initialDate }) { const loadSession = async (dateStr) => { setLoadingSession(true); setDaySessions([]); - setSelectedSession(null); try { const results = await fetchSessionsByDate(dateStr); results.forEach(s => { diff --git a/app/src/components/TemplateSessionEditor.jsx b/app/src/components/TemplateSessionEditor.jsx index 91a33d2..6e7af6b 100644 --- a/app/src/components/TemplateSessionEditor.jsx +++ b/app/src/components/TemplateSessionEditor.jsx @@ -49,7 +49,6 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const [saved, setSaved] = useState(false); useEffect(() => { fetchLibraryExercises().then(setLibraryExercises).catch(() => {}); // degrades silently to manual entry @@ -100,14 +99,12 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem const saveToTemplate = async () => { setSaving(true); setSaveError(null); - setSaved(false); try { const enabled = exercises.filter(e => e.enabled && e.name); if (templateName !== template.name) { await updateTemplateName(template.id, templateName); } await replaceTemplateExercises(template.id, enabled); - setSaved(true); if (mode === "edit") { setTimeout(onBack, 800); } diff --git a/app/src/lib/bodymap.jsx b/app/src/lib/bodymap.jsx index d19e998..85fb636 100644 --- a/app/src/lib/bodymap.jsx +++ b/app/src/lib/bodymap.jsx @@ -126,10 +126,17 @@ export function HeatmapBodySVG({ view, counts = {}, maxCount = 1, exerciseMap = const { t } = useTranslation(); const [tooltip, setTooltip] = React.useState(null); const [focused, setFocused] = React.useState(null); + const [wrapWidth, setWrapWidth] = React.useState(200); const wrapRef = React.useRef(); const rafRef = React.useRef(null); React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []); + React.useEffect(() => { + if (!wrapRef.current) return; + const ro = new ResizeObserver(entries => setWrapWidth(entries[0].contentRect.width)); + ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); const handleEnter = (id, e) => { if (onHover) { onHover(id); return; } @@ -253,7 +260,7 @@ export function HeatmapBodySVG({ view, counts = {}, maxCount = 1, exerciseMap = {!onHover && tooltip && (
() => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []); + React.useEffect(() => { + if (!wrapRef.current) return; + const ro = new ResizeObserver(entries => setWrapWidth(entries[0].contentRect.width)); + ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); const handleEnter = (id, e) => { if (onHover) { onHover(id); return; } @@ -418,7 +432,7 @@ export function BodySVG({ view, primary, secondary, muscleMap = {}, onHover, hov {!onHover && tooltip && muscleMap[tooltip.id]?.length > 0 && (
+
+ Workout Lens + workout.umulig.org +
+ +
+

Confirm your email

+

+ Click the button below to confirm your email address and activate your Workout Lens account. +

+ + + Confirm email + + +

+ If you did not create an account, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because someone signed up with this address. +
+
diff --git a/supabase/templates/invite.html b/supabase/templates/invite.html new file mode 100644 index 0000000..67c6fac --- /dev/null +++ b/supabase/templates/invite.html @@ -0,0 +1,26 @@ +
+
+ Workout Lens + workout.umulig.org +
+ +
+

You have been invited

+

+ You have been invited to create an account on Workout Lens. Click the button below to accept the invite and get started. +

+ + + Accept invite + + +

+ If you were not expecting an invitation, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because someone sent an invitation to this address. +
+
diff --git a/supabase/templates/magic_link.html b/supabase/templates/magic_link.html new file mode 100644 index 0000000..9937fdd --- /dev/null +++ b/supabase/templates/magic_link.html @@ -0,0 +1,26 @@ +
+
+ Workout Lens + workout.umulig.org +
+ +
+

Sign in

+

+ Click the button below to sign in to Workout Lens. The link is valid for 60 minutes and can only be used once. +

+ + + Sign in + + +

+ If you did not request this link, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because someone attempted to sign in with this address. +
+
From 78d120492c871d2257c470a0a4916bd026de131c Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 01:22:58 +0200 Subject: [PATCH 03/10] fix: restore production auth settings in supabase config.toml Previous config.toml only had email template sections, causing config push to overwrite site_url, redirect URLs, MFA and email confirmation settings with local dev defaults. Full auth section added with correct production values. Co-Authored-By: Claude Sonnet 4.6 --- supabase/config.toml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/supabase/config.toml b/supabase/config.toml index 32e648c..cae12b5 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,5 +1,32 @@ project_id = "kyolnraqudwrjjbtxhwx" +[auth] +enabled = true +site_url = "https://white-island-090dfd003.7.azurestaticapps.net" +additional_redirect_urls = ["https://white-island-090dfd003.7.azurestaticapps.net", "http://localhost:4280", "https://workout.umulig.org", "https://workout.umulig.org/**", "https://white-island-090dfd003-*.westeurope.7.azurestaticapps.net"] +jwt_expiry = 3600 +enable_refresh_token_rotation = true + +[auth.mfa] +max_enrolled_factors = 10 + +[auth.mfa.totp] +enroll_enabled = true +verify_enabled = true + +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = true +secure_password_change = false +max_frequency = "1m0s" +otp_length = 8 +otp_expiry = 3600 + [auth.email.template.magic_link] subject = "Sign in to Workout Lens" content_path = "./supabase/templates/magic_link.html" From 49b1cee0299221a4a3cfd0cfbd958b07db0a9833 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 01:25:46 +0200 Subject: [PATCH 04/10] feat: add recovery and email_change branded email templates (#148) Completes full coverage of all 5 Supabase auth email templates. Co-Authored-By: Claude Sonnet 4.6 --- supabase/config.toml | 8 ++++++++ supabase/templates/email_change.html | 26 ++++++++++++++++++++++++++ supabase/templates/recovery.html | 26 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 supabase/templates/email_change.html create mode 100644 supabase/templates/recovery.html diff --git a/supabase/config.toml b/supabase/config.toml index cae12b5..0059356 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -38,3 +38,11 @@ content_path = "./supabase/templates/invite.html" [auth.email.template.confirmation] subject = "Confirm your Workout Lens account" content_path = "./supabase/templates/confirmation.html" + +[auth.email.template.recovery] +subject = "Reset your Workout Lens password" +content_path = "./supabase/templates/recovery.html" + +[auth.email.template.email_change] +subject = "Confirm your new Workout Lens email" +content_path = "./supabase/templates/email_change.html" diff --git a/supabase/templates/email_change.html b/supabase/templates/email_change.html new file mode 100644 index 0000000..8a0b8ee --- /dev/null +++ b/supabase/templates/email_change.html @@ -0,0 +1,26 @@ +
+
+ Workout Lens + workout.umulig.org +
+ +
+

Confirm your new email

+

+ Click the button below to confirm this as your new email address for Workout Lens. +

+ + + Confirm new email + + +

+ If you did not request an email change, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because an email address change was requested for this account. +
+
diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html new file mode 100644 index 0000000..3027824 --- /dev/null +++ b/supabase/templates/recovery.html @@ -0,0 +1,26 @@ +
+
+ Workout Lens + workout.umulig.org +
+ +
+

Reset your password

+

+ Click the button below to reset your Workout Lens password. The link is valid for 60 minutes. +

+ + + Reset password + + +

+ If you did not request a password reset, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because a password reset was requested for this address. +
+
From 5a32bc2952014fe9fc5df7cf1c7243af76ceb4c8 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 01:27:09 +0200 Subject: [PATCH 05/10] feat: add reauthentication email template (#148) Completes all 6 Supabase auth email templates with consistent branding. Co-Authored-By: Claude Sonnet 4.6 --- supabase/config.toml | 4 ++++ supabase/templates/reauthentication.html | 25 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 supabase/templates/reauthentication.html diff --git a/supabase/config.toml b/supabase/config.toml index 0059356..4d54b08 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -46,3 +46,7 @@ content_path = "./supabase/templates/recovery.html" [auth.email.template.email_change] subject = "Confirm your new Workout Lens email" content_path = "./supabase/templates/email_change.html" + +[auth.email.template.reauthentication] +subject = "Confirm your identity on Workout Lens" +content_path = "./supabase/templates/reauthentication.html" diff --git a/supabase/templates/reauthentication.html b/supabase/templates/reauthentication.html new file mode 100644 index 0000000..bae7442 --- /dev/null +++ b/supabase/templates/reauthentication.html @@ -0,0 +1,25 @@ +
+
+ Workout Lens + workout.umulig.org +
+ +
+

Confirm your identity

+

+ Enter the code below to confirm your identity on Workout Lens. The code expires in 10 minutes. +

+ +
+ {{ .Token }} +
+ +

+ If you did not request this, you can safely ignore this email. +

+
+ +
+ workout.umulig.org — you are receiving this email because a sensitive action was requested on your account. +
+
From f759ba4846de8dfc691a8be0d0d00a81addc3586 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 02:23:42 +0200 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20UI=20polish=20from=20PR=20#147=20r?= =?UTF-8?q?eview=20=E2=80=94=2010=20UX=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - History: remove username from edit box; unify ghost buttons; fix chip overflow (+N); pre-fetch library on mount for autocomplete; clarify gym conflict warning - Bibliotek: rename "Maler" → "Mine maler"; remove Snarveier carousel; remove used_at date from template cards - TemplatePicker: remove "Sist brukt" date - Planlegger: auto-save on add/remove; remove Lagre/Fjern uke buttons - MuscleMap: remove "Neste steg" CTA and "Tips" callout - Login: English-only quotes (language unknown before sign-in) - app.css: strengthen Carbon Select fix (default + hover background) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++ CLAUDE.md | 8 +- app/public/locales/en/translation.json | 10 +- app/public/locales/fa/translation.json | 10 +- app/public/locales/nb/translation.json | 10 +- app/src/components/Bibliotek.jsx | 71 ++------------ app/src/components/History.jsx | 51 +++++----- app/src/components/Login.jsx | 24 ++--- app/src/components/MuscleMap.jsx | 47 --------- app/src/components/Planlegger.jsx | 127 +++++-------------------- app/src/components/TemplatePicker.jsx | 6 +- app/src/styles/app.css | 4 + 12 files changed, 95 insertions(+), 283 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e44daa7..002456d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Workout Lens are documented here. ## [Unreleased] +### Changed +- **UI polish — post-#147 review (#147)** — ten UX fixes across History, Bibliotek, Planlegger, MuscleMap, TemplatePicker, and Login: + - **History** — removed username display from exercise edit box; "Legg til øvelse manuelt" and "Last opp nytt bilde" unified as sibling ghost buttons below the exercise list; session header chips capped at 2 visible + `+N` overflow to prevent title overflow; library pre-fetched on mount so autocomplete is always ready; gym-class conflict warning wording clarified + - **Bibliotek** — "Maler" tab renamed to "Mine maler"; Snarveier carousel removed (caused horizontal overflow); `used_at` date removed from template cards + - **TemplatePicker** — "Sist brukt" date removed from template cards + - **Planlegger** — "Lagre plan" and "Fjern uke" buttons removed; plan now auto-saves on every add/remove and auto-deletes when all slots are cleared + - **MuscleMap** — "NESTE STEG / Analyser perioden" CTA card removed from result step; "TIPS" callout removed from upload step + - **Login** — daily quotes hardcoded to English (language is unknown before sign-in) + - **Carbon Select** — global CSS fix strengthened to also force `background-color: var(--cds-field-01)` in default state, preventing white-on-white in all layer contexts + ### Added - **Email templates (#148)** — Supabase auth emails (magic link, invite, email confirmation) are now version-controlled in `supabase/templates/`. Branded with Workout Lens name, `workout.umulig.org` domain, magenta CTA button, and Carbon-matching dark colour scheme. Apply to the remote project with `supabase link` + `supabase config push`. - **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 sessions. `fetchClassHistory(gymCalendarId)` added to `db.js`. diff --git a/CLAUDE.md b/CLAUDE.md index c64f57b..0ee5f3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,10 +49,10 @@ Fully migrated to IBM Carbon Design System (issue #8, resolved 2026-04-29). - IBM Plex fonts (Sans, Mono, Serif, Condensed) bundled locally in `app/public/fonts/` — no Google Fonts, no CDN - `app/src/styles/carbon-tokens.css` — all Carbon CSS variables for g10 (light) and g100 (dark) themes, plus `@font-face` declarations; font URLs use `/fonts/...` (Vite public-dir absolute paths) - `app/src/theme.jsx` — `ThemeProvider` sets `data-theme="g10"` or `data-theme="g100"` on ``, persists to `localStorage`, respects `prefers-color-scheme`, defaults to g100 (dark) -- `Login.jsx` → Carbon `TextInput`, `Button`, `InlineNotification`, `Email` icon; `getDailyQuote()` renders a date-aware motivational quote below the subtitle — keyed by `MM-DD` for special dates (`01-01`, `05-05`, `05-17`, `12-24`), falls back to a per-weekday quote (mandag–søndag); 13px italic `var(--cds-text-secondary)` +- `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` (no global `editMode` boolean); a dirty-state Save / Discard / Reupload bar appears when changes are detected; `Camera`, `Add`, `Renew` icons; 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); `PageHeading` hero; live search input on exercises tab with load-more (batches of 20 when >20 shown); Snarvei template carousel (capped at 6, "Se alle →" link to templates tab); search input on templates tab with load-more (batches of 12 when >12); exercise rows use `AccentChip` for primary muscles + Cond 700 name + 3px accent left strip; template cards use `borderRadius: var(--r-card)`; "Ny øvelse" button renders above Snarveier carousel to prevent tab-switch layout shift; exercise form via `ExerciseForm` +- `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` (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` - `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) @@ -181,7 +181,7 @@ week_plan_days - SVG body uses `BODY_PATH` (bezier curves, viewBox `0 0 160 360`) — improved silhouette with curved shoulders, arms, waist and hips. Still simplified, not anatomically precise. `SHAPES` entries are either ellipses (`{ cx, cy, rx, ry }`) or SVG paths (`{ d }`); the render loop handles both. Key muscles with path shapes: `traps` (trapezoid with neck notch), `lats` (wing paths). `BodySVG` renders primary muscles as solid green glow, secondary as diagonal blue stripes (``). - `useIsMobile(breakpoint=500)` — exported hook from `bodymap.jsx`. Below breakpoint: single body view with Front/Bak toggle. Above: side-by-side. Consumed via `BodyPanel` — do not use directly in page components. - **Shared exercise row:** `app/src/components/ExerciseRow.jsx` — renders one editable exercise row (checkbox, inline name edit, sets/reps inputs, delete). Props: `exercise`, `onChange(updates)`, `onDelete()`, `layer` ("layer-01"/"layer-02"), `validateNumbers`, `autoFocusName`, `onNameBlur` (optional callback fired when the name input blurs — used by `ExerciseRowWithAutocomplete` to trigger muscle inference). The outer row div has no click handler — only the Checkbox toggles `enabled` (prevents accidental untick when editing fields). Used by `MuscleMap.jsx`, `History.jsx`, and `TemplateSessionEditor.jsx`. -- **Planlegger:** `app/src/components/Planlegger.jsx` — weekly training planner view (issue #59). State: `weekOffset` (±week navigation), `assignments` (`{ [dow 1-7]: template | null }`), `templates`, `weekSessions` (logged sessions for the visible ISO week — issue #143), `pickerDow`, `confirmDelete`, `saving`, `saveError`, `hoveredMuscle`. Computed via `useMemo`: `monday`, `weekIso`, `weekLabel` (built inline with `Intl.DateTimeFormat` for the locale-aware month abbreviation + `t("planlegger.weekLabel", ...)`), `untrainedThisWeekIds` (muscle IDs not trained in any logged session for the visible ISO week — derived from `weekSessions` via `extractMuscles`; issue #143), `projectedExerciseMap` (union of all assigned templates' exercises via `buildMuscleMapFromExercises`), `sessionCount`, `muscleGroupCount`, `untrainedMuscleIds`, `showForslag` (≥2 untrained muscles), `forslagTemplates` (up to 3 templates from library covering untrained muscles). Layout: week nav chevrons → `PageHeading` → `SectionLabel "IKKE TRENT DENNE UKEN"` → wrap row of mono pill chips (History-style: `var(--r-pill)`, `var(--border-subtle-wl)`, `var(--text-muted-wl)`, `var(--cds-font-mono)` 11px) listing muscles not yet trained that week (or a single mono message when all 17 are trained) → `SectionLabel "PROJISERT DEKNING"` → projected `HeatmapBodySVG` (side-by-side/toggle) → fixed-height 48px hover-detail container (always rendered, prevents layout shift) → optional Forslag card → `SectionLabel "UKESPLAN"` → 7 × DayRow → inline `TemplatePicker` bottom-sheet overlay → `StickyCta` ("Fjern uke" ghost + "Lagre plan" primary) → confirm-delete strip. Persists via `fetchWeekPlan` / `saveWeekPlan` / `deleteWeekPlan` in `db.js`; loads logged sessions via `fetchSessionsForWeek` in parallel with the plan fetch. Duration (`N MIN`) omitted — `session_templates` has no duration column. +- **Planlegger:** `app/src/components/Planlegger.jsx` — weekly training planner view (issue #59). State: `weekOffset` (±week navigation), `assignments` (`{ [dow 1-7]: template | null }`), `templates`, `weekSessions` (logged sessions for the visible ISO week — issue #143), `pickerDow`, `saving`, `saveError`, `hoveredMuscle`. Computed via `useMemo`: `monday`, `weekIso`, `weekLabel` (built inline with `Intl.DateTimeFormat` for the locale-aware month abbreviation + `t("planlegger.weekLabel", ...)`), `untrainedThisWeekIds` (muscle IDs not trained in any logged session for the visible ISO week — derived from `weekSessions` via `extractMuscles`; issue #143), `projectedExerciseMap` (union of all assigned templates' exercises via `buildMuscleMapFromExercises`), `sessionCount`, `muscleGroupCount`, `untrainedMuscleIds`, `showForslag` (≥2 untrained muscles), `forslagTemplates` (up to 3 templates from library covering untrained muscles). Layout: week nav chevrons → `PageHeading` → `SectionLabel "IKKE TRENT DENNE UKEN"` → wrap row of mono pill chips (History-style: `var(--r-pill)`, `var(--border-subtle-wl)`, `var(--text-muted-wl)`, `var(--cds-font-mono)` 11px) listing muscles not yet trained that week (or a single mono message when all 17 are trained) → `SectionLabel "PROJISERT DEKNING"` → projected `HeatmapBodySVG` (side-by-side/toggle) → fixed-height 48px hover-detail container (always rendered, prevents layout shift) → optional Forslag card → `SectionLabel "UKESPLAN"` → 7 × DayRow → inline `TemplatePicker` bottom-sheet overlay. No sticky save/delete bar — plan auto-saves on every add/remove; `deleteWeekPlan` is called automatically when all slots are cleared. Persists via `fetchWeekPlan` / `saveWeekPlan` / `deleteWeekPlan` in `db.js`; loads logged sessions via `fetchSessionsForWeek` in parallel with the plan fetch. Duration (`N MIN`) omitted — `session_templates` has no duration column. - **Settings:** `app/src/components/Settings.jsx` — settings view reachable via the gear icon in the header (issue #123). Sections in order: (1) Språk — `RadioButtonGroup` for nb/en/fa; calls `i18n.changeLanguage()` + persists to `localStorage`; (2) Utseende — Carbon `Toggle` for dark/light theme with a live `BodyPanel` preview (fixed sample: primary `chest, quads, lats`; secondary `shoulders_front, hamstrings, triceps`); (3) Kontakt — feedback text + GitHub link; (4) Om appen — version number + "Vis endringslogg" opening `ChangelogModal`; (5) Konto — logged-in email (read-only) + danger logout button. `ChangelogModal` is no longer rendered in `PageShell` — it lives here exclusively. - **BodyPanel:** `app/src/components/BodyPanel.jsx` — shared front/back body map. Manages its own `mobileView` toggle state internally. Props: `primary[]`, `secondary[]`, `muscleMap`, `marginBottom`. Replaces the duplicated mobile/desktop render pattern that previously existed in `MuscleMap`, `History`, and `TemplateSessionEditor`. - **MusclePicker:** `app/src/components/MusclePicker.jsx` — interactive body map where clicking a muscle cycles off → primary → secondary → off. Props: `primary[]`, `secondary[]`, `onChange({ primary, secondary })`, `instanceId` (unique suffix to avoid SVG filter ID collisions). Used inside `ExerciseForm.jsx`. diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index 53a6b29..6cfc90a 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -193,8 +193,8 @@ }, "gymClassLabel": "Gym class", "noClassSelected": "No class selected", - "conflictWarningTitle": "Existing session:", - "conflictWarningBody": "This class already has a saved session ({{date}}). Saving will replace it.", + "conflictWarningTitle": "Warning:", + "conflictWarningBody": "This class is already linked to a session from {{date}}. Saving will unlink that session — it keeps its data but loses the class connection.", "duplicateGymSession": "This gym class already has a saved session.", "reanalyzeServerError": "Server error ({{status}}): Invalid response from server", "reanalyzeServerErrorDetail": "Server error ({{status}}): {{detail}}", @@ -207,9 +207,8 @@ "sectionLabel": "LIBRARY", "heading": "Your building blocks.", "tabExercises": "Exercises", - "tabTemplates": "Templates", + "tabTemplates": "My templates", "newExercise": "New exercise", - "shortcuts": "SHORTCUTS", "searchPlaceholder": "Search exercise…", "loadingExercises": "Loading exercises…", "noExercises": "No exercises added yet.", @@ -230,8 +229,7 @@ "exerciseRemovedWarning": "and will be removed from it.", "exerciseCount": "{{count}} EX", "searchTemplates": "Search templates…", - "showMore": "Show {{count}} more", - "seeAll": "See all" + "showMore": "Show {{count}} more" }, "planlegger": { "heading": "Plan the week", diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index aeeb8d7..7d9b641 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -193,8 +193,8 @@ }, "gymClassLabel": "کلاس ورزشی", "noClassSelected": "کلاسی انتخاب نشده", - "conflictWarningTitle": "جلسه موجود:", - "conflictWarningBody": "این کلاس از قبل یک جلسه ذخیره شده دارد ({{date}}). ذخیره جدید جایگزین آن می‌شود.", + "conflictWarningTitle": "هشدار:", + "conflictWarningBody": "این کلاس از قبل به جلسه‌ای از {{date}} متصل است. با ذخیره، اتصال آن جلسه حذف می‌شود — داده‌هایش حفظ می‌شود اما ارتباط با کلاس قطع می‌شود.", "duplicateGymSession": "این کلاس از قبل یک جلسه ذخیره شده دارد.", "reanalyzeServerError": "خطای سرور ({{status}}): پاسخ نامعتبر از سرور", "reanalyzeServerErrorDetail": "خطای سرور ({{status}}): {{detail}}", @@ -207,9 +207,8 @@ "sectionLabel": "کتابخانه", "heading": "بلوک‌های سازنده شما.", "tabExercises": "تمرین‌ها", - "tabTemplates": "قالب‌ها", + "tabTemplates": "قالب‌های من", "newExercise": "تمرین جدید", - "shortcuts": "میانبرها", "searchPlaceholder": "جستجوی تمرین…", "loadingExercises": "در حال بارگذاری تمرین‌ها…", "noExercises": "هنوز تمرینی اضافه نشده.", @@ -230,8 +229,7 @@ "exerciseRemovedWarning": "و از آن حذف خواهد شد.", "exerciseCount": "{{count}} تمرین", "searchTemplates": "جستجو در قالب‌ها…", - "showMore": "نمایش {{count}} بیشتر", - "seeAll": "مشاهده همه" + "showMore": "نمایش {{count}} بیشتر" }, "planlegger": { "heading": "برنامه‌ریزی هفته", diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index 3f5370a..637e374 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -193,8 +193,8 @@ }, "gymClassLabel": "Gymtime", "noClassSelected": "Ingen time valgt", - "conflictWarningTitle": "Eksisterende økt:", - "conflictWarningBody": "Denne gymtimen har allerede en lagret økt ({{date}}). Lagring erstatter den.", + "conflictWarningTitle": "Advarsel:", + "conflictWarningBody": "Denne gymtimen er allerede koblet til en økt fra {{date}}. Lagring fjerner den koblingen — den andre øvelsen beholder sine data.", "duplicateGymSession": "Denne gymtimen har allerede en økt lagret.", "reanalyzeServerError": "Serverfeil ({{status}}): Ugyldig svar fra server", "reanalyzeServerErrorDetail": "Serverfeil ({{status}}): {{detail}}", @@ -207,9 +207,8 @@ "sectionLabel": "BIBLIOTEK", "heading": "Dine byggeklosser.", "tabExercises": "Øvelser", - "tabTemplates": "Maler", + "tabTemplates": "Mine maler", "newExercise": "Ny øvelse", - "shortcuts": "SNARVEIER", "searchPlaceholder": "Søk øvelse…", "loadingExercises": "Laster øvelser…", "noExercises": "Ingen øvelser lagt til ennå.", @@ -230,8 +229,7 @@ "exerciseRemovedWarning": "og vil bli fjernet derfra.", "exerciseCount": "{{count}} ØV", "searchTemplates": "Søk i maler…", - "showMore": "Vis {{count}} til", - "seeAll": "Se alle" + "showMore": "Vis {{count}} til" }, "planlegger": { "heading": "Planlegg uken", diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index e08c099..f91529c 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -5,14 +5,13 @@ import { } from "@carbon/react"; import { Add, TrashCan, Edit as EditIcon, ChevronRight, Search } from "@carbon/icons-react"; import { useTranslation } from "react-i18next"; -import { getIntlLocale } from "../lib/utils"; +import { logDevError } from "../lib/utils"; import PageShell, { SectionLabel, PageHeading, AccentChip } from "./PageShell"; import { fetchLibraryExercises, saveLibraryExercise, updateLibraryExercise, deleteLibraryExercise, fetchTemplates, saveTemplate, deleteTemplate, fetchTemplateNamesUsingExercise, } from "../lib/db"; import { MUSCLES, BodySVG } from "../lib/bodymap.jsx"; -import { logDevError } from "../lib/utils"; import ExerciseForm from "./ExerciseForm"; export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { @@ -189,62 +188,6 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { )} - {!showNewEx && ( - - )} - - {/* Shortcut carousel — template shortcuts */} - {!tplLoading && templates.length > 0 && ( -
-

- {t("bibliotek.shortcuts")} -

-
- {templates.slice(0, 6).map(tpl => { - const exCount = tpl.session_template_exercises?.length || 0; - return ( - - ); - })} - {templates.length > 6 && ( - - )} -
-
- )} - {/* Search */} {!exLoading && exercises.length > 0 && (
@@ -270,6 +213,13 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
)} + {!showNewEx && ( + + )} + {showNewEx && ( {filteredTemplates.slice(0, tplVisible).map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; - const usedAt = tpl.used_at - ? new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(new Date(tpl.used_at)) - : null; const tplPrimary = [...new Set((tpl.session_template_exercises || []).flatMap(e => e.primary_muscles || []))]; const muscleCount = tplPrimary.length; return ( @@ -458,7 +405,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {tpl.name}
- {t("bibliotek.exerciseCount", { count: exCount })} · {muscleCount} MUS{usedAt ? ` · ${usedAt}` : ""} + {t("bibliotek.exerciseCount", { count: exCount })} · {muscleCount} MUS
@@ -680,11 +685,6 @@ export default function History({ initialDate }) { {/* Exercise list — always editable */}
- {myDisplayName && !isDirty && ( -

- {myDisplayName} -

- )} {workExercises && (
{workExercises.map((ex) => ( @@ -708,6 +708,10 @@ export default function History({ initialDate }) { ))}
)} +
+ + {/* Action row: add exercise + re-upload photo */} +
+
{/* Class history (gym-linked sessions only) */} @@ -776,16 +783,6 @@ export default function History({ initialDate }) { uploadingForSession.current = null; }} /> - {/* Upload new photo button (always shown) */} - {!isDirty && ( -
- -
- )} - {/* Dirty state: error notifications + save bar */} {isDirty && ( <> @@ -797,10 +794,6 @@ export default function History({ initialDate }) { )}
-
- {/* Tips callout */} -
-

{t("muscleMap.tipsHeading")}

-

{t("muscleMap.tipsBody")}

-
{sizeError && ( @@ -782,43 +772,6 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } })}
- {/* Forward CTA → Periode-rapport */} -
-
- {t("muscleMap.nextStep")} -
-

- {t("muscleMap.nextStepBody")} -

- -
- {/* Recommendations */} - -
-
- )} )}
- {/* Sticky action bar */} - {!loading && ( - -
- {hasSavedPlan && !confirmDelete && ( - - )} - -
-
- )} - {/* Template picker bottom sheet */} {pickerDow !== null && ( {templates.map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; - const usedAt = tpl.used_at - ? new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(new Date(tpl.used_at)) - : null; return ( ); diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 07cd0af..2fb3ed8 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -34,6 +34,10 @@ input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; } input[type=number] { -moz-appearance: textfield; } +/* Carbon Select: force text and background to stay readable (prevents white-on-white in some layer contexts) */ +.cds--select-input { color: var(--cds-text-primary); background-color: var(--cds-field-01); } +.cds--select-input:hover { background-color: var(--cds-field-hover); color: var(--cds-text-primary); } + /* ===== RTL (Persian / فارسی) ===== */ [dir="rtl"] { font-family: var(--fa-font); From 583bca9a1bbd4f4e6ea26873a823a904954e9669 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 02:26:45 +0200 Subject: [PATCH 07/10] fix: remove unused onShowReportWithPrefill from MuscleMap Co-Authored-By: Claude Sonnet 4.6 --- app/src/components/MuscleMap.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index c621e6a..25d2804 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -119,7 +119,7 @@ export function reducer(state, action) { // ── MAIN COMPONENT ──────────────────────────────────────────────────── export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }) { const { t } = useTranslation(); - const { onShowHome, onShowTemplatePicker, onShowReportWithPrefill } = useNav(); + const { onShowHome, onShowTemplatePicker } = useNav(); const [state, dispatch] = useReducer(reducer, initialState); const { step, images, exercises, muscles, error, dragging, editingId, recs, loadingRecs, recsError, saving, saved, saveError, From c7e73aae2d6b8c8e278f30f44f649b21f99c519a Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 02:32:38 +0200 Subject: [PATCH 08/10] fix: remove unused saving state from Planlegger (ESLint no-unused-vars) Co-Authored-By: Claude Sonnet 4.6 --- app/src/components/Planlegger.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/components/Planlegger.jsx b/app/src/components/Planlegger.jsx index 633a03a..6d950bb 100644 --- a/app/src/components/Planlegger.jsx +++ b/app/src/components/Planlegger.jsx @@ -204,7 +204,6 @@ export default function Planlegger() { const [templates, setTemplates] = useState([]); const [pickerDow, setPickerDow] = useState(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [hoveredMuscle, setHoveredMuscle] = useState(null); const [mobileBodyView, setMobileBodyView] = useState("front"); @@ -353,7 +352,6 @@ export default function Planlegger() { }, [pickerDow]); const autoSave = async (newAssignments) => { - setSaving(true); setSaveError(null); const asgn = Object.entries(newAssignments) .filter(([, tpl]) => tpl) @@ -367,8 +365,6 @@ export default function Planlegger() { } catch (e) { logDevError("Planlegger/autoSave", e); setSaveError(e.message); - } finally { - setSaving(false); } }; From 39273dc247652a1b31a87646e1bd5701667aa57a Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 02:47:16 +0200 Subject: [PATCH 09/10] feat: step indicator in template flow, rename Mal button, remove Report CTA, fix planner wording (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TemplatePicker: add 3-step visual progress indicator (step 1 active) - TemplateSessionEditor: replace plain step text with same visual stepper (step 2 active) - MuscleMap: rename "Mal" button to "Mine maler" (nb) / "My templates" (en) - Report: remove "Disse bør du legge inn i programmet" StickyCta — misleading (navigates to library root, exercises are not saved) - Planlegger: allMusclesTrained message changed from "trent" to "planlagt" Co-Authored-By: Claude Sonnet 4.6 --- app/public/locales/en/translation.json | 10 ++++--- app/public/locales/fa/translation.json | 10 ++++--- app/public/locales/nb/translation.json | 10 ++++--- app/src/components/Report.jsx | 21 +------------ app/src/components/TemplatePicker.jsx | 26 ++++++++++++++++ app/src/components/TemplateSessionEditor.jsx | 31 ++++++++++++++++---- 6 files changed, 71 insertions(+), 37 deletions(-) diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index 6cfc90a..5a757ac 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -111,7 +111,7 @@ "dropzoneLabel": "Upload training photo", "dropzoneClick": "Tap to choose photo", "dropzoneDrag": "or drag and drop · JPEG, PNG, WebP", - "useTemplate": "Template", + "useTemplate": "My templates", "manualEntry": "Enter manually", "tipsHeading": "Tips", "tipsBody": "Good lighting and the full board in frame gives the best results. Multiple images supported.", @@ -236,7 +236,7 @@ "prevWeek": "Previous week", "nextWeek": "Next week", "notTrainedThisWeek": "Not trained this week", - "allMusclesTrained": "All 17 muscles trained this week", + "allMusclesTrained": "All 17 muscles planned this week", "projectedCoverage": "Projected coverage", "weekSummary_one": "{{count}} session · {{muscleCount}} muscle groups", "weekSummary_other": "{{count}} sessions · {{muscleCount}} muscle groups", @@ -392,7 +392,10 @@ "goToLibrary": "Go to library", "lastUsed": "Last used {{date}}", "exerciseCount_one": "{{count}} exercise", - "exerciseCount_other": "{{count}} exercises" + "exerciseCount_other": "{{count}} exercises", + "step1": "Pick template", + "step2": "Adjust", + "step3": "Log session" }, "templateEditor": { "titleEdit": "Edit template", @@ -405,7 +408,6 @@ "saveChanges": "Save template changes", "useSession": "Use session", "saveTemplate": "Save template", - "stepIndicator": "Step 2 of 3 — Adjust exercises", "nameLabel": "Template name" } } diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index 7d9b641..91cb0a7 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -111,7 +111,7 @@ "dropzoneLabel": "آپلود تصویر تمرین", "dropzoneClick": "برای انتخاب تصویر ضربه بزنید", "dropzoneDrag": "یا بکشید و رها کنید · JPEG، PNG، WebP", - "useTemplate": "قالب", + "useTemplate": "قالب‌های من", "manualEntry": "ورود دستی", "tipsHeading": "راهنما", "tipsBody": "نور مناسب و نمایش کامل تخته در تصویر بهترین نتیجه را می‌دهد. چند تصویر پشتیبانی می‌شود.", @@ -236,7 +236,7 @@ "prevWeek": "هفته قبل", "nextWeek": "هفته بعد", "notTrainedThisWeek": "تمرین‌نشده این هفته", - "allMusclesTrained": "هر ۱۷ عضله این هفته تمرین شده", + "allMusclesTrained": "هر ۱۷ عضله این هفته برنامه‌ریزی شده", "projectedCoverage": "پوشش پیش‌بینی‌شده", "weekSummary_one": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", "weekSummary_other": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", @@ -392,7 +392,10 @@ "goToLibrary": "رفتن به کتابخانه", "lastUsed": "آخرین استفاده: {{date}}", "exerciseCount_one": "{{count}} تمرین", - "exerciseCount_other": "{{count}} تمرین" + "exerciseCount_other": "{{count}} تمرین", + "step1": "انتخاب قالب", + "step2": "تنظیم", + "step3": "ثبت جلسه" }, "templateEditor": { "titleEdit": "ویرایش قالب", @@ -405,7 +408,6 @@ "saveChanges": "ذخیره تغییرات قالب", "useSession": "شروع جلسه", "saveTemplate": "ذخیره قالب", - "stepIndicator": "مرحله ۲ از ۳ — تنظیم تمرین‌ها", "nameLabel": "نام قالب" } } diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index 637e374..f7d66ae 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -111,7 +111,7 @@ "dropzoneLabel": "Last opp treningsbilde", "dropzoneClick": "Trykk for å velge bilde", "dropzoneDrag": "eller dra og slipp · JPEG, PNG, WebP", - "useTemplate": "Mal", + "useTemplate": "Mine maler", "manualEntry": "Legg inn manuelt", "tipsHeading": "Tips", "tipsBody": "God belysning og hele tavla i bildet gir best resultat. Flere bilder støttes.", @@ -236,7 +236,7 @@ "prevWeek": "Forrige uke", "nextWeek": "Neste uke", "notTrainedThisWeek": "Ikke trent denne uken", - "allMusclesTrained": "Alle 17 muskler er trent denne uken", + "allMusclesTrained": "Alle 17 muskler planlagt denne uken", "projectedCoverage": "Projisert dekning", "weekSummary_one": "{{count}} økt · {{muscleCount}} muskelgrupper", "weekSummary_other": "{{count}} økter · {{muscleCount}} muskelgrupper", @@ -392,7 +392,10 @@ "goToLibrary": "Gå til biblioteket", "lastUsed": "Sist brukt {{date}}", "exerciseCount_one": "{{count}} øvelse", - "exerciseCount_other": "{{count}} øvelser" + "exerciseCount_other": "{{count}} øvelser", + "step1": "Velg mal", + "step2": "Tilpass", + "step3": "Logg økt" }, "templateEditor": { "titleEdit": "Rediger mal", @@ -405,7 +408,6 @@ "saveChanges": "Lagre endringer i malen", "useSession": "Bruk økt", "saveTemplate": "Lagre mal", - "stepIndicator": "Steg 2 av 3 — Tilpass øvelser", "nameLabel": "Navn på mal" } } diff --git a/app/src/components/Report.jsx b/app/src/components/Report.jsx index d3189d3..6c073a3 100644 --- a/app/src/components/Report.jsx +++ b/app/src/components/Report.jsx @@ -7,8 +7,7 @@ import { Tag, InlineLoading, DefinitionTooltip, Button, InlineNotification, } from "@carbon/react"; import { AiGenerate, Add, Checkmark } from "@carbon/icons-react"; -import PageShell, { SectionLabel, AccentChip, StickyCta } from "./PageShell"; -import { useNav } from "../lib/NavContext"; +import PageShell, { SectionLabel, AccentChip } from "./PageShell"; import { useTranslation } from "react-i18next"; import i18n from "../lib/i18n"; @@ -52,7 +51,6 @@ function KpiTile({ label, value }) { export default function Report({ prefill, onPrefillConsumed }) { const { t } = useTranslation(); - const { onShowBibliotek } = useNav(); const [periodDays, setPeriodDays] = useState(30); const [selectedDays, setSelectedDays] = useState(new Set()); const [selectedTypes, setSelectedTypes] = useState(new Set()); @@ -617,23 +615,6 @@ export default function Report({ prefill, onPrefillConsumed }) { )}
- {/* Sticky CTA to Bibliotek */} - {recs && recs.length > 0 && ( - - - - )}
); diff --git a/app/src/components/TemplatePicker.jsx b/app/src/components/TemplatePicker.jsx index 3332628..21072bb 100644 --- a/app/src/components/TemplatePicker.jsx +++ b/app/src/components/TemplatePicker.jsx @@ -21,10 +21,36 @@ export default function TemplatePicker({ onBack, onSelectTemplate }) { .finally(() => setLoading(false)); }, []); + const STEPS = [t("templatePicker.step1"), t("templatePicker.step2"), t("templatePicker.step3")]; + return (
+ + {/* Step indicator */} +
+ {STEPS.map((label, idx) => { + const isActive = idx === 0; + const isComplete = false; + return ( +
+
+ {String(idx + 1).padStart(2, "0")} +
+
+ {label} +
+
+ ); + })} +
+ {t("templatePicker.title")}

diff --git a/app/src/components/TemplateSessionEditor.jsx b/app/src/components/TemplateSessionEditor.jsx index 6e7af6b..8dc1a0a 100644 --- a/app/src/components/TemplateSessionEditor.jsx +++ b/app/src/components/TemplateSessionEditor.jsx @@ -135,11 +135,32 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem {mode === "edit" ? t("templateEditor.titleEdit") : t("templateEditor.titleUse")} - {mode === "use" && ( -

- {t("templateEditor.stepIndicator")} -

- )} + {mode === "use" && (() => { + const STEPS = [t("templatePicker.step1"), t("templatePicker.step2"), t("templatePicker.step3")]; + return ( +
+ {STEPS.map((label, idx) => { + const isActive = idx === 1; + const isComplete = idx < 1; + return ( +
+
+ {String(idx + 1).padStart(2, "0")} +
+
+ {label} +
+
+ ); + })} +
+ ); + })()} {/* Editable template name */}
From 4ecdc5c4eb798c6fc147f90443b240ffb82c98f5 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 7 May 2026 03:03:10 +0200 Subject: [PATCH 10/10] feat: restrict Claude recommendations to gym-hall equipment (no machines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both buildRecommendPrompt and buildPeriodRecommendPrompt now tell Claude the session takes place in a group gym hall with only free weights, bars, dumbbells, mats, yoga blocks and resistance bands — no weight machines. Co-Authored-By: Claude Sonnet 4.6 --- app/src/lib/prompts.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/lib/prompts.js b/app/src/lib/prompts.js index a36af92..5a76d4b 100644 --- a/app/src/lib/prompts.js +++ b/app/src/lib/prompts.js @@ -29,6 +29,7 @@ export const buildRecommendPrompt = (trained, untrained, lang = 'nb') => { return `Du er en personlig trener. Brukeren har trent disse musklene i dag: ${trained.join(", ")}. Muskelgrupper som IKKE er trent: ${untrained.join(", ")}. Foreslå 5 øvelser som dekker de utrente musklene. Gjerne øvelser som er vanlige på norske treningssentre. +Treningslokalet er en stor gymsal uten treningmaskiner. Tilgjengelig utstyr: frivekter, vektstenger, manualer, matter, yogablokker, strikk/resistance bands. Foreslå KUN øvelser som passer dette utstyret. Bruk KUN disse muscle-ID-ene: ${MUSCLE_IDS}. Returner KUN et JSON-array, ingen annen tekst, ingen backticks: [{"name":"Øvelsesnavn","primary":["muscle_id"],"secondary":["muscle_id"],"tip":"${tipInstruction}"}]`; @@ -49,6 +50,7 @@ export const buildPeriodRecommendPrompt = (periodDays, sessionCount, trainedLabe Trent (primær): ${trainedLabels || "ingen"}. Ikke trent: ${untrainedLabels || "alle muskelgrupper er dekket"}. Foreslå 5 øvelser som prioriterer de utrente musklene. Gjerne øvelser som er vanlige på norske treningssentre. +Treningslokalet er en stor gymsal uten treningmaskiner. Tilgjengelig utstyr: frivekter, vektstenger, manualer, matter, yogablokker, strikk/resistance bands. Foreslå KUN øvelser som passer dette utstyret. Bruk KUN disse muscle-ID-ene: ${MUSCLE_IDS}. Returner KUN et JSON-array, ingen annen tekst, ingen backticks: [{"name":"Øvelsesnavn","primary":["muscle_id"],"secondary":["muscle_id"],"tip":"${tipInstruction}"}]`;