diff --git a/CHANGELOG.md b/CHANGELOG.md index 527fd90..0a14dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to Workout Lens are documented here. +## [1.2.0-rc.1] — 2026-05-05 + +### Added +- **Weekly training planner** — new `Planlegger` view (calendar icon in nav) lets users assign templates to each day of the week; a live `HeatmapBodySVG` shows projected cumulative muscle coverage; a Forslag card surfaces neglected muscles when ≥2 have no planned coverage; plan is persisted to Supabase (`week_plans` / `week_plan_days` tables with RLS) (#59) +- **Settings view** — dedicated settings screen (gear icon in nav) with theme toggle + live body map preview, account section (email + logout), version/changelog, and a contact section; replaces the old inline theme toggle and logout button in the header (#123) + +### Changed +- `EventSchedule` nav icon now navigates to the weekly planner (was a non-interactive placeholder after issue #123) +- Header reduced from a cluttered mix of function + utility icons to 6 clean icons: Camera, History, Report, Library, Planner, Settings — all at 48px on a 390px iPhone (#123) +- `ChangelogModal` moved from `PageShell` inline rendering to the Settings view (#123) +- Version footer button removed from `PageShell` (now shown in Settings → Om appen) (#123) + +### Infrastructure +- New Supabase tables: `week_plans` (user_id, week_iso UNIQUE per user) and `week_plan_days` (plan_id FK cascade, day_of_week 1–7, template_id nullable FK); RLS policies restrict to owning user (#59) + +--- + ## [1.1.0-rc.1] — 2026-05-05 First release candidate for beta testing. Builds on 1.0.0 with a full UI redesign and several usability improvements. diff --git a/CLAUDE.md b/CLAUDE.md index 6f2854e..915ce9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Fully migrated to IBM Carbon Design System (issue #8, resolved 2026-04-29). - `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`; `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 +- `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 → Book → EventSchedule (Planlegger) → 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` @@ -142,21 +142,46 @@ Name + muscles are denormalised into `session_template_exercises` so renaming a `touchTemplate(id)` updates `used_at` to now — called on "Bruk økt" so templates sort by recency in TemplatePicker. +## Week plan data model (issue #59) + +Two new Supabase tables: + +```sql +week_plans + id, user_id, week_iso text (e.g. "2026-W19"), created_at + UNIQUE (user_id, week_iso) + +week_plan_days + id, plan_id → week_plans (on delete cascade), day_of_week int (1=Mon…7=Sun), + template_id → session_templates (on delete set null, nullable), sort_order int +``` + +`week_plan_days.template_id` nullable — an empty slot is a valid row with `template_id = null`. RLS on both tables restricts all operations to the owning user (`auth.uid() = user_id` / exists check via join). + +`db.js` functions: +| Function | Description | +|---|---| +| `fetchWeekPlan(weekIso)` | Fetches `week_plans` + `week_plan_days` with joined template data. Returns `{ plan, days }`. | +| `saveWeekPlan(weekIso, assignments)` | Upserts `week_plans`, deletes + reinserts all `week_plan_days`. `assignments: [{ day_of_week, template_id }]`. | +| `deleteWeekPlan(weekIso)` | Deletes the `week_plans` row (cascade removes days automatically). | + ## Key architecture decisions - **Shared muscle/SVG module:** `app/src/lib/bodymap.jsx` exports `MUSCLES`, `SHAPES`, `EX_DB`, color constants (`PRIMARY_FILL`, `PRIMARY_HOVER`, `PRIMARY_STROKE`, heat vars), `calcMuscles`, `BodySVG`, `HeatmapBodySVG` (accepts `onHover(id|null)` and `hovered` props — when `onHover` is set the internal tooltip is suppressed), and `useIsMobile`. Do not duplicate these in component files. -- **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — injects Supabase JWT automatically), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary). Do not redefine these locally in component files. +- **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — injects Supabase JWT automatically), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`). Do not redefine these locally in component files. - **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (opus, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet, for recommendations), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`. All model IDs and prompt text live here; update in one place. - Claude returns muscle IDs directly in JSON — local keyword matching (EX_DB) was abandoned because Norwegian abbreviations and whiteboard variants didn't match reliably. EX_DB is kept only as fallback for manually added exercises. - 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`. 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`, `pickerDow`, `confirmDelete`, `saving`, `saveError`, `hoveredMuscle`. Computed via `useMemo`: `monday`, `weekIso`, `weekLabel` ("UKE N · D–D MÅNED"), `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 "Planlegg uken"` → `SectionLabel "PROJISERT DEKNING"` → `HeatmapBodySVG` (side-by-side/toggle) → 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`. 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: (1) Utseende — Carbon `Toggle` for dark/light theme with a live `BodyPanel` preview (fixed sample: primary `chest, quads, lats`; secondary `shoulders_front, hamstrings, triceps`); (2) Konto — logged-in email (read-only) + danger logout; (3) Om appen — version number + "Vis endringslogg" opening `ChangelogModal`; (4) Kontakt — feedback text + GitHub link; (5) Språk — non-interactive placeholder ("Kommer snart"). `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`. - **ExerciseForm:** `app/src/components/ExerciseForm.jsx` — form for creating/editing a library exercise (name, default sets/reps, MusclePicker). Props: `initial`, `onSave(fields)`, `onCancel()`, `saving`. Extracted from inline definition in `Bibliotek.jsx`. - **LibraryPicker:** `app/src/components/LibraryPicker.jsx` — searchable list of library exercises for adding to a template. Props: `libraryExercises[]`, `onAdd(exercise)`, `onClose()`. Extracted from inline definition in `TemplateSessionEditor.jsx`. - **ExerciseRowWithAutocomplete:** `app/src/components/ExerciseRowWithAutocomplete.jsx` — wrapper around `ExerciseRow` that adds an inline autocomplete dropdown when a new exercise name is typed. Only activates when `isNew` prop is true (IDs added during the current edit session, tracked via `newExerciseIds` Set in History). Props: all `ExerciseRow` props + `libraryExercises[]` + `isNew`. Library is fetched once when edit mode opens; failure degrades silently to manual entry. Uses `onMouseDown + e.preventDefault()` on suggestions to prevent input blur from closing the dropdown before the click fires. Used in `History.jsx` edit mode only — `ExerciseRow` is unchanged for `MuscleMap` and `TemplateSessionEditor`. - **API security:** `app/api/claude.js` requires a valid Supabase JWT on every request (`Authorization: Bearer `). Verifies via `GET /auth/v1/user`. Also enforces a model allowlist (`claude-opus-4-5`, `claude-sonnet-4-6`) and caps `max_tokens` at 2000. The `callClaude(body)` helper in `utils.js` injects the token automatically — all Claude calls must go through it. -- **Template navigation:** `App.jsx` manages views `"bibliotek"`, `"template-picker"`, `"template-editor"` alongside existing views. `App.jsx` also accumulates cross-cutting state as features land (`bibliotekInitialTab`, `pendingTemplateExercises`, history context state). This is acceptable at current scale — if more than 2–3 further pieces of cross-component state are needed, extract navigation and shared state to a React Context rather than continuing to lift into `App.jsx`. `bibliotekInitialTab` state ensures returning from template edit lands on the "Mal for gymtime" tab. When "Bruk økt" is pressed in `TemplateSessionEditor` (mode="use"), exercises pass to `MuscleMap` via `templatePreload` prop, triggering a `useEffect` that pre-fills the list and jumps to the confirm step. +- **Template navigation:** `App.jsx` manages views `"bibliotek"`, `"template-picker"`, `"template-editor"`, `"settings"`, `"planlegger"` alongside existing views. `App.jsx` also accumulates cross-cutting state as features land (`bibliotekInitialTab`, `pendingTemplateExercises`, history context state). This is acceptable at current scale — if more than 2–3 further pieces of cross-component state are needed, extract navigation and shared state to a React Context rather than continuing to lift into `App.jsx`. `bibliotekInitialTab` state ensures returning from template edit lands on the "Mal for gymtime" tab. When "Bruk økt" is pressed in `TemplateSessionEditor` (mode="use"), exercises pass to `MuscleMap` via `templatePreload` prop, triggering a `useEffect` that pre-fills the list and jumps to the confirm step. - Supabase Auth uses magic links (`emailRedirectTo: window.location.origin`) - Anthropic API calls go through `app/api/claude.js` — Azure Function v4 model, browser hits `/api/claude` - **Azure Functions entry point:** `app/api/index.js` imports all function files (`claude.js`, `sportySync.js`). `package.json#main` points to `index.js`. Azure Functions v4 only loads the single file referenced in `main` — add new function files here or they will never be registered. diff --git a/README.md b/README.md index 137e821..c411d1e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus 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 8. **Library** — build a named exercise library with click-to-toggle muscle selection; 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; a live heatmap body map shows projected cumulative muscle coverage; a Forslag card flags muscle groups with no planned coverage; plan is saved to Supabase and reloaded on next visit +10. **Settings** — theme toggle (dark/light) with live body map preview, account info, changelog, and contact section ## Tech stack @@ -58,7 +60,7 @@ app/ src/ main.jsx # Entry — imports Carbon + app CSS, wraps with ThemeProvider App.jsx # Auth gate + view router (logger, history, report, bibliotek, - # template-picker, template-editor) + # template-picker, template-editor, settings, planlegger) theme.jsx # ThemeProvider + useTheme hook (g10 ↔ g100 toggle) components/ Login.jsx # Magic-link email login @@ -74,15 +76,19 @@ app/ Bibliotek.jsx # Library page — exercise library CRUD + template CRUD (two tabs) TemplatePicker.jsx # Template selection screen (recently used first) TemplateSessionEditor.jsx # Edit/use a template with live body map; save-back or hand off to logger - PageShell.jsx # Shared nav shell (header, nav buttons, theme toggle, logout) + Planlegger.jsx # Weekly training planner — assign templates to days, projected heatmap + Settings.jsx # Settings view — theme toggle, account, changelog, contact + PageShell.jsx # Shared nav shell (6-icon header: camera/history/report/library/planner/settings) Home.jsx # Landing page — last session summary + quick-nav ErrorBoundary.jsx # Catches render errors and shows a reload prompt lib/ supabase.js # Supabase client db.js # DB helpers: sessions, exercises, muscle_activations, gym_calendar, - # exercise_library, session_templates, session_template_exercises + # exercise_library, session_templates, session_template_exercises, + # week_plans, week_plan_days bodymap.jsx # Shared: MUSCLES, SHAPES, BodySVG, HeatmapBodySVG (onHover/hovered), calcMuscles, useIsMobile - utils.js # toBase64, getMediaType, buildMuscleMap*, isInvalidNum, callClaude, extractMuscles + utils.js # toBase64, getMediaType, buildMuscleMap*, isInvalidNum, callClaude, extractMuscles, + # toWeekIso, weekIsoToMonday prompts.js # Claude model IDs + prompt builders styles/ carbon-tokens.css # IBM Carbon CSS variables (g10 + g100) + IBM Plex @font-face diff --git a/app/src/App.jsx b/app/src/App.jsx index 75e0832..d1af07b 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -10,6 +10,7 @@ import Bibliotek from "./components/Bibliotek"; import TemplatePicker from "./components/TemplatePicker"; import TemplateSessionEditor from "./components/TemplateSessionEditor"; import Settings from "./components/Settings"; +import Planlegger from "./components/Planlegger"; function App() { const [session, setSession] = useState(undefined); @@ -42,6 +43,7 @@ function App() { onShowTemplatePicker: () => setView("template-picker"), onShowReportWithPrefill: (prefill) => { setReportPrefill(prefill); setView("report"); }, onShowSettings: () => setView("settings"), + onShowPlanlegger: () => setView("planlegger"), }; let content; @@ -89,6 +91,8 @@ function App() { setView("logger"); }} />; + else if (view === "planlegger") + content = ; else content = @@ -164,7 +164,7 @@ export default function PageShell({ children }) { - + diff --git a/app/src/components/Planlegger.jsx b/app/src/components/Planlegger.jsx new file mode 100644 index 0000000..e1d9f80 --- /dev/null +++ b/app/src/components/Planlegger.jsx @@ -0,0 +1,621 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Button, InlineLoading, InlineNotification } from "@carbon/react"; +import { ChevronLeft, ChevronRight, Add, Close, TrashCan } from "@carbon/icons-react"; +import { fetchWeekPlan, saveWeekPlan, deleteWeekPlan, fetchTemplates } from "../lib/db"; +import { buildMuscleMapFromExercises } from "../lib/utils"; +import { toWeekIso } from "../lib/utils"; +import { calcMuscles, MUSCLES, HeatmapBodySVG, useIsMobile } from "../lib/bodymap.jsx"; +import { logDevError } from "../lib/utils"; +import PageShell, { SectionLabel, PageHeading, StickyCta, AccentChip } from "./PageShell"; + +const DAY_LABELS = ["MAN", "TIR", "ONS", "TOR", "FRE", "LØR", "SØN"]; + +function formatWeekLabel(monday) { + const sunday = new Date(monday); + sunday.setUTCDate(monday.getUTCDate() + 6); + + const monthNames = ["JAN", "FEB", "MAR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DES"]; + const startDay = monday.getUTCDate(); + const endDay = sunday.getUTCDate(); + const endMonth = monthNames[sunday.getUTCMonth()]; + + // ISO week number + const d = new Date(Date.UTC(monday.getUTCFullYear(), monday.getUTCMonth(), monday.getUTCDate())); + const dayOfWeek = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + + return `UKE ${week} · ${startDay}–${endDay} ${endMonth}`; +} + +function TemplatePicker({ templates, onSelect, onClose }) { + return ( +
+
e.stopPropagation()} + onKeyDown={e => e.key === "Escape" && onClose()} + style={{ + background: "var(--cds-layer-01)", + width: "100%", + maxWidth: 640, + maxHeight: "60vh", + overflowY: "auto", + borderTop: "2px solid var(--accent)", + paddingBottom: 16, + }} + > +
+ + Velg mal + + +
+ + {templates.length === 0 ? ( +

+ Ingen maler opprettet ennå. +

+ ) : ( +
+ {templates.map(tpl => ( + + ))} +
+ )} +
+
+ ); +} + +function DayRow({ dow, date, template, onAdd, onRemove }) { + const dateLabel = date + ? date.toLocaleDateString("no-NO", { day: "numeric", month: "short", timeZone: "UTC" }) + : ""; + + const muscles = useMemo(() => { + if (!template) return []; + const exercises = (template.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })); + const { primary, secondary } = calcMuscles(exercises); + const ids = [...new Set([...primary, ...secondary])]; + return ids.slice(0, 4).map(id => MUSCLES[id]?.label || id); + }, [template]); + + return ( +
+
+
+ {DAY_LABELS[dow - 1]} +
+
+ {dateLabel} +
+
+ + {template ? ( +
+
+
+ {template.name} +
+ {muscles.length > 0 && ( +
+ {muscles.map(m => ( + {m} + ))} +
+ )} +
+ +
+ ) : ( + + )} +
+ ); +} + +export default function Planlegger() { + const [weekOffset, setWeekOffset] = useState(0); + // assignments: { [dow: 1-7]: template | null } + const [assignments, setAssignments] = useState({}); + const [savedAssignments, setSavedAssignments] = useState({}); + const [templates, setTemplates] = useState([]); + const [pickerDow, setPickerDow] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [saveError, setSaveError] = useState(null); + const [hoveredMuscle, setHoveredMuscle] = useState(null); + const [mobileBodyView, setMobileBodyView] = useState("front"); + const isMobile = useIsMobile(); + + const today = useMemo(() => new Date(), []); + const monday = useMemo(() => { + const base = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + const day = base.getUTCDay() || 7; + base.setUTCDate(base.getUTCDate() - (day - 1) + weekOffset * 7); + return base; + }, [today, weekOffset]); + + const weekIso = useMemo(() => toWeekIso(monday), [monday]); + const weekLabel = useMemo(() => formatWeekLabel(monday), [monday]); + + // Dates for each day of the week + const weekDates = useMemo(() => { + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); + }, [monday]); + + // Projected body map from all assigned templates + const projectedData = useMemo(() => { + const allExercises = Object.values(assignments) + .filter(Boolean) + .flatMap(tpl => + (tpl.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })) + ); + const { primary, secondary } = calcMuscles(allExercises); + const muscleMap = buildMuscleMapFromExercises(allExercises); + + // Build counts for HeatmapBodySVG + const counts = {}; + primary.forEach(id => { counts[id] = { primary: 1, secondary: 0 }; }); + secondary.forEach(id => { + if (!counts[id]) counts[id] = { primary: 0, secondary: 1 }; + }); + + return { primary, secondary, muscleMap, counts }; + }, [assignments]); + + const sessionCount = useMemo( + () => Object.values(assignments).filter(Boolean).length, + [assignments] + ); + + const muscleGroupCount = useMemo( + () => new Set([...projectedData.primary, ...projectedData.secondary]).size, + [projectedData] + ); + + const untrainedMuscleIds = useMemo(() => { + const trained = new Set([...projectedData.primary, ...projectedData.secondary]); + return Object.keys(MUSCLES).filter(id => !trained.has(id)); + }, [projectedData]); + + const showForslag = untrainedMuscleIds.length >= 2 && sessionCount > 0; + + const forslagTemplates = useMemo(() => { + if (!showForslag) return []; + const untrainedSet = new Set(untrainedMuscleIds); + return templates + .map(tpl => { + const exercises = (tpl.session_template_exercises || []).map(e => ({ + name: e.name, + primary: e.primary_muscles || [], + secondary: e.secondary_muscles || [], + enabled: true, + })); + const { primary, secondary } = calcMuscles(exercises); + const covered = [...primary, ...secondary].filter(id => untrainedSet.has(id)).length; + return { tpl, covered }; + }) + .filter(x => x.covered > 0) + .sort((a, b) => b.covered - a.covered) + .slice(0, 3) + .map(x => x.tpl); + }, [showForslag, untrainedMuscleIds, templates]); + + const loadPlan = useCallback(async (iso) => { + setLoading(true); + setSaveError(null); + try { + const [{ days }, tpls] = await Promise.all([fetchWeekPlan(iso), fetchTemplates()]); + setTemplates(tpls); + const map = {}; + (days || []).forEach(d => { + map[d.day_of_week] = d.session_templates || null; + }); + setAssignments(map); + setSavedAssignments(map); + } catch (e) { + logDevError("Planlegger/loadPlan", e); + setSaveError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadPlan(weekIso); + }, [weekIso, loadPlan]); + + // Close picker on Escape + useEffect(() => { + if (pickerDow === null) return; + const handler = (e) => { if (e.key === "Escape") setPickerDow(null); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [pickerDow]); + + const handleAssign = (dow, tpl) => { + setAssignments(prev => ({ ...prev, [dow]: tpl })); + setPickerDow(null); + }; + + const handleRemove = (dow) => { + setAssignments(prev => ({ ...prev, [dow]: null })); + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + const asgn = Object.entries(assignments) + .filter(([, tpl]) => tpl) + .map(([dow, tpl]) => ({ day_of_week: parseInt(dow, 10), template_id: tpl.id })); + await saveWeekPlan(weekIso, asgn); + setSavedAssignments({ ...assignments }); + } catch (e) { + logDevError("Planlegger/saveWeekPlan", e); + setSaveError(e.message); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setSaveError(null); + try { + await deleteWeekPlan(weekIso); + setAssignments({}); + setSavedAssignments({}); + setConfirmDelete(false); + } catch (e) { + logDevError("Planlegger/deleteWeekPlan", e); + setSaveError(e.message); + } finally { + setDeleting(false); + } + }; + + const hasSavedPlan = Object.values(savedAssignments).some(Boolean); + + return ( + +
+ + {/* Week navigation */} +
+ + + {weekLabel} + + +
+ + Planlegg uken + + {loading ? ( + + ) : ( + <> + {/* Projected coverage */} + Projisert dekning + +

+ {sessionCount} {sessionCount === 1 ? "økt" : "økter"} · {muscleGroupCount} muskelgrupper +

+ + {/* Body map */} + {isMobile ? ( +
+
+ {["front", "back"].map(v => ( + + ))} +
+
+ +
+
+ ) : ( +
+ {["front", "back"].map(view => ( +
+ +
+ ))} +
+ )} + + {hoveredMuscle && projectedData.muscleMap[hoveredMuscle]?.length > 0 && ( +
+ {MUSCLES[hoveredMuscle]?.label}:{" "} + {projectedData.muscleMap[hoveredMuscle].join(", ")} +
+ )} + + {/* Forslag card */} + {showForslag && ( +
+

+ {untrainedMuscleIds.length} muskelgrupper er ikke dekket denne uken +

+
0 ? 10 : 0 }}> + {untrainedMuscleIds.map(id => ( + {MUSCLES[id]?.label || id} + ))} +
+ {forslagTemplates.length > 0 && ( + <> +

+ Maler som dekker disse: +

+
+ {forslagTemplates.map(tpl => ( + + · {tpl.name} + + ))} +
+ + )} +
+ )} + + {/* Week plan */} + Ukesplan + + {saveError && ( + + )} + +
+ {Array.from({ length: 7 }, (_, i) => i + 1).map(dow => ( + + ))} +
+ + {/* Confirm delete strip */} + {confirmDelete && ( +
+ + Fjerne hele ukeplanen? + +
+ + +
+
+ )} + + )} +
+ + {/* Sticky action bar */} + {!loading && ( + +
+ {hasSavedPlan && !confirmDelete && ( + + )} + +
+
+ )} + + {/* Template picker bottom sheet */} + {pickerDow !== null && ( + handleAssign(pickerDow, tpl)} + onClose={() => setPickerDow(null)} + /> + )} +
+ ); +} diff --git a/app/src/lib/db.js b/app/src/lib/db.js index ebd0b93..1e59cec 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -281,6 +281,71 @@ export async function checkGymCalendarConflict(gymCalendarId, excludeSessionId = return data; // null = no conflict } +// ── WEEK PLANS ──────────────────────────────────────────────────────── + +export async function fetchWeekPlan(weekIso) { + const { data: plan, error: planError } = await supabase + .from("week_plans") + .select("id, week_iso") + .eq("week_iso", weekIso) + .maybeSingle(); + if (planError) throw planError; + if (!plan) return { plan: null, days: [] }; + + const { data: days, error: daysError } = await supabase + .from("week_plan_days") + .select(` + id, day_of_week, sort_order, + template_id, + session_templates( + id, name, + session_template_exercises( + id, name, primary_muscles, secondary_muscles, sets, reps, sort_order + ) + ) + `) + .eq("plan_id", plan.id) + .order("day_of_week", { ascending: true }); + if (daysError) throw daysError; + + return { plan, days: days || [] }; +} + +// assignments: [{ day_of_week: 1..7, template_id: uuid|null }] +export async function saveWeekPlan(weekIso, assignments) { + const { data: { user } } = await supabase.auth.getUser(); + + const { data: plan, error: upsertError } = await supabase + .from("week_plans") + .upsert({ user_id: user.id, week_iso: weekIso }, { onConflict: "user_id,week_iso" }) + .select("id") + .single(); + if (upsertError) throw upsertError; + + await supabase.from("week_plan_days").delete().eq("plan_id", plan.id); + + if (assignments.length > 0) { + const rows = assignments.map((a, i) => ({ + plan_id: plan.id, + day_of_week: a.day_of_week, + template_id: a.template_id || null, + sort_order: i, + })); + const { error: insertError } = await supabase.from("week_plan_days").insert(rows); + if (insertError) throw insertError; + } + + return plan; +} + +export async function deleteWeekPlan(weekIso) { + const { error } = await supabase + .from("week_plans") + .delete() + .eq("week_iso", weekIso); + if (error) throw error; +} + export async function updateSession(sessionId, exercises, gymCalendarId, { replace = false } = {}) { const enabledExercises = exercises.filter(e => e.enabled && e.name); diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index e4fc5db..9b86f18 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -112,6 +112,28 @@ export function buildMuscleMapFromSession(session) { return map; } +// Returns ISO week string e.g. "2026-W19" for a given Date. +export function toWeekIso(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +// Returns the Monday Date for a given ISO week string e.g. "2026-W19". +export function weekIsoToMonday(weekIso) { + const [yearStr, weekStr] = weekIso.split("-W"); + const year = parseInt(yearStr, 10); + const week = parseInt(weekStr, 10); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const day = jan4.getUTCDay() || 7; + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - (day - 1) + (week - 1) * 7); + return monday; +} + // Builds muscle-ID → exercise-name map from a recommendations array. export function buildRecMuscleMap(recs) { const map = {};