diff --git a/CLAUDE.md b/CLAUDE.md index a76ab3f..409646d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Fully migrated to IBM Carbon Design System (issue #8, resolved 2026-04-29). - `MuscleMap.jsx` confirm step → 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`; `KpiTile` (42px Plex Light value); `muscleLastDate` in useMemo +- `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 - `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 → 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 @@ -168,7 +168,7 @@ week_plan_days ## Key architecture decisions - **i18n:** `app/src/lib/i18n.js` initialises `i18next` with `fallbackLng: "nb"` and three resource bundles (`nb`, `en`, `fa`). All components use `useTranslation()` for strings. All locale-aware date/time rendering uses `Intl.DateTimeFormat` with a `getIntlLocale()` helper that maps `"nb" → "no"` (the IETF tag `Intl` expects). Never use hardcoded locale strings like `"no-NO"` or `date-fns` locale objects — they break when the user switches language. The `i18n` singleton can be imported directly (`import i18n from "../lib/i18n"`) for `i18n.language` access outside hooks. RTL (`dir="rtl"`) is applied to `` automatically on language change. - **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), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`). 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`), `getIntlLocale()` (maps `i18n.language` to the IETF tag `Intl` expects, e.g. `"nb" → "no"`). 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 (``). @@ -186,7 +186,7 @@ week_plan_days - 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. -- **Sporty.no sync:** `app/api/sportySync.js` — timer trigger at 04:00, 11:00, and 14:00 UTC upserts today's sessions from `https://sporty.no/api/v1/businessunits/8/groupactivities` into `gym_calendar` by `sporty_id`. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` available for manual testing; accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data without re-running the live API). `GET /api/sporty-health` returns DB row counts and today's sessions — requires `x-api-key: ` header (same key as the sync endpoint). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user). +- **Sporty.no sync:** `app/api/sportySync.js` — timer trigger at 04:00, 11:00, and 14:00 UTC upserts today's sessions from `https://sporty.no/api/v1/businessunits/8/groupactivities` into `gym_calendar` by `sporty_id`. Business unit `8` is hardcoded — intentional for now (single-gym product); if extended to multiple gyms, this must become an env var or DB config. HTTP trigger `POST /api/sporty-sync` available for manual testing; accepts optional JSON body `{ "shiftDays": -7 }` to offset all timestamps by N days (useful for backfilling historical gym calendar data without re-running the live API). `GET /api/sporty-health` returns DB row counts (total rows, earliest/latest row timestamps, today's session count — no session list) — requires `x-api-key: ` header (same key as the sync endpoint). Requires `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `SPORTY_SYNC_API_KEY` as Azure app settings (service role needed because the timer has no auth user). - **Claude API proxy:** `app/api/claude.js` verifies incoming Supabase JWTs via `GET /auth/v1/user`. Requires `ANTHROPIC_API_KEY`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY` as Azure app settings. Use `SUPABASE_ANON_KEY` (no `VITE_` prefix) — the `VITE_` prefix is Vite build-time only and is invisible to the Azure Functions runtime. - **CI/CD build split:** the frontend is pre-built in the GitHub Actions runner (`npm ci && npm run build` with `VITE_*` in `env:`), then the Azure SWA action uploads `app/dist/` directly (`app_location: "app/dist"`). This bypasses Oryx for the frontend — Oryx strips `VITE_*` env vars before spawning Vite and they never reach the bundle. Oryx still handles the API (`app/api`). `vite.config.js` has a build-time assertion that fails immediately if the required vars are missing. - **Supabase client explicit apikey header:** `createClient` is called with `global: { headers: { apikey: supabaseKey } }` in `app/src/lib/supabase.js`. The Supabase JS v2 fetch interceptor should add this automatically, but it was not reaching browser requests — passing it in `global.headers` puts it directly on `PostgrestClient`'s base headers, bypassing the interceptor. Do not remove this option. diff --git a/README.md b/README.md index 9c6d983..526a793 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ app/ # week_plans, week_plan_days bodymap.jsx # Shared: MUSCLES, SHAPES, BodySVG, HeatmapBodySVG (onHover/hovered), calcMuscles, useIsMobile utils.js # toBase64, getMediaType, buildMuscleMap*, isInvalidNum, callClaude, extractMuscles, - # toWeekIso, weekIsoToMonday + # toWeekIso, weekIsoToMonday, getIntlLocale prompts.js # Claude model IDs + prompt builders i18n.js # i18next init — nb/en/fa resources, fallbackLng, RTL direction wiring public/ diff --git a/app/api/claudeUtils.js b/app/api/claudeUtils.js index 5d9d471..f6e68e2 100644 --- a/app/api/claudeUtils.js +++ b/app/api/claudeUtils.js @@ -4,6 +4,7 @@ export const ALLOWED_MODELS = new Set([ ]); export const MAX_TOKENS_LIMIT = 2000; +// Best-effort only: resets on cold start and is not shared across Azure Function instances. const rateLimitMap = new Map(); const RATE_LIMIT_REQUESTS = 10; const RATE_LIMIT_WINDOW_MS = 60_000; diff --git a/app/api/sportySync.js b/app/api/sportySync.js index bbe21a1..5922e24 100644 --- a/app/api/sportySync.js +++ b/app/api/sportySync.js @@ -160,7 +160,6 @@ app.http('sportySyncHealth', { earliestRow: earliest?.start_time ?? null, latestRow: latest?.start_time ?? null, todayCount: todaySessions.length, - todaySessions, }), { status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8' }, diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index be941e2..ef1373c 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -5,6 +5,7 @@ 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 PageShell, { SectionLabel, PageHeading, AccentChip } from "./PageShell"; import { fetchLibraryExercises, saveLibraryExercise, updateLibraryExercise, deleteLibraryExercise, @@ -295,7 +296,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { padding: "3px 10px", background: "rgba(69,137,255,.10)", border: "1px solid rgba(69,137,255,.25)", - color: "#78a9ff", + color: "var(--cds-blue-40)", fontFamily: "var(--cds-font-mono)", fontSize: 11, letterSpacing: "0.06em", }}> {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} @@ -370,7 +371,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {templates.map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; const usedAt = tpl.used_at - ? new Date(tpl.used_at).toLocaleDateString("no-NO") + ? 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; diff --git a/app/src/components/ExerciseRowWithAutocomplete.jsx b/app/src/components/ExerciseRowWithAutocomplete.jsx index 271211e..6fc250c 100644 --- a/app/src/components/ExerciseRowWithAutocomplete.jsx +++ b/app/src/components/ExerciseRowWithAutocomplete.jsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import ExerciseRow from "./ExerciseRow"; export default function ExerciseRowWithAutocomplete({ @@ -13,6 +13,9 @@ export default function ExerciseRowWithAutocomplete({ }) { const [showSuggestions, setShowSuggestions] = useState(false); const containerRef = useRef(); + const blurTimer = useRef(null); + + useEffect(() => () => { if (blurTimer.current) clearTimeout(blurTimer.current); }, []); const filtered = isNew && showSuggestions && exercise.name?.trim() @@ -41,7 +44,7 @@ export default function ExerciseRowWithAutocomplete({ }; const handleBlur = () => { - setTimeout(() => { + blurTimer.current = setTimeout(() => { if ( containerRef.current && !containerRef.current.contains(document.activeElement) diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 7a141d3..8d832b1 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -1,14 +1,8 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { format, parseISO } from "date-fns"; -import i18n from "../lib/i18n"; - -function getIntlLocale() { - const lang = i18n.language; - return lang === "nb" ? "no" : lang; -} import { fetchSessions, fetchSessionsByDate, fetchGymSessionsByDate, updateSession, checkGymCalendarConflict, fetchLibraryExercises } from "../lib/db"; import { MUSCLES, PRIMARY_FILL, SEC_FILL, calcMuscles } from "../lib/bodymap.jsx"; -import { toBase64, detectMediaType, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError } from "../lib/utils"; +import { toBase64, detectMediaType, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale } from "../lib/utils"; import { CLAUDE_MODEL_VISION, ANALYZE_PROMPT } from "../lib/prompts"; import { Button, Tag, InlineNotification, DefinitionTooltip, @@ -81,7 +75,7 @@ function MonthGrid({ year, month, sessionCountMap, onDayClick, selectedDate, tod borderRadius: 0, background: calHeatColor(count), border: "1px solid var(--border-subtle-wl)", - outline: isSelected ? "3px solid #ffffff" : isToday ? "1px dashed var(--cds-text-secondary)" : undefined, + outline: isSelected ? "3px solid var(--cds-background)" : isToday ? "1px dashed var(--cds-text-secondary)" : undefined, outlineOffset: isSelected ? "-3px" : "-2px", display: "flex", alignItems: "center", justifyContent: "center", }; @@ -192,6 +186,7 @@ export default function History({ initialDate }) { const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [libraryExercises, setLibraryExercises] = useState([]); + const libraryCache = useRef(null); const [newExerciseIds, setNewExerciseIds] = useState(new Set()); const [hoveredMuscle, setHoveredMuscle] = useState(null); const fileRef = useRef(); @@ -298,9 +293,13 @@ export default function History({ initialDate }) { fetchGymSessionsByDate(session.session_date) .then(setEditGymSessions) .catch(() => setEditGymSessions([])); - fetchLibraryExercises() - .then(setLibraryExercises) - .catch(() => {}); + if (libraryCache.current) { + setLibraryExercises(libraryCache.current); + } else { + fetchLibraryExercises() + .then(data => { libraryCache.current = data; setLibraryExercises(data); }) + .catch(() => {}); + } }; const cancelEdit = () => { diff --git a/app/src/components/Home.jsx b/app/src/components/Home.jsx index b4ea94c..557b107 100644 --- a/app/src/components/Home.jsx +++ b/app/src/components/Home.jsx @@ -1,25 +1,24 @@ import { useState, useEffect, useRef } from "react"; -import { format, parseISO, startOfISOWeek, addDays, getISOWeek } from "date-fns"; -import { nb } from "date-fns/locale"; +import { format, startOfISOWeek, addDays } from "date-fns"; import { InlineLoading } from "@carbon/react"; import { ArrowRight } from "@carbon/icons-react"; import { useTranslation } from "react-i18next"; import { BodySVG } from "../lib/bodymap.jsx"; import { fetchLastSession, fetchThisWeekSessions } from "../lib/db"; -import { extractMuscles, logDevError } from "../lib/utils"; +import { extractMuscles, logDevError, getIntlLocale, toWeekIso } from "../lib/utils"; import PageShell, { SectionLabel, AccentChip } from "./PageShell"; import { useNav } from "../lib/NavContext"; const WEEK_DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; function formatSessionDate(isoDate) { - const raw = format(parseISO(isoDate), "EEEE d. MMMM", { locale: nb }); + const raw = new Intl.DateTimeFormat(getIntlLocale(), { weekday: "long", day: "numeric", month: "long" }).format(new Date(isoDate + "T12:00:00")); return raw.charAt(0).toUpperCase() + raw.slice(1); } function formatTodayEyebrow(today) { - const day = format(today, "EEEE", { locale: nb }); - const week = getISOWeek(today); + const day = new Intl.DateTimeFormat(getIntlLocale(), { weekday: "long" }).format(today); + const week = parseInt(toWeekIso(today).split("-W")[1], 10); return `${day.charAt(0).toUpperCase() + day.slice(1)} · uke ${week}`; } @@ -98,8 +97,7 @@ export default function Home({ onShowHistoryWithDate }) {
{/* Hero card */} -
- {muscleLastDate[hoveredMuscle] && ( - {t("report.lastDate")} {format(new Date(muscleLastDate[hoveredMuscle] + "T12:00:00"), "d. MMM", { locale: nb })} + {t("report.lastDate")} {new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short" }).format(new Date(muscleLastDate[hoveredMuscle] + "T12:00:00"))} )}
@@ -458,7 +458,7 @@ export default function Report({ prefill, onPrefillConsumed }) { const countColor = primary > 0 ? "var(--cds-text-primary)" : secondary > 0 - ? "#4589ff" + ? "var(--cds-blue-40)" : "var(--text-disabled-wl)"; const countLabel = primary > 0 ? String(primary) diff --git a/app/src/components/TemplatePicker.jsx b/app/src/components/TemplatePicker.jsx index b24a1d3..660ead3 100644 --- a/app/src/components/TemplatePicker.jsx +++ b/app/src/components/TemplatePicker.jsx @@ -3,7 +3,7 @@ import { Button, InlineLoading, InlineNotification } from "@carbon/react"; import { Book } from "@carbon/icons-react"; import { useTranslation } from "react-i18next"; import { fetchTemplates } from "../lib/db"; -import { logDevError } from "../lib/utils"; +import { logDevError, getIntlLocale } from "../lib/utils"; import PageShell, { PageTitle, BackButton } from "./PageShell"; import { useNav } from "../lib/NavContext"; @@ -52,7 +52,7 @@ export default function TemplatePicker({ onBack, onSelectTemplate }) { {templates.map(tpl => { const exCount = tpl.session_template_exercises?.length || 0; const usedAt = tpl.used_at - ? new Date(tpl.used_at).toLocaleDateString("no-NO") + ? new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(new Date(tpl.used_at)) : null; return (