Files
knur-app/CLAUDE.md

82 lines
5.2 KiB
Markdown
Raw Normal View History

2026-06-16 11:11:19 +02:00
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
2026-06-15 13:04:53 +02:00
@AGENTS.md
2026-06-16 11:11:19 +02:00
## 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.