init
This commit is contained in:
80
CLAUDE.md
80
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
|
@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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { InfoTooltip } from "@/components/info-tooltip";
|
|||||||
import { formatDate, formatDateShort } from "@/lib/format";
|
import { formatDate, formatDateShort } from "@/lib/format";
|
||||||
import { getLatestAnalysisForTarget } from "@/lib/models/analysis";
|
import { getLatestAnalysisForTarget } from "@/lib/models/analysis";
|
||||||
import { getStrengthWorkout, listStrengthWorkouts } from "@/lib/models/strength";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -43,19 +43,31 @@ export default async function StrengthWorkoutPage({
|
|||||||
<AiAnalysisCard targetType="strength" targetId={workout._id.toString()} analysis={analysis} />
|
<AiAnalysisCard targetType="strength" targetId={workout._id.toString()} analysis={analysis} />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
||||||
{exercisesWithHistory.map(({ exercise }, index) => (
|
{exercisesWithHistory.map(({ exercise }, index) => {
|
||||||
<div key={index} className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface px-3 py-2.5">
|
const e1rm = exerciseE1rm(exercise);
|
||||||
<p className="text-xs font-semibold text-fg">{exercise.name}</p>
|
return (
|
||||||
{exercise.notes ? <p className="text-xs text-fg/50 italic">{exercise.notes}</p> : null}
|
<div key={index} className="flex flex-col gap-2 rounded-lg border border-muted/40 bg-surface px-3 py-2.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
<p className="text-xs font-semibold text-fg">{exercise.name}</p>
|
||||||
{exercise.sets.map((set) => (
|
{exercise.notes ? <p className="text-xs text-fg/50 italic">{exercise.notes}</p> : null}
|
||||||
<span key={set.order} className="rounded bg-bg px-1.5 py-0.5 text-xs text-fg/70">
|
<div className="flex flex-wrap gap-1">
|
||||||
{set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"}
|
{exercise.sets.map((set) => {
|
||||||
</span>
|
const pct =
|
||||||
))}
|
e1rm && set.weightKg != null
|
||||||
|
? Math.round((set.weightKg / e1rm) * 100)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<span key={set.order} className="rounded bg-bg px-1.5 py-0.5 text-xs text-fg/70">
|
||||||
|
{set.reps ?? "?"}×{set.weightKg !== undefined ? `${set.weightKg} kg` : "—"}
|
||||||
|
{pct !== null ? (
|
||||||
|
<span className="ml-1 text-fg/40">{pct}%</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exercisesWithHistory.some(({ history }) => history.length >= 2) ? (
|
{exercisesWithHistory.some(({ history }) => history.length >= 2) ? (
|
||||||
@@ -75,6 +87,7 @@ export default async function StrengthWorkoutPage({
|
|||||||
label: formatDateShort(point.date),
|
label: formatDateShort(point.date),
|
||||||
volumeKg: point.volumeKg,
|
volumeKg: point.volumeKg,
|
||||||
topWeightKg: point.topWeightKg,
|
topWeightKg: point.topWeightKg,
|
||||||
|
e1rmKg: point.e1rmKg,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,9 +4,27 @@ import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YA
|
|||||||
|
|
||||||
type ExerciseProgressChartProps = {
|
type ExerciseProgressChartProps = {
|
||||||
name: string;
|
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 (
|
||||||
|
<span className={`text-xs font-medium tabular-nums ${color}`}>
|
||||||
|
E1RM {sign}{diffKg} kg ({sign}{diffPct}%)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps) {
|
export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps) {
|
||||||
if (data.length < 2) {
|
if (data.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
@@ -14,7 +32,10 @@ export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
|
<div className="w-full rounded-lg border border-muted/40 bg-surface p-4">
|
||||||
<div className="mb-2 text-sm text-fg/60">{name}</div>
|
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm text-fg/60">{name}</span>
|
||||||
|
<E1rmDelta data={data} />
|
||||||
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={150}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
<CartesianGrid stroke="var(--color-muted)" opacity={0.3} vertical={false} />
|
||||||
@@ -36,10 +57,12 @@ export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "var(--color-fg)",
|
color: "var(--color-fg)",
|
||||||
}}
|
}}
|
||||||
formatter={(value, key) => [
|
formatter={(value, key) => {
|
||||||
key === "topWeightKg" ? `${value} kg` : `${Math.round(Number(value)).toLocaleString("pl-PL")} kg`,
|
if (key === "volumeKg") return [`${Math.round(Number(value)).toLocaleString("pl-PL")} kg`, "Wolumen"];
|
||||||
key === "topWeightKg" ? "Maks. ciężar" : "Wolumen",
|
if (key === "topWeightKg") return [`${value} kg`, "Maks. ciężar"];
|
||||||
]}
|
if (key === "e1rmKg") return [`${value} kg`, "E1RM (Epley)"];
|
||||||
|
return [`${value}`, String(key)];
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="volume"
|
yAxisId="volume"
|
||||||
@@ -57,6 +80,15 @@ export function ExerciseProgressChart({ name, data }: ExerciseProgressChartProps
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ r: 3 }}
|
dot={{ r: 3 }}
|
||||||
/>
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="weight"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="e1rmKg"
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { StrengthExercise, StrengthWorkout } from "@/lib/models/strength";
|
import type { StrengthExercise, StrengthWorkout } from "@/lib/models/strength";
|
||||||
|
|
||||||
|
|
||||||
export function exerciseVolumeKg(exercise: StrengthExercise): number {
|
export function exerciseVolumeKg(exercise: StrengthExercise): number {
|
||||||
return exercise.sets.reduce((sum, set) => sum + (set.weightKg ?? 0) * (set.reps ?? 0), 0);
|
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;
|
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 {
|
export function workoutVolumeKg(workout: StrengthWorkout): number {
|
||||||
return workout.exercises.reduce((sum, exercise) => sum + exerciseVolumeKg(exercise), 0);
|
return workout.exercises.reduce((sum, exercise) => sum + exerciseVolumeKg(exercise), 0);
|
||||||
}
|
}
|
||||||
@@ -19,6 +31,7 @@ export type ExerciseHistoryPoint = {
|
|||||||
date: Date;
|
date: Date;
|
||||||
volumeKg: number;
|
volumeKg: number;
|
||||||
topWeightKg?: number;
|
topWeightKg?: number;
|
||||||
|
e1rmKg?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +51,7 @@ export function getExerciseHistory(
|
|||||||
date: workout.date,
|
date: workout.date,
|
||||||
volumeKg: exerciseVolumeKg(exercise),
|
volumeKg: exerciseVolumeKg(exercise),
|
||||||
topWeightKg: exerciseTopWeightKg(exercise),
|
topWeightKg: exerciseTopWeightKg(exercise),
|
||||||
|
e1rmKg: exerciseE1rm(exercise),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user