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 f1ee92b..002456d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,29 @@ 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 -- **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. +- **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. ### 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..0ee5f3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,18 +49,18 @@ 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; 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 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` → 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` @@ -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`. @@ -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 @@ -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/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..5a757ac 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", @@ -110,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.", @@ -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", @@ -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.", @@ -228,14 +227,16 @@ "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" }, "planlegger": { "heading": "Plan the week", "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", @@ -292,7 +293,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 +321,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 +360,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", @@ -386,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", @@ -398,6 +407,7 @@ "manual": "Manually", "saveChanges": "Save template changes", "useSession": "Use session", - "saveTemplate": "Save template" + "saveTemplate": "Save template", + "nameLabel": "Template name" } } diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index 7bc63d7..91cb0a7 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": "سینه", @@ -110,7 +111,7 @@ "dropzoneLabel": "آپلود تصویر تمرین", "dropzoneClick": "برای انتخاب تصویر ضربه بزنید", "dropzoneDrag": "یا بکشید و رها کنید · JPEG، PNG، WebP", - "useTemplate": "قالب", + "useTemplate": "قالب‌های من", "manualEntry": "ورود دستی", "tipsHeading": "راهنما", "tipsBody": "نور مناسب و نمایش کامل تخته در تصویر بهترین نتیجه را می‌دهد. چند تصویر پشتیبانی می‌شود.", @@ -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": "بارگذاری جلسات همکاران ممکن نشد", @@ -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": "هنوز تمرینی اضافه نشده.", @@ -228,14 +227,16 @@ "usedInTemplates_one": "این تمرین در قالب استفاده شده", "usedInTemplates_other": "این تمرین در قالب‌ها استفاده شده", "exerciseRemovedWarning": "و از آن حذف خواهد شد.", - "exerciseCount": "{{count}} تمرین" + "exerciseCount": "{{count}} تمرین", + "searchTemplates": "جستجو در قالب‌ها…", + "showMore": "نمایش {{count}} بیشتر" }, "planlegger": { "heading": "برنامه‌ریزی هفته", "prevWeek": "هفته قبل", "nextWeek": "هفته بعد", "notTrainedThisWeek": "تمرین‌نشده این هفته", - "allMusclesTrained": "هر ۱۷ عضله این هفته تمرین شده", + "allMusclesTrained": "هر ۱۷ عضله این هفته برنامه‌ریزی شده", "projectedCoverage": "پوشش پیش‌بینی‌شده", "weekSummary_one": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", "weekSummary_other": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", @@ -292,7 +293,8 @@ "languagePersian": "فارسی", "myGym": "باشگاه من", "myGymMembership": "Sporty Thon Senter Ski", - "myGymFutureHint": "به زودی: انتخاب باشگاه و مشاهده تاریخچه مشترک با سایر مربیان." + "myGymFutureHint": "به زودی: انتخاب باشگاه و مشاهده تاریخچه مشترک با سایر مربیان.", + "dataSharingNote": "تمام جلسات ثبت‌شده برای سایر مربیان در همان مرکز ورزشی قابل مشاهده است. این برای ارائه بینش درباره تمرین همکاران ضروری است." }, "report": { "heroMuscles_one": "{{count}} عضله", @@ -319,6 +321,8 @@ "noSessions": "جلسه‌ای برای فیلتر انتخاب‌شده یافت نشد.", "saveRecError": "ذخیره تمرین ناموفق بود. دوباره امتحان کنید.", "fetchRecError": "دریافت پیشنهادها ناموفق بود. دوباره امتحان کنید.", + "allMusclesPrimary": "همه ۱۷ گروه عضلانی در این دوره تمرین شده. آفرین!", + "allMusclesSecondaryNote": "همه عضلات اولیه تمرین شده. اینها فقط ثانویه تمرین شده‌اند:", "toCta": "این‌ها را به برنامه‌ات اضافه کن →", "period": "دوره", "activeDays": "روزهای فعال", @@ -356,7 +360,9 @@ "defaultSets": "ست پیش‌فرض", "defaultReps": "تکرار پیش‌فرض", "saveExercise": "ذخیره تمرین", - "noMusclesWarning": "هیچ عضله‌ای انتخاب نشده — روی شکل کلیک کنید" + "noMusclesWarning": "هیچ عضله‌ای انتخاب نشده — روی شکل کلیک کنید", + "headerNew": "تمرین جدید", + "headerEdit": "ویرایش تمرین" }, "libraryPicker": { "searchLabel": "جستجو در کتابخانه تمرین‌ها", @@ -386,7 +392,10 @@ "goToLibrary": "رفتن به کتابخانه", "lastUsed": "آخرین استفاده: {{date}}", "exerciseCount_one": "{{count}} تمرین", - "exerciseCount_other": "{{count}} تمرین" + "exerciseCount_other": "{{count}} تمرین", + "step1": "انتخاب قالب", + "step2": "تنظیم", + "step3": "ثبت جلسه" }, "templateEditor": { "titleEdit": "ویرایش قالب", @@ -398,6 +407,7 @@ "manual": "دستی", "saveChanges": "ذخیره تغییرات قالب", "useSession": "شروع جلسه", - "saveTemplate": "ذخیره قالب" + "saveTemplate": "ذخیره قالب", + "nameLabel": "نام قالب" } } diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index ccc0546..f7d66ae 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", @@ -110,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.", @@ -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", @@ -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å.", @@ -228,14 +227,16 @@ "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" }, "planlegger": { "heading": "Planlegg uken", "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", @@ -292,7 +293,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 +321,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 +360,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", @@ -386,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", @@ -398,6 +407,7 @@ "manual": "Manuelt", "saveChanges": "Lagre endringer i malen", "useSession": "Bruk økt", - "saveTemplate": "Lagre mal" + "saveTemplate": "Lagre mal", + "nameLabel": "Navn på mal" } } diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index ef1373c..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 }) { @@ -42,6 +41,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 +56,18 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { .finally(() => setTplLoading(false)); }, []); - const filteredExercises = useMemo( - () => debouncedSearch.trim() + const filteredExercises = useMemo(() => { + return debouncedSearch.trim() ? exercises.filter(e => e.name.toLowerCase().includes(debouncedSearch.toLowerCase().trim())) - : exercises, - [exercises, debouncedSearch] - ); + : exercises; + }, [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; + }, [templates, tplSearch]); const handleSaveNewExercise = async (fields) => { setSavingEx(true); @@ -180,49 +188,6 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { )} - {!showNewEx && ( - - )} - - {/* Shortcut carousel — template shortcuts */} - {!tplLoading && templates.length > 0 && ( -
-

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

-
- {templates.map(tpl => { - const exCount = tpl.session_template_exercises?.length || 0; - return ( - - ); - })} -
-
- )} - {/* Search */} {!exLoading && exercises.length > 0 && (
@@ -248,6 +213,13 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
)} + {!showNewEx && ( + + )} + {showNewEx && ( ) : ( -
- {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,19 +348,41 @@ 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)) - : null; const tplPrimary = [...new Set((tpl.session_template_exercises || []).flatMap(e => e.primary_muscles || []))]; const muscleCount = tplPrimary.length; return ( @@ -395,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
+ )} + )} )} 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("")} - - )} -
- ); - })} - + {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")} -
- ); - })} -
- )} + {/* Action row: add exercise + re-upload photo */} +
+ + +
- {/* 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 +773,37 @@ 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; + }} /> + + {/* 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/Login.jsx b/app/src/components/Login.jsx index e05ea9e..1abdaa8 100644 --- a/app/src/components/Login.jsx +++ b/app/src/components/Login.jsx @@ -4,27 +4,23 @@ import { supabase } from "../lib/supabase"; import { Button, TextInput, InlineNotification } from "@carbon/react"; import { Email } from "@carbon/icons-react"; -// Daily quotes stay in Norwegian regardless of language setting. function getDailyQuote() { const now = new Date(); const mmdd = String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0"); const special = { - "01-01": "Nytt år, ny treningslogg. Dag 1 av 365.", - "05-05": "05/05 – en dato som ser like bra ut baklengs. En god økt gjør det samme.", - "05-17": "Gratulerer med dagen! 17. mai feires best med bein som allerede er slitne.", - "12-24": "Julaften. Treningssalen er tom – det er din fordel.", + "01-01": "New year, new training log. Day 1 of 365.", + "12-24": "Christmas Eve. The gym is empty — that's your advantage.", }; if (special[mmdd]) return special[mmdd]; - const weekday = now.getDay(); // 0=sun return [ - "Søndag er ikke hviledag – det er oppladningsdag.", - "Mandag: uken starter med deg.", - "Tirsdag. Ikke mandag-angst, ikke fredags-latskap. Bare ren treningslyst.", - "Onsdag – midtpunktet. Perfekt dag for et personlig rekord.", - "Torsdag: en økt i dag og du går inn i helgen med samvittigheten i orden.", - "Fredag! Siste sjanse til å gjøre uken komplett.", - "Lørdag – de beste øktene skjer når ingen forventer det.", - ][weekday]; + "Sunday isn't a rest day — it's a recharge day.", + "Monday: the week starts with you.", + "Tuesday. No Monday dread, no Friday laziness. Just pure drive.", + "Wednesday — the midpoint. Perfect day for a personal best.", + "Thursday: one session today and you enter the weekend with a clear conscience.", + "Friday! Last chance to make the week complete.", + "Saturday — the best sessions happen when no one expects it.", + ][now.getDay()]; } export default function Login() { diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index c59228d..25d2804 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"; @@ -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, @@ -427,16 +427,6 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }
- {/* Tips callout */} -
-

{t("muscleMap.tipsHeading")}

-

{t("muscleMap.tipsBody")}

-
{sizeError && ( @@ -497,6 +487,10 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {/* ── CONFIRM ── */} {step === "confirm" && ( +
+ + {t("muscleMap.confirmLabel")} +
{/* Hero */} @@ -666,6 +660,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }
+
)} {/* ── RESULTAT ── */} @@ -777,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 && ( 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 +295,6 @@ export default function Report({ prefill, onPrefillConsumed }) { {dayLabel} - {/* Hero */} -
-

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

-

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

-
- {/* Filters */}
{/* Row 1: period */} @@ -424,24 +414,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 +468,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 && (

@@ -652,23 +615,6 @@ export default function Report({ prefill, onPrefillConsumed }) { )}

- {/* Sticky CTA to Bibliotek */} - {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")} +

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")}

@@ -51,9 +77,6 @@ export default function TemplatePicker({ onBack, onSelectTemplate }) {

{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/components/TemplateSessionEditor.jsx b/app/src/components/TemplateSessionEditor.jsx index 8837354..8dc1a0a 100644 --- a/app/src/components/TemplateSessionEditor.jsx +++ b/app/src/components/TemplateSessionEditor.jsx @@ -1,10 +1,10 @@ import { useState, useEffect, useMemo } from "react"; import { - Button, Tag, InlineNotification, + Button, Tag, InlineNotification, TextInput, } from "@carbon/react"; -import { Add, ArrowLeft, ArrowRight, Save } from "@carbon/icons-react"; +import { Add, ArrowLeft, ArrowRight, Save, Edit } from "@carbon/icons-react"; import { useTranslation } from "react-i18next"; -import PageShell, { PageTitle, BackButton } from "./PageShell"; +import PageShell, { SectionLabel, BackButton } from "./PageShell"; import { fetchLibraryExercises, replaceTemplateExercises, touchTemplate, updateTemplateName } from "../lib/db"; import { calcMuscles } from "../lib/bodymap.jsx"; import { buildMuscleMapFromExercises, logDevError } from "../lib/utils"; @@ -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); } @@ -132,42 +129,61 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem
- {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" && (() => { + 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 */} +
+ {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 +245,21 @@ export default function TemplateSessionEditor({ template, mode, onBack, onUseTem {/* Action bar */}
{mode === "use" && ( - <> - -
- - -
- +
+ + +
)} {mode === "edit" && (
-
+
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 && (
{ 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}"}]`; 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); diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..4d54b08 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,52 @@ +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" + +[auth.email.template.invite] +subject = "You have been invited to Workout Lens" +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" + +[auth.email.template.reauthentication] +subject = "Confirm your identity on Workout Lens" +content_path = "./supabase/templates/reauthentication.html" diff --git a/supabase/templates/confirmation.html b/supabase/templates/confirmation.html new file mode 100644 index 0000000..97e3365 --- /dev/null +++ b/supabase/templates/confirmation.html @@ -0,0 +1,26 @@ +
+
+ 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/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/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. +
+
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. +
+
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. +
+