diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ea83b..986463b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Workout Lens are documented here. +## [1.2.9] — 2026-05-14 + +### Fixed +- **HEIF/iPhone photo exceeds 5 MB limit** — root cause: Anthropic enforces the 5 MB limit on the **base64 string character count**, not the decoded byte size. A 3.75 MB decoded image produces ~5.25 M base64 chars and is rejected. `compressImage` was checking `b64.length * 0.75 <= 5 MB` (decoded bytes), which passes an image up to ~6.67 M base64 chars — well over the limit. Fixed by changing all checks to compare the base64 string length directly: `b64.length <= MAX_B64_CHARS`. Additionally fixed iOS-specific canvas source issue: `img.src = dataUrl` (large base64 data URL) causes iOS Safari to silently zero `naturalWidth`/`naturalHeight`, producing a blank canvas — fixed by using `URL.createObjectURL(file)` instead. +- **ALL CAPS exercise names** — when canvas quality reduction degrades the image enough for Claude to return exercise names in ALL CAPS, `normalizeExName` in `MuscleMap.jsx` converts fully-uppercase strings to title case before they reach the exercise list. Acts as a permanent safety net. +- **Anthropic error detail not shown** — the error message surfaced to the user read `data?.error?.message`, but the Anthropic API returns the detail in `data.detail` (string). Fixed to read `data.detail || data?.error?.message`. +- **"Siste økt" showing empty despite a session existing** — `fetchLastSession` in `db.js` used `.maybeSingle()` which sends PostgREST `Accept: application/vnd.pgrst.object+json`. PostgREST returns 406 when multiple rows exist even with `limit=1` (the 406 check precedes limit application). `.maybeSingle()` silently converts 406 to `{ data: null, error: null }`. Fixed by removing `.maybeSingle()` and using `data?.[0] ?? null` on a plain array query. +- **Slow app load after gym-wide templates deploy** — `onAuthStateChange` in `App.jsx` called `ensureGymMembership()` and `ensureDisplayName()` on every Supabase auth event (INITIAL_SESSION, TOKEN_REFRESHED, etc.), causing 3–4 redundant DB upserts per page load. These calls now only fire on `SIGNED_IN` events. + ## [1.2.8] — 2026-05-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1d8bf9f..fe3f159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -412,3 +412,23 @@ Carbon's compiled CSS from `@carbon/styles` emits dark skeleton token overrides **Pattern to watch:** Any new Carbon token that Carbon's SCSS emits only under `.cds--g100` (not `:root`) must be explicitly added to the `[data-theme="g100"]` block in `carbon-tokens.css`. Run a visual check in dark mode whenever a new Carbon component is introduced. +### Issue #173 — HEIF photo exceeds Anthropic 5 MB limit (resolved 2026-05-14) +Symptom: uploading an iPhone 17 Pro photo failed with `Serverfeil (400): image exceeds 5 MB maximum: 5246896 bytes > 5242880 bytes`. + +**Root cause 1 — Wrong comparison unit in `compressImage`:** Anthropic enforces the 5 MB limit on the **base64 string character count**, not the decoded byte size. `compressImage` was checking `b64.length * 0.75 <= 5 MB` (decoded bytes ≤ 5 MB), which allows base64 strings up to ~6.67 M chars. A 3.75 MB decoded image produces ~5.25 M base64 chars — passes our check, fails Anthropic's. Fixed by changing all checks to `b64.length <= MAX_B64_CHARS` (5,242,880) and setting the canvas compression target to 90% of that limit. + +**Root cause 2 — iOS silently ignores large data URLs as `img.src`:** The original canvas fallback path set `img.src = dataUrl` (the ~9 MB base64 string from FileReader). iOS Safari silently fails to decode a data URL this large: `img.naturalWidth` and `img.naturalHeight` both become 0. The canvas is created as 0×0, `toDataURL` returns a near-empty result that passes the size check, and the original un-compressed data stays in state. No error is thrown. Fixed by using `URL.createObjectURL(file)` as the image source instead. + +**Root cause 3 — iOS Safari ignores `canvas.toDataURL` quality parameter:** iOS Safari on some versions silently ignores the `quality` argument and always outputs at default quality (~0.92). Dimension reduction (canvas pixel dimensions) is the only reliable lever on iOS — not quality stepping. + +**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height. Do not use `b64.length * 0.75` to compare against Anthropic's limit — compare `b64.length` directly. + +### Issue #173 — fetchLastSession returning null (resolved 2026-05-14) +Symptom: Home → "Siste økt" showed "Ingen økter logget ennå" even though sessions existed in the DB and the weekly strip showed the correct session count. + +**Root cause — `.maybeSingle()` with multiple rows in the sessions table:** `fetchLastSession` used `.limit(1).maybeSingle()`. `.maybeSingle()` sends PostgREST `Accept: application/vnd.pgrst.object+json`. PostgREST evaluates the "single row" constraint and returns 406 when the base query (before LIMIT) would produce multiple rows — the LIMIT is not applied before this check. `.maybeSingle()` in Supabase JS v2 silently converts a 406 (PGRST116) to `{ data: null, error: null }`, so `fetchLastSession` returned null without any error. Works fine with 1 session in the DB; silently breaks once there are 2+ sessions. + +**Fix:** Removed `.maybeSingle()`. Changed to a plain array query (`.limit(1)` without `.maybeSingle()`) and returned `data?.[0] ?? null`. The simpler approach avoids the `Accept: application/vnd.pgrst.object+json` header entirely. + +**Pattern to watch:** Do not combine `.limit(1)` with `.maybeSingle()` in Supabase JS v2 when the table can have multiple rows. Use `.limit(1)` with an array query and take `data?.[0]` instead. `.maybeSingle()` is only safe on queries where the base set is already guaranteed to be 0 or 1 rows (e.g. `.eq("id", id)` on a primary key). + diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index f903168..c058488 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -85,6 +85,8 @@ "seeAll": "SEE ALL →", "loading": "Loading last session…", "noSessions": "No sessions logged yet. Log your first session!", + "muscleCount_one": "{{count}} muscle group", + "muscleCount_other": "{{count}} muscle groups", "ownTraining": "Personal training", "train": "Train.", "today": "Today.", diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json index f03ea9c..7631151 100644 --- a/app/public/locales/fa/translation.json +++ b/app/public/locales/fa/translation.json @@ -85,6 +85,8 @@ "seeAll": "مشاهده همه →", "loading": "در حال بارگذاری آخرین جلسه…", "noSessions": "هنوز جلسهای ثبت نشده. اولین جلسه خود را ثبت کنید!", + "muscleCount_one": "{{count}} گروه عضلانی", + "muscleCount_other": "{{count}} گروه عضلانی", "ownTraining": "تمرین شخصی", "train": "تمرین کن.", "today": "امروز.", diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json index ee0bc5c..fe4c11d 100644 --- a/app/public/locales/nb/translation.json +++ b/app/public/locales/nb/translation.json @@ -85,6 +85,8 @@ "seeAll": "SE ALLE →", "loading": "Laster siste økt…", "noSessions": "Ingen økter logget ennå. Logg din første økt!", + "muscleCount_one": "{{count}} muskelgruppe", + "muscleCount_other": "{{count}} muskelgrupper", "ownTraining": "Egentrening", "train": "Tren.", "today": "I dag.", diff --git a/app/public/staticwebapp.config.json b/app/public/staticwebapp.config.json index 14c4a10..fe4daeb 100644 --- a/app/public/staticwebapp.config.json +++ b/app/public/staticwebapp.config.json @@ -7,6 +7,18 @@ { "route": "/api/*", "allowedRoles": ["anonymous"] + }, + { + "route": "/", + "headers": { + "Cache-Control": "no-store" + } + }, + { + "route": "/index.html", + "headers": { + "Cache-Control": "no-store" + } } ], "globalHeaders": { diff --git a/app/src/App.jsx b/app/src/App.jsx index 1c9be12..172a064 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { supabase } from "./lib/supabase"; import { ensureGymMembership, ensureDisplayName } from "./lib/db"; import { NavContext } from "./lib/NavContext"; @@ -24,14 +24,21 @@ function App() { const [reportPrefill, setReportPrefill] = useState(null); const [introOpen, setIntroOpen] = useState(false); + const ensuredRef = useRef(false); useEffect(() => { + const runEnsures = () => { + if (ensuredRef.current) return; + ensuredRef.current = true; + ensureGymMembership().catch(() => {}); + ensureDisplayName().catch(() => {}); + }; supabase.auth.getSession().then(({ data: { session } }) => { setSession(session); - if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } + if (session) runEnsures(); }); - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { setSession(session); - if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } + if ((event === "SIGNED_IN" || event === "INITIAL_SESSION") && session) runEnsures(); }); return () => subscription.unsubscribe(); }, []); diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 110bc73..fbc25f5 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { fetchSessions, fetchSessionsByDate, fetchGymSessionsByDate, updateSession, checkGymCalendarConflict, fetchLibraryExercises, fetchClassHistory } from "../lib/db"; import { MUSCLES, calcMuscles } from "../lib/bodymap.jsx"; -import { toBase64, detectMediaType, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale, toIsoDate } from "../lib/utils"; +import { compressImage, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale, toIsoDate } from "../lib/utils"; import { CLAUDE_MODEL_VISION, ANALYZE_PROMPT } from "../lib/prompts"; import { Button, Tag, InlineNotification, InlineLoading, @@ -374,8 +374,7 @@ export default function History({ initialDate }) { if (!file) return; patchSessionEdit(session.id, { analyzing: true, analyzeError: null }); try { - const mt = await detectMediaType(file); - const b64 = await toBase64(file); + const { base64: b64, mediaType: mt } = await compressImage(file); const res = await callClaude({ model: CLAUDE_MODEL_VISION, max_tokens: 1500, @@ -390,7 +389,7 @@ export default function History({ initialDate }) { let data; try { data = await res.json(); } catch { throw new Error(t("history.reanalyzeServerError", { status: res.status })); } if (!res.ok) { - const detail = data?.error?.message; + const detail = data?.detail || data?.error?.message; throw new Error(detail ? t("history.reanalyzeServerErrorDetail", { status: res.status, detail }) : t("history.reanalyzeServerErrorCode", { status: res.status })); diff --git a/app/src/components/Home.jsx b/app/src/components/Home.jsx index eac456f..4ca2e0d 100644 --- a/app/src/components/Home.jsx +++ b/app/src/components/Home.jsx @@ -262,7 +262,7 @@ export default function Home({ onShowHistoryWithDate }) { )}