From 21e5db34097e9da3f4436bf0e20230e7957b31d7 Mon Sep 17 00:00:00 2001 From: Dominik Klarkowski Date: Tue, 16 Jun 2026 11:11:19 +0200 Subject: [PATCH] init --- CLAUDE.md | 80 ++++++++++++++++++++++++++ app/strength/[id]/page.tsx | 39 ++++++++----- components/exercise-progress-chart.tsx | 44 ++++++++++++-- lib/strength/stats.ts | 14 +++++ 4 files changed, 158 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..e4989dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + @AGENTS.md + +## Commands + +```bash +pnpm dev # start dev server (Turbopack, port 3000) +pnpm build # production build — catches TS and async-params errors +pnpm lint # eslint +``` + +No test suite exists. Type-check only: `pnpm tsc --noEmit`. + +## Architecture + +**Knur** is a single-user personal training tracker: running data from Garmin Connect, strength workouts imported from the Strong app, AI analysis via Claude. + +Stack: Next.js 16.2.9 · App Router · React 19 · Turbopack · Tailwind v4 · MongoDB · TypeScript. + +### Key constraints from Next.js 16 + +- `params` and `searchParams` in page components are always `Promise<...>` — always `await` them. +- All pages reading from MongoDB must export `export const dynamic = "force-dynamic"` to prevent static caching. +- `next/dynamic` with `ssr: false` cannot be called from Server Components — wrap it in a `"use client"` file (see `components/route-map-section.tsx`). +- `import dynamic from "next/dynamic"` conflicts with the `dynamic` named export — rename the import: `import nextDynamic from "next/dynamic"`. + +### Data layer (`lib/`) + +`lib/db.ts` — singleton `MongoClient` cached on `global` to survive HMR. Call `getDb()` to get the `Db` instance. Database name defaults to `knur`. + +`lib/models/` — each model file exports a Zod schema, TypeScript types derived from it, and MongoDB CRUD helpers. MongoDB collections: +- `running_activities` — deduplicated on `garminActivityId`; also stores lazy-loaded `routePoints: [lat, lon][]` +- `strength_workouts` — deduplicated on `sourceKey` (= the Strong share URL or a hash) +- `ai_analyses` — one doc per analysis; dashboard analysis uses sentinel `targetId = ObjectId("000000000000000000000001")` +- `garmin_auth` — OAuth1 tokens (`_id: "tokens"`) and pending MFA state (`_id: "pending"`) +- `sync_state` — singleton `{ _id: "garmin", lastSyncAt: Date }` + +`lib/garmin/` — Garmin Connect integration: +- `sso.ts` — custom SSO login flow (fetches CSRF, posts credentials, handles MFA redirect) +- `client.ts` — `getAuthorizedClient()` loads saved OAuth1 tokens; `fetchRunningActivities()` fetches up to 50 activities and maps them to `RunningActivityInput`; `fetchActivityRoutePoints()` fetches GPS polyline from the activity details endpoint +- `wellness.ts` — `fetchRecentWellness(client, days)` fetches sleep/HRV data for N days concurrently via `Promise.allSettled` + +`lib/strong/parser.ts` — parses the plain-text "Share workout" output from the Strong iOS app into `StrengthWorkout[]`. Handles multiple workouts in one paste, comma-decimal weights (`12,5 kg`), and optional per-exercise `Notes:` blocks. + +`lib/ai/claude.ts` — builds prompts and calls the Anthropic API. Three entry points: +- `generateAnalysis(targetType, targetId)` — per-workout/run analysis with context from previous sessions +- `generateDashboardAnalysis()` — holistic analysis combining last 6 runs + 4 workouts + 7 days of Garmin wellness data; Garmin wellness fetch failure is swallowed so analysis works without it + +`lib/strength/stats.ts` — pure helpers for computing exercise volume/top-weight history (`getExerciseHistory`). + +### App routes (`app/`) + +| Route | Purpose | +|---|---| +| `/` | Dashboard: weekly stats, latest run/workout, comprehensive AI analysis card | +| `/running` | Activity list + Garmin sync button | +| `/running/[id]` | 3-col grid: route map (lazy via Suspense) + highlighted stats | +| `/strength` | Workout list + volume chart | +| `/strength/import` | Textarea form to paste Strong share text | +| `/strength/[id]` | Exercise cards + AI analysis + progress charts | +| `/settings` | Env config status + Garmin sync | +| `/ai/actions.ts` | Server actions: `generateAnalysisAction`, `generateDashboardAnalysisAction` | + +### Garmin auth flow + +Garmin uses OAuth1 with a custom SSO. The first sync triggers a username/password login via `lib/garmin/sso.ts`. If MFA is required, the pending cookie state is stored in MongoDB (`garmin_login_pending`) and the UI prompts for the MFA code. On success, OAuth1 tokens are saved to `garmin_auth` and reused for all subsequent requests via `getAuthorizedClient()`. + +### Route map (GPS) + +GPS points are fetched lazily on first visit to a run detail page. `RouteMapFetcher` (async Server Component in `app/running/[id]/page.tsx`) checks `activity.routePoints` in DB — if absent and `hasRoute` is true, fetches from Garmin and caches. The map renders via Leaflet (`react-leaflet`) with CartoDB dark tiles (no API key required). Leaflet requires SSR bypass — see `components/route-map-section.tsx` ("use client" wrapper with `next/dynamic ssr: false`). + +### Theming + +Tailwind v4 with CSS custom properties defined in `app/globals.css`. Key tokens: `bg` (dark navy), `surface` (slightly lighter), `fg` (cream), `accent` (orange `#fb4617`), `muted`, `sand`. Use `var(--color-accent)` etc. when CSS variables are needed outside Tailwind (recharts, Leaflet SVG). + +### recharts pitfall + +`ResponsiveContainer` with `height="100%"` inside a dynamically-sized wrapper overflows when labels add extra space. Always use a fixed pixel `height` on `ResponsiveContainer` and do NOT set a fixed height on the wrapper div. diff --git a/app/strength/[id]/page.tsx b/app/strength/[id]/page.tsx index 4fbdd05..7152615 100644 --- a/app/strength/[id]/page.tsx +++ b/app/strength/[id]/page.tsx @@ -5,7 +5,7 @@ import { InfoTooltip } from "@/components/info-tooltip"; import { formatDate, formatDateShort } from "@/lib/format"; import { getLatestAnalysisForTarget } from "@/lib/models/analysis"; import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength"; -import { getExerciseHistory } from "@/lib/strength/stats"; +import { exerciseE1rm, getExerciseHistory } from "@/lib/strength/stats"; export const dynamic = "force-dynamic"; @@ -43,19 +43,31 @@ export default async function StrengthWorkoutPage({
- {exercisesWithHistory.map(({ exercise }, index) => ( -
-

{exercise.name}

- {exercise.notes ?

{exercise.notes}

: null} -
- {exercise.sets.map((set) => ( - - {set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"} - - ))} + {exercisesWithHistory.map(({ exercise }, index) => { + const e1rm = exerciseE1rm(exercise); + return ( +
+

{exercise.name}

+ {exercise.notes ?

{exercise.notes}

: null} +
+ {exercise.sets.map((set) => { + const pct = + e1rm && set.weightKg != null + ? Math.round((set.weightKg / e1rm) * 100) + : null; + return ( + + {set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"} + {pct !== null ? ( + {pct}% + ) : null} + + ); + })} +
-
- ))} + ); + })}
{exercisesWithHistory.some(({ history }) => history.length >= 2) ? ( @@ -75,6 +87,7 @@ export default async function StrengthWorkoutPage({ label: formatDateShort(point.date), volumeKg: point.volumeKg, topWeightKg: point.topWeightKg, + e1rmKg: point.e1rmKg, }))} /> ))} diff --git a/components/exercise-progress-chart.tsx b/components/exercise-progress-chart.tsx index 186f9e0..6becc27 100644 --- a/components/exercise-progress-chart.tsx +++ b/components/exercise-progress-chart.tsx @@ -4,9 +4,27 @@ import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YA type ExerciseProgressChartProps = { name: string; - data: { label: string; volumeKg: number; topWeightKg?: number }[]; + data: { label: string; volumeKg: number; topWeightKg?: number; e1rmKg?: number }[]; }; +function E1rmDelta({ data }: { data: ExerciseProgressChartProps["data"] }) { + const points = data.filter((d) => d.e1rmKg != null).slice(-5); + if (points.length < 2) return null; + + const first = points[0].e1rmKg!; + const last = points[points.length - 1].e1rmKg!; + const diffKg = Math.round((last - first) * 10) / 10; + const diffPct = Math.round(((last - first) / first) * 100); + const sign = diffKg >= 0 ? "+" : ""; + const color = diffKg > 0 ? "text-emerald-400" : diffKg < 0 ? "text-rose-400" : "text-fg/40"; + + return ( + + E1RM {sign}{diffKg} kg ({sign}{diffPct}%) + + ); +} + export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps) { if (data.length < 2) { return null; @@ -14,7 +32,10 @@ export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps return (
-
{name}
+
+ {name} + +
@@ -36,10 +57,12 @@ export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps fontSize: 12, color: "var(--color-fg)", }} - formatter={(value, key) => [ - key === "topWeightKg" ? `${value} kg` : `${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, - key === "topWeightKg" ? "Maks. ciężar" : "Wolumen", - ]} + formatter={(value, key) => { + if (key === "volumeKg") return [`${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, "Wolumen"]; + if (key === "topWeightKg") return [`${value} kg`, "Maks. ciężar"]; + if (key === "e1rmKg") return [`${value} kg`, "E1RM (Epley)"]; + return [`${value}`, String(key)]; + }} /> +
diff --git a/lib/strength/stats.ts b/lib/strength/stats.ts index 720c05a..48713bd 100644 --- a/lib/strength/stats.ts +++ b/lib/strength/stats.ts @@ -1,5 +1,6 @@ import type { StrengthExercise, StrengthWorkout } from "@/lib/models/strength"; + export function exerciseVolumeKg(exercise: StrengthExercise): number { return exercise.sets.reduce((sum, set) => sum + (set.weightKg ?? 0) * (set.reps ?? 0), 0); } @@ -11,6 +12,17 @@ export function exerciseTopWeightKg(exercise: StrengthExercise): number | undefi return weights.length > 0 ? Math.max(...weights) : undefined; } +// Epley formula: weight × (1 + reps / 30). Returns best e1rm across all sets. +export function exerciseE1rm(exercise: StrengthExercise): number | undefined { + let best: number | undefined; + for (const set of exercise.sets) { + if (set.weightKg == null || set.reps == null || set.reps < 1) continue; + const e1rm = set.weightKg * (1 + set.reps / 30); + if (best === undefined || e1rm > best) best = e1rm; + } + return best !== undefined ? Math.round(best * 10) / 10 : undefined; +} + export function workoutVolumeKg(workout: StrengthWorkout): number { return workout.exercises.reduce((sum, exercise) => sum + exerciseVolumeKg(exercise), 0); } @@ -19,6 +31,7 @@ export type ExerciseHistoryPoint = { date: Date; volumeKg: number; topWeightKg?: number; + e1rmKg?: number; }; /** @@ -38,6 +51,7 @@ export function getExerciseHistory( date: workout.date, volumeKg: exerciseVolumeKg(exercise), topWeightKg: exerciseTopWeightKg(exercise), + e1rmKg: exerciseE1rm(exercise), }); }