Files
knur-app/CLAUDE.md
Dominik Klarkowski 21e5db3409 init
2026-06-16 11:11:19 +02:00

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

  • 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.tsgetAuthorizedClient() 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.tsfetchRecentWellness(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.