5.2 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
Commands
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
paramsandsearchParamsin page components are alwaysPromise<...>— alwaysawaitthem.- All pages reading from MongoDB must export
export const dynamic = "force-dynamic"to prevent static caching. next/dynamicwithssr: falsecannot be called from Server Components — wrap it in a"use client"file (seecomponents/route-map-section.tsx).import dynamic from "next/dynamic"conflicts with thedynamicnamed 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 ongarminActivityId; also stores lazy-loadedroutePoints: [lat, lon][]strength_workouts— deduplicated onsourceKey(= the Strong share URL or a hash)ai_analyses— one doc per analysis; dashboard analysis uses sentineltargetId = 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 toRunningActivityInput;fetchActivityRoutePoints()fetches GPS polyline from the activity details endpointwellness.ts—fetchRecentWellness(client, days)fetches sleep/HRV data for N days concurrently viaPromise.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 sessionsgenerateDashboardAnalysis()— 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.