From 2c96467ab289fd88cd62e7af03ddb6a031ccb4c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:37:52 +0000 Subject: [PATCH 01/30] fix: surface Anthropic error detail in UI error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy returns { error: '...', detail: '' } but both MuscleMap and History were reading data?.error?.message — always undefined because .error is a string. Changed to data?.detail so the actual Anthropic error (e.g. "model_not_found", "invalid_request_error") is shown directly in the error notification on mobile and desktop. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/History.jsx | 2 +- app/src/components/MuscleMap.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 110bc73..9b680ee 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -390,7 +390,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/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index f09848a..dea4596 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -199,7 +199,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } let data; try { data = await res.json(); } catch { throw new Error(`Serverfeil (${res.status}): Ugyldig svar fra server`); } if (!res.ok) { - const detail = data?.error?.message; + const detail = data?.detail || data?.error?.message; throw new Error(res.status === 401 ? "Ikke innlogget. Logg inn på nytt." : detail ? `Serverfeil (${res.status}): ${detail}` : `Serverfeil (${res.status})`); } const text = (data.content || []).map(b => b.text || "").join("").replace(/```json|```/g, "").trim(); From 0207329b531df2423de5efeef20991917894c402 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:44:23 +0000 Subject: [PATCH 02/30] fix: auto-compress images before sending to Anthropic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iPhone 17 Pro (and all iPhones) shoot in HEIF by default. iOS Safari converts HEIF to JPEG when reading via FileReader/canvas, but the resulting JPEG inflates the 2.6 MB HEIF to ~5.2 MB — over Anthropic's 5 MB per-image limit, causing a 400 error. New compressImage() in utils.js: - Always outputs image/jpeg via canvas (converts HEIF, WebP, PNG, etc.) - Iteratively lowers JPEG quality (0.85 → 0.3) then reduces dimensions (75% steps) until decoded byte size is under 5 MB - Whiteboard photos typically compress to <2 MB with no visible loss Replaces toBase64 + detectMediaType + the old file-size guard in both MuscleMap and History. The size guard was checking file.size (the compressed HEIF size) which is unrelated to the decoded JPEG size Anthropic actually enforces. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/History.jsx | 5 ++-- app/src/components/MuscleMap.jsx | 15 +++++------- app/src/lib/utils.js | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 9b680ee..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, diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index dea4596..5275de4 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -1,7 +1,7 @@ import { useReducer, useRef, useCallback, useEffect, useMemo, useState } from "react"; import { saveSession, fetchGymSessionsByDate, checkGymCalendarConflict, fetchLibraryExercises } from "../lib/db"; import { EX_DB, MUSCLES, calcMuscles } from "../lib/bodymap.jsx"; -import { toBase64, detectMediaType, buildMuscleMapFromExercises, buildRecMuscleMap, callClaude, logDevError, getIntlLocale } from "../lib/utils"; +import { compressImage, buildMuscleMapFromExercises, buildRecMuscleMap, callClaude, logDevError, getIntlLocale } from "../lib/utils"; import { CLAUDE_MODEL_VISION, CLAUDE_MODEL_TEXT, ANALYZE_PROMPT, buildRecommendPrompt } from "../lib/prompts"; import { Button, Select, SelectItem, @@ -22,8 +22,6 @@ const localDateStr = () => { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }; -const MAX_FILE_SIZE_MB = 5; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; function getConfidenceColor(ex) { if (ex.primary?.length || ex.secondary?.length) return "var(--heat-4)"; @@ -170,13 +168,12 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } const addImage = useCallback(async (file) => { if (!file || !file.type.startsWith("image/")) return; - if (file.size > MAX_FILE_SIZE_BYTES) { - setSizeError(`Bildet er for stort (maks ${MAX_FILE_SIZE_MB} MB). Komprimer eller velg et annet bilde.`); - return; + try { + const { base64: b64, mediaType: mt } = await compressImage(file); + dispatch({ type: "ADD_IMAGE", image: { id: Date.now() + Math.random(), base64: b64, mediaType: mt, preview: `data:${mt};base64,${b64}` } }); + } catch (e) { + setSizeError(e.message || "Kunne ikke laste bildet."); } - const mt = await detectMediaType(file); - const b64 = await toBase64(file); - dispatch({ type: "ADD_IMAGE", image: { id: Date.now() + Math.random(), base64: b64, mediaType: mt, preview: `data:${mt};base64,${b64}` } }); }, [setSizeError]); const handleFiles = useCallback(async (files) => { diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 91fa8eb..9f93187 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -81,6 +81,47 @@ export async function detectMediaType(file) { return file.type || "image/jpeg"; } +// Compress an image to JPEG, reducing quality then dimensions until decoded +// byte size is under maxDecodedBytes (default 5 MB = Anthropic's hard limit). +// Always outputs image/jpeg — this also converts HEIF/HEIC (iPhone default +// format) to JPEG, since iOS Safari converts HEIF→JPEG via FileReader/canvas +// but the resulting JPEG can be larger than the original compressed HEIF. +export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + const canvas = document.createElement('canvas'); + let w = img.naturalWidth; + let h = img.naturalHeight; + + const tryEncode = (quality) => { + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); + const dataUrl = canvas.toDataURL('image/jpeg', quality); + const b64 = dataUrl.split(',')[1]; + // base64 decoded bytes ≈ b64.length × 3/4 + if (b64.length * 0.75 <= maxDecodedBytes || (quality <= 0.3 && w <= 800)) { + resolve({ base64: b64, mediaType: 'image/jpeg' }); + return; + } + if (quality > 0.45) { + tryEncode(parseFloat((quality - 0.15).toFixed(2))); + } else { + w = Math.round(w * 0.75); + h = Math.round(h * 0.75); + tryEncode(0.75); + } + }; + tryEncode(0.85); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Kunne ikke laste bildet')); }; + img.src = url; + }); +} + // Builds muscle-ID → exercise-name map from a live exercises array (confirm/edit step). // Falls back to EX_DB keyword matching for exercises without Claude-assigned muscle data. export function buildMuscleMapFromExercises(exercises) { From d748b25f505889e50791f756c9b37761565b8a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:50:13 +0000 Subject: [PATCH 03/30] fix: scale image to 2048px before compressing, not iterative quality-only The previous approach tried reducing JPEG quality iteratively on the full 24MP frame. A 4284x5712 image at quality 0.85 still outputs ~6 MB because quality alone can't compensate for that many pixels. New strategy: scale to max 2048px on the longest side first, then reduce JPEG quality only if still over 5 MB. 2048px is sufficient for whiteboard OCR; a 1533x2048 JPEG at quality 0.85 is ~0.5-1.5 MB. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/lib/utils.js | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 9f93187..20c0656 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -81,41 +81,41 @@ export async function detectMediaType(file) { return file.type || "image/jpeg"; } -// Compress an image to JPEG, reducing quality then dimensions until decoded -// byte size is under maxDecodedBytes (default 5 MB = Anthropic's hard limit). -// Always outputs image/jpeg — this also converts HEIF/HEIC (iPhone default -// format) to JPEG, since iOS Safari converts HEIF→JPEG via FileReader/canvas -// but the resulting JPEG can be larger than the original compressed HEIF. +// Compress an image to JPEG and ensure decoded size is under Anthropic's 5 MB limit. +// Always outputs image/jpeg — this also converts HEIF/HEIC (iPhone default format) +// since iOS Safari inflates HEIF to a larger JPEG via FileReader/canvas. +// Strategy: scale to max 2048px on the longest side first (sufficient for whiteboard +// OCR and reliably under 5 MB for any camera), then reduce JPEG quality if still needed. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); - const canvas = document.createElement('canvas'); + const maxDim = 2048; let w = img.naturalWidth; let h = img.naturalHeight; + if (w > maxDim || h > maxDim) { + const scale = maxDim / Math.max(w, h); + w = Math.round(w * scale); + h = Math.round(h * scale); + } + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); - const tryEncode = (quality) => { - canvas.width = w; - canvas.height = h; - canvas.getContext('2d').drawImage(img, 0, 0, w, h); + const tryQuality = (quality) => { const dataUrl = canvas.toDataURL('image/jpeg', quality); const b64 = dataUrl.split(',')[1]; // base64 decoded bytes ≈ b64.length × 3/4 - if (b64.length * 0.75 <= maxDecodedBytes || (quality <= 0.3 && w <= 800)) { + if (b64.length * 0.75 <= maxDecodedBytes || quality <= 0.3) { resolve({ base64: b64, mediaType: 'image/jpeg' }); return; } - if (quality > 0.45) { - tryEncode(parseFloat((quality - 0.15).toFixed(2))); - } else { - w = Math.round(w * 0.75); - h = Math.round(h * 0.75); - tryEncode(0.75); - } + tryQuality(parseFloat((quality - 0.1).toFixed(1))); }; - tryEncode(0.85); + tryQuality(0.85); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Kunne ikke laste bildet')); }; img.src = url; From 0b1ba9cc56e81533e9912f7cfd8a9f74d14d361b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:54:57 +0000 Subject: [PATCH 04/30] fix: normalize ALL CAPS exercise names from whiteboard to title case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gym whiteboards are typically written in ALL CAPS. Claude returns names verbatim, so parsed exercises came back as e.g. "BENKPRESS MED MANUALER". normalizeExName() converts to title case only when the entire string is uppercase — mixed-case names (already correct) are left untouched. Applies to both name and standardName fields on ANALYZE_SUCCESS. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/MuscleMap.jsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index 5275de4..2dad3c7 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -23,6 +23,18 @@ const localDateStr = () => { }; +// Gym whiteboards are often written in ALL CAPS. Normalize to title case +// only when the entire string is uppercase (leave mixed-case names alone). +function toTitleCase(str) { + if (!str) return str; + return str.toLowerCase().split(' ').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(' '); +} +function normalizeExName(str) { + if (!str) return str; + const t = str.trim(); + return t === t.toUpperCase() && /[A-ZÆØÅ]{2,}/.test(t) ? toTitleCase(t) : t; +} + function getConfidenceColor(ex) { if (ex.primary?.length || ex.secondary?.length) return "var(--heat-4)"; const txt = ((ex.name || "") + " " + (ex.standardName || "")).toLowerCase(); @@ -205,7 +217,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } throw new Error("Svaret fra Claude var ikke gyldig JSON. Prøv igjen."); } if (!Array.isArray(parsed)) throw new Error("Uventet svarformat fra Claude."); - dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1" })) }); + dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1", name: normalizeExName(ex.name), standardName: normalizeExName(ex.standardName) })) }); setUseTodayDate(true); } catch (err) { logDevError("MuscleMap/analyse", err); From f4363d1fb12cec87c1942927985cc446a4c524ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 00:57:58 +0000 Subject: [PATCH 05/30] fix: increase max image dimension to 3000px, revert name normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2048px was too aggressive — it degraded whiteboard text enough for Claude to return exercise names in ALL CAPS where it previously didn't. 3000px (2253×3000 for the iPhone 17 Pro frame) preserves enough resolution for accurate OCR while keeping JPEG well under 5 MB. Reverts the ALL CAPS → title case normalization (was masking the quality regression, not fixing the root cause). https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/MuscleMap.jsx | 11 ----------- app/src/lib/utils.js | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index 2dad3c7..beeaf67 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -23,17 +23,6 @@ const localDateStr = () => { }; -// Gym whiteboards are often written in ALL CAPS. Normalize to title case -// only when the entire string is uppercase (leave mixed-case names alone). -function toTitleCase(str) { - if (!str) return str; - return str.toLowerCase().split(' ').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(' '); -} -function normalizeExName(str) { - if (!str) return str; - const t = str.trim(); - return t === t.toUpperCase() && /[A-ZÆØÅ]{2,}/.test(t) ? toTitleCase(t) : t; -} function getConfidenceColor(ex) { if (ex.primary?.length || ex.secondary?.length) return "var(--heat-4)"; diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 20c0656..3ba36a0 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -92,7 +92,7 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); - const maxDim = 2048; + const maxDim = 3000; let w = img.naturalWidth; let h = img.naturalHeight; if (w > maxDim || h > maxDim) { From 3e0f136e1bea71217682fcb6562966b3d520e427 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:00:00 +0000 Subject: [PATCH 06/30] fix: remove leftover normalizeExName calls after revert The normalizeExName function was reverted but its two call sites in the ANALYZE_SUCCESS dispatch were not cleaned up, causing no-undef lint errors and a CI failure. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/MuscleMap.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index beeaf67..a080424 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -206,7 +206,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } throw new Error("Svaret fra Claude var ikke gyldig JSON. Prøv igjen."); } if (!Array.isArray(parsed)) throw new Error("Uventet svarformat fra Claude."); - dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1", name: normalizeExName(ex.name), standardName: normalizeExName(ex.standardName) })) }); + dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1" })) }); setUseTodayDate(true); } catch (err) { logDevError("MuscleMap/analyse", err); From 06f08b84091658bc952bdea7b1e19e70d2e799e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:04:51 +0000 Subject: [PATCH 07/30] fix: use FileReader path for full-res conversion, canvas only to reduce quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit dimension scaling (2048px, 3000px) was causing Claude to return ALL CAPS exercise names — the downsampled image degraded whiteboard text enough to change OCR behaviour. New strategy: 1. FileReader reads the file — iOS converts HEIF→JPEG at full native resolution (4284×5712 for iPhone 17 Pro). If the result is ≤5 MB, use it directly with zero quality loss. 2. Only if over 5 MB: draw to canvas at natural dimensions and reduce JPEG quality (0.75 → 0.65 → ... → 0.3). No explicit width/height cap — iOS applies its own canvas limits internally at a much higher resolution than our previous 2048/3000px cap. For the iPhone 17 Pro: HEIF→JPEG ≈5.2 MB → canvas at quality 0.75 ≈3.5-4 MB → under limit, full resolution preserved for OCR. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/lib/utils.js | 69 ++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 3ba36a0..b902091 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -82,43 +82,48 @@ export async function detectMediaType(file) { } // Compress an image to JPEG and ensure decoded size is under Anthropic's 5 MB limit. -// Always outputs image/jpeg — this also converts HEIF/HEIC (iPhone default format) -// since iOS Safari inflates HEIF to a larger JPEG via FileReader/canvas. -// Strategy: scale to max 2048px on the longest side first (sufficient for whiteboard -// OCR and reliably under 5 MB for any camera), then reduce JPEG quality if still needed. +// Strategy: +// 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native +// resolution. If the result is already under 5 MB, use it directly with zero +// quality loss (this is the happy path for most photos). +// 2. Only if over 5 MB: draw to canvas at the original dimensions and reduce JPEG +// quality. We never explicitly scale dimensions — iOS applies its own canvas +// limits internally (much higher than our previous 2048/3000px cap) so the +// image stays close to the original resolution, preserving OCR quality. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(file); - img.onload = () => { - URL.revokeObjectURL(url); - const maxDim = 3000; - let w = img.naturalWidth; - let h = img.naturalHeight; - if (w > maxDim || h > maxDim) { - const scale = maxDim / Math.max(w, h); - w = Math.round(w * scale); - h = Math.round(h * scale); + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Kunne ikke laste bildet')); + reader.onload = () => { + const dataUrl = reader.result; + const b64 = dataUrl.split(',')[1]; + // base64 decoded bytes ≈ b64.length × 3/4 + if (b64.length * 0.75 <= maxDecodedBytes) { + resolve({ base64: b64, mediaType: 'image/jpeg' }); + return; } - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - canvas.getContext('2d').drawImage(img, 0, 0, w, h); - - const tryQuality = (quality) => { - const dataUrl = canvas.toDataURL('image/jpeg', quality); - const b64 = dataUrl.split(',')[1]; - // base64 decoded bytes ≈ b64.length × 3/4 - if (b64.length * 0.75 <= maxDecodedBytes || quality <= 0.3) { - resolve({ base64: b64, mediaType: 'image/jpeg' }); - return; - } - tryQuality(parseFloat((quality - 0.1).toFixed(1))); + // Over limit — re-encode at lower quality via canvas (no dimension scaling). + const img = new Image(); + img.onerror = () => reject(new Error('Kunne ikke laste bildet')); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + const tryQuality = (quality) => { + const d = canvas.toDataURL('image/jpeg', quality); + const b = d.split(',')[1]; + if (b.length * 0.75 <= maxDecodedBytes || quality <= 0.3) { + resolve({ base64: b, mediaType: 'image/jpeg' }); + return; + } + tryQuality(parseFloat((quality - 0.1).toFixed(1))); + }; + tryQuality(0.75); }; - tryQuality(0.85); + img.src = dataUrl; }; - img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Kunne ikke laste bildet')); }; - img.src = url; + reader.readAsDataURL(file); }); } From 3d8edb8275b1eb009719028bc5345dfc32c761af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:08:17 +0000 Subject: [PATCH 08/30] fix: normalize ALL CAPS exercise names from Claude Gym whiteboards are written in ALL CAPS by convention. When the canvas fallback in compressImage reduces JPEG quality (trading resolution for size), Claude tends to return exercise names verbatim in ALL CAPS. normalizeExName converts fully-uppercase strings to title case so names display consistently regardless of image resolution. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/components/MuscleMap.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index a080424..3163b23 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -24,6 +24,16 @@ const localDateStr = () => { +// Gym whiteboards are written in ALL CAPS by convention. Normalize to title +// case when the entire string is uppercase so names display consistently. +const toTitleCase = (str) => + str.toLowerCase().split(' ').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(' '); +const normalizeExName = (str) => { + if (!str) return str; + const t = str.trim(); + return t === t.toUpperCase() && /[A-ZÆØÅ]{2,}/.test(t) ? toTitleCase(t) : t; +}; + function getConfidenceColor(ex) { if (ex.primary?.length || ex.secondary?.length) return "var(--heat-4)"; const txt = ((ex.name || "") + " " + (ex.standardName || "")).toLowerCase(); @@ -206,7 +216,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } throw new Error("Svaret fra Claude var ikke gyldig JSON. Prøv igjen."); } if (!Array.isArray(parsed)) throw new Error("Uventet svarformat fra Claude."); - dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1" })) }); + dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1", name: normalizeExName(ex.name), standardName: normalizeExName(ex.standardName) })) }); setUseTodayDate(true); } catch (err) { logDevError("MuscleMap/analyse", err); From b020779da62315403d93f132927291a3769ad5ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:13:30 +0000 Subject: [PATCH 09/30] fix: cap canvas long edge at 4500px to prevent iOS memory failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4284×5712 (24.5MP) canvas needs ~98MB GPU backing store. iOS Safari silently refuses to allocate it, so toDataURL returned the original un-compressed data unchanged — causing Anthropic to still see the 5MB image even after the FileReader path correctly detected it was over the limit and entered the canvas fallback. Capping at 4500px (3375×4500 = ~61MB) keeps the canvas within iOS limits. OCR quality is preserved — 4500px is well above the 3000px that previously caused ALL CAPS output. Quality reduction starts at 0.9 since we have already reduced pixel count. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/lib/utils.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index b902091..8e29efe 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -86,10 +86,12 @@ export async function detectMediaType(file) { // 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native // resolution. If the result is already under 5 MB, use it directly with zero // quality loss (this is the happy path for most photos). -// 2. Only if over 5 MB: draw to canvas at the original dimensions and reduce JPEG -// quality. We never explicitly scale dimensions — iOS applies its own canvas -// limits internally (much higher than our previous 2048/3000px cap) so the -// image stays close to the original resolution, preserving OCR quality. +// 2. Only if over 5 MB: draw to canvas capped at 4500px on the long edge, then +// reduce JPEG quality until it fits. The 4500px cap prevents iOS from silently +// refusing to allocate a ~98MB GPU backing store for 24MP+ images (which causes +// toDataURL to return the original-size data unchanged). At 4500px the canvas +// is ~61MB and compresses reliably. OCR quality is preserved — 4500px is +// significantly more resolution than the 3000px that caused ALL CAPS issues. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -102,14 +104,21 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { resolve({ base64: b64, mediaType: 'image/jpeg' }); return; } - // Over limit — re-encode at lower quality via canvas (no dimension scaling). + // Over limit — scale to ≤4500px long edge, then reduce JPEG quality. const img = new Image(); img.onerror = () => reject(new Error('Kunne ikke laste bildet')); img.onload = () => { + const MAX_LONG_EDGE = 4500; + let w = img.naturalWidth; + let h = img.naturalHeight; + if (Math.max(w, h) > MAX_LONG_EDGE) { + if (w >= h) { h = Math.round(h * MAX_LONG_EDGE / w); w = MAX_LONG_EDGE; } + else { w = Math.round(w * MAX_LONG_EDGE / h); h = MAX_LONG_EDGE; } + } const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext('2d').drawImage(img, 0, 0); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); const tryQuality = (quality) => { const d = canvas.toDataURL('image/jpeg', quality); const b = d.split(',')[1]; @@ -119,7 +128,7 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { } tryQuality(parseFloat((quality - 0.1).toFixed(1))); }; - tryQuality(0.75); + tryQuality(0.9); }; img.src = dataUrl; }; From 8cff4ddd824cf49c10d5bbc0d8f56ffccee622bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:20:40 +0000 Subject: [PATCH 10/30] fix: use blob URL instead of data URL for canvas image source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting img.src to a ~9 MB data URL string caused iOS Safari to silently fail the Image decode (naturalWidth/naturalHeight = 0), producing a blank 0×0 canvas. toDataURL on a 0×0 canvas passes the size check and resolves with garbage base64, leaving the original over-5MB data in state. Using URL.createObjectURL(file) gives iOS a short blob:// reference to the original file instead — iOS decodes it reliably via its native image pipeline. Also retains the 4500px long-edge cap (15 MP canvas ≈ 61 MB) to stay within iOS GPU memory limits. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- app/src/lib/utils.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 8e29efe..ef257a9 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -86,12 +86,12 @@ export async function detectMediaType(file) { // 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native // resolution. If the result is already under 5 MB, use it directly with zero // quality loss (this is the happy path for most photos). -// 2. Only if over 5 MB: draw to canvas capped at 4500px on the long edge, then -// reduce JPEG quality until it fits. The 4500px cap prevents iOS from silently -// refusing to allocate a ~98MB GPU backing store for 24MP+ images (which causes -// toDataURL to return the original-size data unchanged). At 4500px the canvas -// is ~61MB and compresses reliably. OCR quality is preserved — 4500px is -// significantly more resolution than the 3000px that caused ALL CAPS issues. +// 2. Only if over 5 MB: load the original file via a blob URL (a short +// blob:// reference), NOT the huge data URL string. Setting img.src to a ~9 MB +// data URL causes iOS Safari to silently fail the Image decode (naturalWidth = 0), +// so toDataURL returns a blank result that passes the size check. The blob URL +// approach avoids this. Cap long edge at 4500px (keeps canvas ≤61 MB vs the +// ~98 MB a 24 MP canvas needs), then reduce JPEG quality until it fits. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -104,10 +104,13 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { resolve({ base64: b64, mediaType: 'image/jpeg' }); return; } - // Over limit — scale to ≤4500px long edge, then reduce JPEG quality. + // Over limit — use a blob URL so iOS can decode the image without hitting the + // data URL size limit, then scale to ≤4500px and reduce JPEG quality. + const blobUrl = URL.createObjectURL(file); const img = new Image(); - img.onerror = () => reject(new Error('Kunne ikke laste bildet')); + img.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('Kunne ikke laste bildet')); }; img.onload = () => { + URL.revokeObjectURL(blobUrl); const MAX_LONG_EDGE = 4500; let w = img.naturalWidth; let h = img.naturalHeight; @@ -130,7 +133,7 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { }; tryQuality(0.9); }; - img.src = dataUrl; + img.src = blobUrl; }; reader.readAsDataURL(file); }); From 781cb249b45c4098085207afdc52956641bac961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:38:23 +0000 Subject: [PATCH 11/30] fix: remove ambiguous created_at sort from fetchLastSession; deduplicate auth upserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchLastSession had a secondary .order("created_at") that PostgREST treated as ambiguous because session_exercises (joined in the same query) also has a created_at column — it returned 0 rows silently, causing Home to show "Ingen økter logget ennå" despite sessions existing. App.jsx called ensureGymMembership + ensureDisplayName on every Supabase auth event (INITIAL_SESSION, TOKEN_REFRESHED, etc.) causing 3-4 redundant upserts per page load. Now only fires on SIGNED_IN. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- CHANGELOG.md | 9 +++++++++ CLAUDE.md | 20 ++++++++++++++++++++ app/src/App.jsx | 9 +++++++-- app/src/lib/db.js | 1 - 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ea83b..4da0f45 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** — iPhone 17 Pro HEIF images auto-converted to JPEG by iOS's `FileReader` inflated from ~2.6 MB to ~5.25 MB (just over Anthropic's 5 MB decoded limit). First canvas attempt used the huge data URL as `img.src`, which iOS Safari silently ignores for large blobs (naturalWidth becomes 0), so compression never ran. Fixed by using `URL.createObjectURL(file)` as the image source for the canvas path — iOS decodes blob URLs natively without the data URL size restriction. Canvas long edge is capped at 4500 px to stay within iOS GPU memory limits (~61 MB vs ~98 MB for a 24 MP canvas). JPEG quality is reduced in 0.1 steps from 0.9 until decoded size is ≤ 5 MB. +- **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` now converts fully-uppercase strings to title case before they reach the exercise list. Acts as a permanent safety net regardless of image quality. +- **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` so the actual reason (e.g. "image exceeds 5 MB maximum") is shown instead of `undefined`. +- **"Siste økt" showing empty despite a session existing** — `fetchLastSession` in `db.js` applied a secondary `.order("created_at")` sort after the primary `session_date` sort. Because `session_exercises` (joined in the same query) also has a `created_at` column, PostgREST treated the sort as ambiguous and returned 0 rows instead of 1. Removed the secondary sort — `session_date DESC` with `limit(1)` is sufficient. +- **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; the initial `getSession().then(...)` path continues to run them on first mount. + ## [1.2.8] — 2026-05-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1d8bf9f..f692c58 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 — iOS FileReader HEIF → JPEG inflation:** `FileReader.readAsDataURL` triggers an automatic HEIF → JPEG conversion at full native resolution. A 2.6 MB HEIF image inflates to 5.25 MB — just over the 5,242,880 decoded-byte 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. + +**Fix:** Use `URL.createObjectURL(file)` as the canvas image source instead. iOS decodes blob URLs via its native pipeline, reliably returning correct `naturalWidth`/`naturalHeight`. The blob URL is immediately revoked in both `onload` and `onerror` to avoid memory leaks. Canvas long edge is capped at 4500 px (≈61 MB GPU memory) to avoid iOS GPU limits with 24 MP sensors. JPEG quality is stepped down from 0.9 in 0.1 increments until decoded size ≤ 5 MB or quality reaches 0.3. + +**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height, producing a blank canvas result that passes the size check without actually compressing. + +### 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 — ambiguous `created_at` ORDER in joined query:** `fetchLastSession` applied `.order("created_at", { ascending: false })` after `.order("session_date", ...)`. The query joins `session_exercises`, which also has a `created_at` column. PostgREST treated the unqualified `created_at` as ambiguous and returned 0 rows (HTTP 200, empty body) instead of 1. The `fetchThisWeekSessions` query has no secondary `created_at` sort, which is why it was unaffected. + +**Fix:** Removed the secondary `.order("created_at")` sort from `fetchLastSession`. `session_date DESC` with `limit(1)` is sufficient to return the most recent session. The secondary sort was only intended to resolve ties on the same date — not worth the ambiguity risk. + +**Pattern to watch:** When writing PostgREST queries that join tables, any ORDER clause column name must be unique across the main table and all joined tables, or must be qualified with the table name (PostgREST supports `{ foreignTable: "sessions" }` in the options object). `created_at` appears on many tables; always check for join conflicts before using it as a sort key. + diff --git a/app/src/App.jsx b/app/src/App.jsx index 1c9be12..0af220e 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -29,9 +29,14 @@ function App() { setSession(session); if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } }); - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { setSession(session); - if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); } + // Only run on actual sign-in — INITIAL_SESSION, TOKEN_REFRESHED, etc. fire on + // every page load and would trigger redundant upserts on every auth event. + if (event === "SIGNED_IN" && session) { + ensureGymMembership().catch(() => {}); + ensureDisplayName().catch(() => {}); + } }); return () => subscription.unsubscribe(); }, []); diff --git a/app/src/lib/db.js b/app/src/lib/db.js index 99b8583..a052e88 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -197,7 +197,6 @@ export async function fetchLastSession() { ) `) .order("session_date", { ascending: false }) - .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (error) throw error; From 0915690adf5b7fb727e8c0f2e14335be7ba34656 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 01:55:18 +0000 Subject: [PATCH 12/30] fix: dimension-reduction fallback for iOS quality-param bug; fix fetchLastSession using maybeSingle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compressImage: iOS Safari silently ignores canvas.toDataURL quality param, so quality stepping 0.9→0.3 produced the same ~5.25 MB output every time, then the || quality<=0.3 guard resolved with the oversize image. Added dimension reduction (2048px start, 70% each round to 800px floor) as the reliable fallback when quality is ineffective. fetchLastSession: .limit(1).maybeSingle() breaks silently once the sessions table has 2+ rows — PostgREST evaluates the single-row constraint before LIMIT and returns 406; .maybeSingle() converts 406 to {data:null,error:null}. Replaced with a plain array query + data?.[0] ?? null. https://claude.ai/code/session_01BX3VsjZbhAR5n85NbUmZRJ --- CHANGELOG.md | 10 ++++---- CLAUDE.md | 12 +++++---- app/src/lib/db.js | 5 ++-- app/src/lib/utils.js | 61 ++++++++++++++++++++++++-------------------- 4 files changed, 48 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da0f45..9ae12f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ All notable changes to Workout Lens are documented here. ## [1.2.9] — 2026-05-14 ### Fixed -- **HEIF/iPhone photo exceeds 5 MB limit** — iPhone 17 Pro HEIF images auto-converted to JPEG by iOS's `FileReader` inflated from ~2.6 MB to ~5.25 MB (just over Anthropic's 5 MB decoded limit). First canvas attempt used the huge data URL as `img.src`, which iOS Safari silently ignores for large blobs (naturalWidth becomes 0), so compression never ran. Fixed by using `URL.createObjectURL(file)` as the image source for the canvas path — iOS decodes blob URLs natively without the data URL size restriction. Canvas long edge is capped at 4500 px to stay within iOS GPU memory limits (~61 MB vs ~98 MB for a 24 MP canvas). JPEG quality is reduced in 0.1 steps from 0.9 until decoded size is ≤ 5 MB. -- **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` now converts fully-uppercase strings to title case before they reach the exercise list. Acts as a permanent safety net regardless of image quality. -- **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` so the actual reason (e.g. "image exceeds 5 MB maximum") is shown instead of `undefined`. -- **"Siste økt" showing empty despite a session existing** — `fetchLastSession` in `db.js` applied a secondary `.order("created_at")` sort after the primary `session_date` sort. Because `session_exercises` (joined in the same query) also has a `created_at` column, PostgREST treated the sort as ambiguous and returned 0 rows instead of 1. Removed the secondary sort — `session_date DESC` with `limit(1)` is sufficient. -- **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; the initial `getSession().then(...)` path continues to run them on first mount. +- **HEIF/iPhone photo exceeds 5 MB limit** — iPhone 17 Pro HEIF images auto-converted to JPEG by iOS's `FileReader` inflated from ~2.6 MB to ~5.25 MB (just over Anthropic's 5 MB limit). Two iterations of fixes were needed: (1) `img.src = large data URL` caused iOS to silently zero `naturalWidth`, producing a blank canvas that "passed" the size check — fixed by using `URL.createObjectURL(file)` as image source; (2) iOS Safari silently ignores the `quality` parameter in `canvas.toDataURL`, so quality stepping 0.9→0.3 always returned the same ~5.25 MB result and the fallback `|| quality <= 0.3` resolved with the still-oversize image — fixed by also reducing canvas dimensions when quality steps are exhausted (2048px start → 70% each round → floor 800px). +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index f692c58..ba66d48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -419,16 +419,18 @@ Symptom: uploading an iPhone 17 Pro photo failed with `Serverfeil (400): image e **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. -**Fix:** Use `URL.createObjectURL(file)` as the canvas image source instead. iOS decodes blob URLs via its native pipeline, reliably returning correct `naturalWidth`/`naturalHeight`. The blob URL is immediately revoked in both `onload` and `onerror` to avoid memory leaks. Canvas long edge is capped at 4500 px (≈61 MB GPU memory) to avoid iOS GPU limits with 24 MP sensors. JPEG quality is stepped down from 0.9 in 0.1 increments until decoded size ≤ 5 MB or quality reaches 0.3. +**Root cause 3 — iOS Safari ignores `canvas.toDataURL` quality parameter:** Even with the blob URL fix, iOS Safari on some versions silently ignores the `quality` argument to `canvas.toDataURL('image/jpeg', quality)` and always outputs at its default quality (~0.92). Quality stepping 0.9→0.3 has no effect — all steps produce the same ~5.25 MB result. The `|| quality <= 0.3` fallback in the old `tryQuality` function then resolved the Promise with the still-oversize image. -**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height, producing a blank canvas result that passes the size check without actually compressing. +**Fix:** Use `URL.createObjectURL(file)` as the canvas image source (fixes root cause 2). Start at 2048 px long edge (not 4500 px) so even worst-case iOS default quality produces < 5 MB. After quality steps are exhausted, shrink canvas to 70% long edge and retry from quality 0.9. Repeat down to 800 px, then resolve unconditionally. Dimension reduction is the reliable fallback when quality is ignored. + +**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height. Do not rely on quality-only stepping for iOS — dimension reduction is the only reliable size control. ### 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 — ambiguous `created_at` ORDER in joined query:** `fetchLastSession` applied `.order("created_at", { ascending: false })` after `.order("session_date", ...)`. The query joins `session_exercises`, which also has a `created_at` column. PostgREST treated the unqualified `created_at` as ambiguous and returned 0 rows (HTTP 200, empty body) instead of 1. The `fetchThisWeekSessions` query has no secondary `created_at` sort, which is why it was unaffected. +**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 the secondary `.order("created_at")` sort from `fetchLastSession`. `session_date DESC` with `limit(1)` is sufficient to return the most recent session. The secondary sort was only intended to resolve ties on the same date — not worth the ambiguity risk. +**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:** When writing PostgREST queries that join tables, any ORDER clause column name must be unique across the main table and all joined tables, or must be qualified with the table name (PostgREST supports `{ foreignTable: "sessions" }` in the options object). `created_at` appears on many tables; always check for join conflicts before using it as a sort key. +**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/src/lib/db.js b/app/src/lib/db.js index a052e88..c795ffc 100644 --- a/app/src/lib/db.js +++ b/app/src/lib/db.js @@ -197,10 +197,9 @@ export async function fetchLastSession() { ) `) .order("session_date", { ascending: false }) - .limit(1) - .maybeSingle(); + .limit(1); if (error) throw error; - return data; + return data?.[0] ?? null; } export async function fetchSessionsForWeek(weekIso) { diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index ef257a9..8e9678f 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -86,12 +86,12 @@ export async function detectMediaType(file) { // 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native // resolution. If the result is already under 5 MB, use it directly with zero // quality loss (this is the happy path for most photos). -// 2. Only if over 5 MB: load the original file via a blob URL (a short -// blob:// reference), NOT the huge data URL string. Setting img.src to a ~9 MB -// data URL causes iOS Safari to silently fail the Image decode (naturalWidth = 0), -// so toDataURL returns a blank result that passes the size check. The blob URL -// approach avoids this. Cap long edge at 4500px (keeps canvas ≤61 MB vs the -// ~98 MB a 24 MP canvas needs), then reduce JPEG quality until it fits. +// 2. Only if over 5 MB: load the original file via a blob URL, NOT the huge data URL +// string (iOS Safari silently fails to decode large data URLs — naturalWidth = 0). +// Start at 2048px long edge and step quality 0.9→0.3. If quality alone doesn't +// reduce size (iOS Safari ignores the quality param on some versions), shrink +// dimensions by 30% and retry from quality 0.9. Repeat until it fits or we reach +// 800px, then resolve unconditionally. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -104,34 +104,41 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { resolve({ base64: b64, mediaType: 'image/jpeg' }); return; } - // Over limit — use a blob URL so iOS can decode the image without hitting the - // data URL size limit, then scale to ≤4500px and reduce JPEG quality. + // Over limit — use a blob URL so iOS can decode the image reliably. const blobUrl = URL.createObjectURL(file); const img = new Image(); img.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('Kunne ikke laste bildet')); }; img.onload = () => { URL.revokeObjectURL(blobUrl); - const MAX_LONG_EDGE = 4500; - let w = img.naturalWidth; - let h = img.naturalHeight; - if (Math.max(w, h) > MAX_LONG_EDGE) { - if (w >= h) { h = Math.round(h * MAX_LONG_EDGE / w); w = MAX_LONG_EDGE; } - else { w = Math.round(w * MAX_LONG_EDGE / h); h = MAX_LONG_EDGE; } - } - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - canvas.getContext('2d').drawImage(img, 0, 0, w, h); - const tryQuality = (quality) => { - const d = canvas.toDataURL('image/jpeg', quality); - const b = d.split(',')[1]; - if (b.length * 0.75 <= maxDecodedBytes || quality <= 0.3) { - resolve({ base64: b, mediaType: 'image/jpeg' }); - return; + // tryDimension: draw image at maxEdge px long edge, then step quality down. + // If quality stepping doesn't reduce size (iOS ignores quality param), shrink + // dimensions by 30% and retry. Stop at 800px — accept whatever we have. + const tryDimension = (maxEdge) => { + let w = img.naturalWidth; + let h = img.naturalHeight; + if (Math.max(w, h) > maxEdge) { + if (w >= h) { h = Math.round(h * maxEdge / w); w = maxEdge; } + else { w = Math.round(w * maxEdge / h); h = maxEdge; } } - tryQuality(parseFloat((quality - 0.1).toFixed(1))); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); + const tryQuality = (quality) => { + const d = canvas.toDataURL('image/jpeg', quality); + const b = d.split(',')[1]; + if (b.length * 0.75 <= maxDecodedBytes) { + resolve({ base64: b, mediaType: 'image/jpeg' }); + return; + } + if (quality > 0.3) { tryQuality(parseFloat((quality - 0.1).toFixed(1))); return; } + // Quality exhausted — shrink dimensions and retry + if (maxEdge > 800) { tryDimension(Math.round(maxEdge * 0.7)); return; } + resolve({ base64: b, mediaType: 'image/jpeg' }); + }; + tryQuality(0.9); }; - tryQuality(0.9); + tryDimension(2048); }; img.src = blobUrl; }; From cf81fe852c82091c86c164ae347ae4e3bed9d41c Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 14 May 2026 04:25:07 +0200 Subject: [PATCH 13/30] fix: correct mediaType in compressImage happy path; fix muscle chip label on Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compressImage: happy path was hardcoding mediaType 'image/jpeg' for all files; now reads actual type from FileReader dataUrl so PNG screenshots are not sent to Claude with the wrong MIME type - Home: second chip on last-session card used history.exerciseCount for muscle count — now uses home.muscleCount (added to all three locales) Co-Authored-By: Claude Sonnet 4.6 --- app/public/locales/en/translation.json | 2 ++ app/public/locales/fa/translation.json | 2 ++ app/public/locales/nb/translation.json | 2 ++ app/src/components/Home.jsx | 2 +- app/src/lib/utils.js | 3 ++- 5 files changed, 9 insertions(+), 2 deletions(-) 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/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 }) { )}
{t("history.exerciseCount", { count: exCount })} - {t("history.exerciseCount", { count: muscleCount })} + {t("home.muscleCount", { count: muscleCount })}
diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 8e9678f..d03eee1 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -98,10 +98,11 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { reader.onerror = () => reject(new Error('Kunne ikke laste bildet')); reader.onload = () => { const dataUrl = reader.result; + const mediaType = dataUrl.split(';')[0].split(':')[1] || 'image/jpeg'; const b64 = dataUrl.split(',')[1]; // base64 decoded bytes ≈ b64.length × 3/4 if (b64.length * 0.75 <= maxDecodedBytes) { - resolve({ base64: b64, mediaType: 'image/jpeg' }); + resolve({ base64: b64, mediaType }); return; } // Over limit — use a blob URL so iOS can decode the image reliably. From 2e04a030408eb0f88fe1d41d99e7780d7231d4c6 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 14 May 2026 04:32:11 +0200 Subject: [PATCH 14/30] fix: rewrite compressImage to use fixed dimension targets instead of recursive 0.7x factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous algorithm started at 2048px and reduced by 0.7x each step. For photos already smaller than 2048px (e.g. 1440x1080), no resize happened on the first pass, and the 0.7x steps (2048→1434) produced almost no reduction from the original 1440px width — both yielding the same 5,246,896 byte output since iOS ignores the quality parameter entirely. New approach: iterate over fixed targets [1600, 1200, 960, 768, 600]. A 1440px photo skips 1600 (no resize), gets capped at 1200x900 on the next step, and produces a JPEG well under 5 MB. Large iPhone photos (4032×3024) are capped at 1600px immediately. Uses fixed quality 0.85; iOS ignores it anyway, so dimension is the only lever. Co-Authored-By: Claude Sonnet 4.6 --- app/src/lib/utils.js | 64 +++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index d03eee1..0dd7b74 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -84,14 +84,14 @@ export async function detectMediaType(file) { // Compress an image to JPEG and ensure decoded size is under Anthropic's 5 MB limit. // Strategy: // 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native -// resolution. If the result is already under 5 MB, use it directly with zero -// quality loss (this is the happy path for most photos). -// 2. Only if over 5 MB: load the original file via a blob URL, NOT the huge data URL -// string (iOS Safari silently fails to decode large data URLs — naturalWidth = 0). -// Start at 2048px long edge and step quality 0.9→0.3. If quality alone doesn't -// reduce size (iOS Safari ignores the quality param on some versions), shrink -// dimensions by 30% and retry from quality 0.9. Repeat until it fits or we reach -// 800px, then resolve unconditionally. +// resolution. If the result is already under 5 MB, use it directly. +// 2. Only if over 5 MB: load the original file via a blob URL (NOT the large data URL +// string — iOS Safari silently zeros naturalWidth for large data URLs) and draw onto +// a canvas, shrinking until the output fits. iOS Safari ignores the quality param in +// canvas.toDataURL, so dimension reduction is the only reliable lever. We iterate +// over fixed target sizes rather than a recursive 0.7× factor — this handles photos +// that are already smaller than 2048px (e.g. 1440×1080) where the old factor +// produced almost no size reduction between steps. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -105,41 +105,39 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { resolve({ base64: b64, mediaType }); return; } - // Over limit — use a blob URL so iOS can decode the image reliably. + // Over limit — compress via canvas using a blob URL. const blobUrl = URL.createObjectURL(file); const img = new Image(); img.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('Kunne ikke laste bildet')); }; img.onload = () => { URL.revokeObjectURL(blobUrl); - // tryDimension: draw image at maxEdge px long edge, then step quality down. - // If quality stepping doesn't reduce size (iOS ignores quality param), shrink - // dimensions by 30% and retry. Stop at 800px — accept whatever we have. - const tryDimension = (maxEdge) => { - let w = img.naturalWidth; - let h = img.naturalHeight; - if (Math.max(w, h) > maxEdge) { - if (w >= h) { h = Math.round(h * maxEdge / w); w = maxEdge; } - else { w = Math.round(w * maxEdge / h); h = maxEdge; } - } + const nw = img.naturalWidth || 1600; + const nh = img.naturalHeight || 1200; + // Try each target size in order. scale = 1 when photo is already smaller than + // the target — the canvas is drawn at native size and the next smaller target + // takes over if it still doesn't fit. + for (const maxEdge of [1600, 1200, 960, 768, 600]) { + const scale = Math.min(1, maxEdge / Math.max(nw, nh)); + const w = Math.max(1, Math.round(nw * scale)); + const h = Math.max(1, Math.round(nh * scale)); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); - const tryQuality = (quality) => { - const d = canvas.toDataURL('image/jpeg', quality); - const b = d.split(',')[1]; - if (b.length * 0.75 <= maxDecodedBytes) { - resolve({ base64: b, mediaType: 'image/jpeg' }); - return; - } - if (quality > 0.3) { tryQuality(parseFloat((quality - 0.1).toFixed(1))); return; } - // Quality exhausted — shrink dimensions and retry - if (maxEdge > 800) { tryDimension(Math.round(maxEdge * 0.7)); return; } + const d = canvas.toDataURL('image/jpeg', 0.85); + const b = d.split(',')[1]; + if (b.length * 0.75 <= maxDecodedBytes) { resolve({ base64: b, mediaType: 'image/jpeg' }); - }; - tryQuality(0.9); - }; - tryDimension(2048); + return; + } + } + // Fallback: 600px at 0.7 quality — accept regardless of size. + const canvas = document.createElement('canvas'); + const scale = Math.min(1, 600 / Math.max(nw, nh)); + canvas.width = Math.max(1, Math.round(nw * scale)); + canvas.height = Math.max(1, Math.round(nh * scale)); + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + resolve({ base64: canvas.toDataURL('image/jpeg', 0.7).split(',')[1], mediaType: 'image/jpeg' }); }; img.src = blobUrl; }; From 9597b422aaf59281464efaa8347a179ef0a25e61 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 14 May 2026 04:33:47 +0200 Subject: [PATCH 15/30] fix: deduplicate ensureGymMembership/ensureDisplayName with a ref flag getSession().then() and onAuthStateChange(SIGNED_IN) can both fire on the same page load (observed on iOS and Chrome staging). The previous SIGNED_IN guard still allowed a double-call when getSession() resolved first with an existing session. A ref flag ensures the ensures run exactly once per mount regardless of which path fires first or whether SIGNED_IN fires redundantly. Also extends the guard to INITIAL_SESSION so returning users are covered even if SIGNED_IN doesn't fire on their platform. Co-Authored-By: Claude Sonnet 4.6 --- app/src/App.jsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/App.jsx b/app/src/App.jsx index 0af220e..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,19 +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) => { setSession(session); - // Only run on actual sign-in — INITIAL_SESSION, TOKEN_REFRESHED, etc. fire on - // every page load and would trigger redundant upserts on every auth event. - if (event === "SIGNED_IN" && session) { - ensureGymMembership().catch(() => {}); - ensureDisplayName().catch(() => {}); - } + if ((event === "SIGNED_IN" || event === "INITIAL_SESSION") && session) runEnsures(); }); return () => subscription.unsubscribe(); }, []); From be93a7b343067845dcc648f8e2f80c50587e74e0 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 14 May 2026 04:51:25 +0200 Subject: [PATCH 16/30] fix: use data URL for canvas load in compressImage; target 4.5 MB; skip 1600 px step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch from blob URL to data URL for the canvas img src — avoids HEIC blob-URL load issues seen on some iOS Safari versions (blob URLs work in Safari but can silently misbehave for HEIC files in WKWebView contexts) - Skip the 1600 px loop step: for 1440 px-wide iPhone photos scale = 1.0, so the step produces zero reduction and just wastes an iOS JPEG encode pass - Target 4.5 MB instead of 5 MB in the loop to give a safety margin against estimation error from the b64.length × 0.75 approximation - Use break + resolved flag instead of return from inside the for-of loop Co-Authored-By: Claude Sonnet 4.6 --- app/src/lib/utils.js | 53 +++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/app/src/lib/utils.js b/app/src/lib/utils.js index 0dd7b74..5026fb2 100644 --- a/app/src/lib/utils.js +++ b/app/src/lib/utils.js @@ -85,13 +85,12 @@ export async function detectMediaType(file) { // Strategy: // 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native // resolution. If the result is already under 5 MB, use it directly. -// 2. Only if over 5 MB: load the original file via a blob URL (NOT the large data URL -// string — iOS Safari silently zeros naturalWidth for large data URLs) and draw onto -// a canvas, shrinking until the output fits. iOS Safari ignores the quality param in -// canvas.toDataURL, so dimension reduction is the only reliable lever. We iterate -// over fixed target sizes rather than a recursive 0.7× factor — this handles photos -// that are already smaller than 2048px (e.g. 1440×1080) where the old factor -// produced almost no size reduction between steps. +// 2. Only if over 5 MB: load the data URL into a canvas img (the data URL is already +// in memory; blob URLs can have quirks with HEIC files on some iOS versions). +// iOS Safari ignores the quality param in canvas.toDataURL, so dimension reduction +// is the only reliable lever. We target 4.5 MB (not 5 MB) for a safety margin. +// We start at 1200 px — the 1600 px step is a no-op for typical 1440 px-wide photos +// and just wastes a round-trip through the iOS JPEG encoder at the same size. export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -105,18 +104,19 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { resolve({ base64: b64, mediaType }); return; } - // Over limit — compress via canvas using a blob URL. - const blobUrl = URL.createObjectURL(file); + // Over limit — compress via canvas. + // Use the data URL we already have rather than a new blob URL; avoids + // HEIC blob-URL load quirks seen on some iOS Safari versions. const img = new Image(); - img.onerror = () => { URL.revokeObjectURL(blobUrl); reject(new Error('Kunne ikke laste bildet')); }; + img.onerror = () => reject(new Error('Kunne ikke laste bildet')); img.onload = () => { - URL.revokeObjectURL(blobUrl); const nw = img.naturalWidth || 1600; const nh = img.naturalHeight || 1200; - // Try each target size in order. scale = 1 when photo is already smaller than - // the target — the canvas is drawn at native size and the next smaller target - // takes over if it still doesn't fit. - for (const maxEdge of [1600, 1200, 960, 768, 600]) { + // Target 4.5 MB to stay safely below the 5 MB API limit; iOS ignores + // the quality param so only dimension reduction reliably shrinks output. + const target = 4.5 * 1024 * 1024; + let resolved = false; + for (const maxEdge of [1200, 960, 768, 600]) { const scale = Math.min(1, maxEdge / Math.max(nw, nh)); const w = Math.max(1, Math.round(nw * scale)); const h = Math.max(1, Math.round(nh * scale)); @@ -126,20 +126,23 @@ export function compressImage(file, maxDecodedBytes = 5 * 1024 * 1024) { canvas.getContext('2d').drawImage(img, 0, 0, w, h); const d = canvas.toDataURL('image/jpeg', 0.85); const b = d.split(',')[1]; - if (b.length * 0.75 <= maxDecodedBytes) { + if (b.length * 0.75 <= target) { resolve({ base64: b, mediaType: 'image/jpeg' }); - return; + resolved = true; + break; } } - // Fallback: 600px at 0.7 quality — accept regardless of size. - const canvas = document.createElement('canvas'); - const scale = Math.min(1, 600 / Math.max(nw, nh)); - canvas.width = Math.max(1, Math.round(nw * scale)); - canvas.height = Math.max(1, Math.round(nh * scale)); - canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); - resolve({ base64: canvas.toDataURL('image/jpeg', 0.7).split(',')[1], mediaType: 'image/jpeg' }); + if (!resolved) { + // Fallback: 600px at 0.7 quality — accept regardless of size. + const canvas = document.createElement('canvas'); + const scale = Math.min(1, 600 / Math.max(nw, nh)); + canvas.width = Math.max(1, Math.round(nw * scale)); + canvas.height = Math.max(1, Math.round(nh * scale)); + canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + resolve({ base64: canvas.toDataURL('image/jpeg', 0.7).split(',')[1], mediaType: 'image/jpeg' }); + } }; - img.src = blobUrl; + img.src = dataUrl; }; reader.readAsDataURL(file); }); From 7dcc07df9b6232aee01552f31031a12d5fc210f1 Mon Sep 17 00:00:00 2001 From: Christopher Rotnes Date: Thu, 14 May 2026 04:59:12 +0200 Subject: [PATCH 17/30] fix: use img.decode() for reliable canvas load on iOS; add debug size label to thumbnails - Replace img.onload with img.decode() in the compressImage canvas branch. img.decode() waits until the bitmap is fully in the rendering pipeline, which is more reliable than onload on mobile WebKit (onload can fire before pixel data is available to drawImage on some iOS Safari versions). Falls back to onload for browsers without img.decode support. - Add a visible MB size label at the bottom of each uploaded image thumbnail. This shows the actual base64-decoded size stored in React state so we can confirm on-device whether compression is working before the API call. Co-Authored-By: Claude Sonnet 4.6 --- app/src/components/MuscleMap.jsx | 3 +++ app/src/lib/utils.js | 13 +++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index 3163b23..2ca664e 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -367,6 +367,9 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {images.map((img, idx) => (
{t("muscleMap.imageAlt", +
+ {(img.base64.length * 0.75 / 1024 / 1024).toFixed(2)} MB +