Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to Workout Lens are documented here.

## [1.2.5] — 2026-05-13

### Fixed
- **Image analysis broken (400 error)** — `CLAUDE_MODEL_VISION` was set to `claude-opus-4-5`, which has been retired by Anthropic. Switched vision to `claude-sonnet-4-6` (same model as text recommendations) — sufficient for OCR + JSON extraction and significantly cheaper than Opus. API allowlist simplified to a single entry.

### Added
- **Instructor filter on Report** — the report page now includes a fourth filter row (instructor display names) when sessions from more than one co-instructor are present in the selected period. Default is all instructors (empty selection = no filter), consistent with the existing weekday and session-type filter pattern. `fetchSessionsForReport` now joins `trainer_id` and `profiles(display_name)` so instructor identity is available client-side without an extra query.
- **Auto-set display name on login** — `ensureDisplayName()` in `db.js` runs alongside `ensureGymMembership()` on every auth state change. If the user's `profiles.display_name` is null, it is automatically set to the prefix before `@` in their email address. This ensures the instructor filter always has a meaningful label for every user without requiring manual action in Settings.

## [1.2.4] — 2026-05-12

### Fixed
Expand Down
52 changes: 49 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Photograph a handwritten gym whiteboard workout, and the app tells you which mus
11. **Settings** — language selector (top), theme toggle (dark/light) + nav hints toggle with live body map preview, contact, Om appen section (version + "Vis introduksjonsguide" replay button + changelog accordion), and account section: display name input + sign-out (bottom)
12. **First-login intro guide** — a 5-slide modal appears automatically on first login (gated by `localStorage` key `wl-intro-seen`); walks through Upload → History → Report → Planner → Library; skippable and replayable from Settings
13. **Joint class history** — when a gym-linked session is expanded in History, a "Kolleger i denne klassen" panel shows co-instructor sessions for the same class slot (display name + exercise list). All sessions are always visible to co-instructors at the same gym — this cross-instructor transparency is the core value of the shared view
14. **Report instructor filter** — when sessions from multiple co-instructors appear in the selected period, a fourth filter row with instructor name chips appears on the Report page; default is all instructors visible; display names are auto-set to the email prefix on first login so the filter always shows a meaningful label
14. **Polished dark/light theme** — IBM Carbon g100 (dark) and g10 (light) themes with no flash-of-unstyled-content on page load or view navigation; theme persists across sessions and respects `prefers-color-scheme` on first visit

## Tech stack
Expand Down
2 changes: 1 addition & 1 deletion app/api/__tests__/claudeUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

describe('ALLOWED_MODELS', () => {
it('contains exactly the two production model IDs', () => {
expect([...ALLOWED_MODELS].sort()).toEqual(['claude-opus-4-5', 'claude-sonnet-4-6']);
expect([...ALLOWED_MODELS].sort()).toEqual(['claude-sonnet-4-6']);
});

it('caps max_tokens at 2000', () => {
Expand Down
6 changes: 4 additions & 2 deletions app/api/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ app.http('claude', {

const data = await upstream.json();
if (!upstream.ok) {
context.error('Anthropic error:', JSON.stringify(data));
const detail = data?.error?.message || 'Unknown error';
const errorType = data?.error?.type || 'unknown';
context.error(`Anthropic error [${errorType}]: ${detail}`);
return new Response(
JSON.stringify({ error: 'Claude request failed' }),
JSON.stringify({ error: 'Claude request failed', detail }),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } }
);
}
Expand Down
1 change: 0 additions & 1 deletion app/api/claudeUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export const ALLOWED_MODELS = new Set([
'claude-opus-4-5',
'claude-sonnet-4-6',
]);
export const MAX_TOKENS_LIMIT = 2000;
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@
"7": "7 days",
"30": "30 days",
"90": "90 days"
}
},
"unnamed": "Unknown"
},
"exerciseRow": {
"namePlaceholder": "Click to enter exercise…",
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/fa/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@
"7": "۷ روز",
"30": "۳۰ روز",
"90": "۹۰ روز"
}
},
"unnamed": "ناشناس"
},
"exerciseRow": {
"namePlaceholder": "برای نوشتن تمرین کلیک کنید…",
Expand Down
3 changes: 2 additions & 1 deletion app/public/locales/nb/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@
"7": "7 dager",
"30": "30 dager",
"90": "90 dager"
}
},
"unnamed": "Ukjent"
},
"exerciseRow": {
"namePlaceholder": "Klikk for å skrive øvelse…",
Expand Down
2 changes: 1 addition & 1 deletion app/public/staticwebapp.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://*.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'"
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'sha256-S1NwxpfinBiP8uiGmiz+HYOp4lnKrfbe0hf7PcCP3Nk='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://*.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'"
},
"platform": {
"apiRuntime": "node:22"
Expand Down
6 changes: 3 additions & 3 deletions app/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { supabase } from "./lib/supabase";
import { ensureGymMembership } from "./lib/db";
import { ensureGymMembership, ensureDisplayName } from "./lib/db";
import { NavContext } from "./lib/NavContext";
import Login from "./components/Login";
import Home from "./components/Home";
Expand All @@ -27,11 +27,11 @@ function App() {
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) ensureGymMembership().catch(() => {});
if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); }
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session) ensureGymMembership().catch(() => {});
if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); }
});
return () => subscription.unsubscribe();
}, []);
Expand Down
38 changes: 33 additions & 5 deletions app/src/components/Report.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default function Report({ prefill, onPrefillConsumed }) {
const [periodDays, setPeriodDays] = useState(30);
const [selectedDays, setSelectedDays] = useState(new Set());
const [selectedTypes, setSelectedTypes] = useState(new Set());
const [selectedInstructors, setSelectedInstructors] = useState(new Set());
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
Expand Down Expand Up @@ -191,6 +192,14 @@ export default function Report({ prefill, onPrefillConsumed }) {
return [...names].sort();
}, [sessions]);

const availableInstructors = useMemo(() => {
const names = new Set();
sessions.forEach(s => {
if (s.gym_calendar?.instructor) names.add(s.gym_calendar.instructor);
});
return [...names].sort((a, b) => a.localeCompare(b));
}, [sessions]);

const filteredSessions = useMemo(() => {
return sessions.filter(s => {
if (selectedDays.size > 0) {
Expand All @@ -201,9 +210,12 @@ export default function Report({ prefill, onPrefillConsumed }) {
const name = s.gym_calendar?.name || null;
if (!name || !selectedTypes.has(name)) return false;
}
if (selectedInstructors.size > 0) {
if (!selectedInstructors.has(s.gym_calendar?.instructor)) return false;
}
return true;
});
}, [sessions, selectedDays, selectedTypes]);
}, [sessions, selectedDays, selectedTypes, selectedInstructors]);

const { muscleCounts, maxPrimaryCount, muscleExercises, muscleVolume, muscleLastDate } = useMemo(() => {
const primarySessions = {};
Expand Down Expand Up @@ -262,6 +274,14 @@ export default function Report({ prefill, onPrefillConsumed }) {
});
};

const toggleInstructor = (id) => {
setSelectedInstructors(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};

const sessionCount = filteredSessions.length;
const musclesCovered = Object.values(muscleCounts).filter(c => c.primary > 0).length;
const avgPerWeek = (sessionCount / (periodDays / 7)).toFixed(1);
Expand Down Expand Up @@ -291,7 +311,7 @@ export default function Report({ prefill, onPrefillConsumed }) {
return () => { cancelled = true; };
// muscleCounts, sessionCount, untrainedMuscles are derived from the state values already in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [periodDays, selectedDays, selectedTypes, sessions]);
}, [periodDays, selectedDays, selectedTypes, selectedInstructors, sessions]);

const dayLabel = selectedDays.size > 0
? DAYS.filter(d => selectedDays.has(d.day)).map(d => d.label.toUpperCase()).join(" · ")
Expand Down Expand Up @@ -337,14 +357,22 @@ export default function Report({ prefill, onPrefillConsumed }) {
))}
</div>
)}
{/* Row 4: instructors — only when >1 instructor present */}
{availableInstructors.length > 1 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", paddingTop: 8, paddingBottom: 8, borderTop: "1px solid var(--border-subtle-wl)" }}>
{availableInstructors.map(name => (
<FilterChip key={name} label={name} active={selectedInstructors.has(name)} onClick={() => toggleInstructor(name)} />
))}
</div>
)}
<button
onClick={() => { setSelectedDays(new Set()); setSelectedTypes(new Set()); }}
onClick={() => { setSelectedDays(new Set()); setSelectedTypes(new Set()); setSelectedInstructors(new Set()); }}
style={{
display: "block", background: "none", border: "none", padding: "4px 0 0", cursor: "pointer",
fontSize: 11, color: "var(--accent)", fontFamily: "var(--cds-font-mono)",
letterSpacing: "0.06em", textAlign: "left",
opacity: (selectedDays.size > 0 || selectedTypes.size > 0) ? 1 : 0,
pointerEvents: (selectedDays.size > 0 || selectedTypes.size > 0) ? "auto" : "none",
opacity: (selectedDays.size > 0 || selectedTypes.size > 0 || selectedInstructors.size > 0) ? 1 : 0,
pointerEvents: (selectedDays.size > 0 || selectedTypes.size > 0 || selectedInstructors.size > 0) ? "auto" : "none",
}}
>
{t("common.resetFilter")}
Expand Down
12 changes: 11 additions & 1 deletion app/src/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export async function fetchSessionsForReport(fromDate, toDate) {
.from("sessions")
.select(`
id, session_date, gym_calendar_id,
gym_calendar(name, start_time),
gym_calendar(name, start_time, instructor),
session_exercises(
id, name,
muscle_activations(muscle_id, activation_type)
Expand Down Expand Up @@ -422,6 +422,16 @@ export async function updateDisplayName(displayName) {
if (error) throw error;
}

export async function ensureDisplayName() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase.from("profiles").select("display_name").eq("id", user.id).maybeSingle();
if (data?.display_name) return;
const derived = user.email?.split("@")[0] || null;
if (!derived) return;
await supabase.from("profiles").update({ display_name: derived }).eq("id", user.id);
}

// ── USER GYMS ─────────────────────────────────────────────────────────

export const DEFAULT_SPORTY_BUSINESS_UNIT_ID = 8;
Expand Down
4 changes: 2 additions & 2 deletions app/src/lib/prompts.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MUSCLES } from './bodymap.jsx';

// claude-opus-4-5 is used for vision tasks (whiteboard photo analysis).
export const CLAUDE_MODEL_VISION = "claude-opus-4-5";
// claude-sonnet-4-6 is used for all Claude calls (vision and text).
export const CLAUDE_MODEL_VISION = "claude-sonnet-4-6";

// claude-sonnet-4-6 is used for text-only tasks (recommendations).
export const CLAUDE_MODEL_TEXT = "claude-sonnet-4-6";
Expand Down
Loading