Guidance for Claude Code when working in this repo. Architecture details live in docs/architecture.md, docs/food-macro-integration.md, and docs/hevy-food-alignment.md — this file captures the high-level layout and gotchas.
npm run dev # Next.js dev server (http://localhost:3000)
npm run build # production build (prebuild refreshes Hevy catalog + embeddings)
npm run lint # ESLint
npm test # Jest, all suites
npm run test:watch # Jest watch mode
npx jest path/to/file.test.ts # single test file
npx jest -t "test name pattern" # filter by test name
# Database (food log)
npm run db:up # start local Postgres (docker compose, :5433)
npm run db:down # stop
npm run db:generate # drizzle-kit generate (after schema edits)
npm run db:migrate # apply migrations
npm run db:seed # seed default macro target
npm run db:reset # nuke volume + remigrate + reseed
# Hevy catalog + embeddings
npm run refresh:hevy # repaginate exercise templates → catalog.json
npm run build:embeddings # build embedding catalog (auto provider)
npm run build:embeddings:both # build LM Studio + Transformers.js catalogs
npm run build:embeddings:check # exit 0 if catalogs up-to-date
# E2E (slow, hits real APIs)
npm run e2e:matching # matching only
npm run e2e:full # Groq + matching (needs GROQ_API_KEY)
npm run e2e:heic # HEIC convert + Groq (needs GROQ_API_KEY)
# Debug
npm run debug:match -- "DB Curl" # score breakdown for one query
# Agent harness (feature-flagged tool-use loop)
AGENT_HARNESS_PROVIDER=groq npm run agent:extract -- tests/fixtures/workout-revl-1.jpeg
AGENT_HARNESS_PROVIDER=lm-studio npm run agent:extract -- tests/fixtures/workout-revl-1.jpeg
AGENT_HARNESS_PROVIDER=claude-cli npm run agent:extract -- tests/fixtures/workout-revl-1.jpeg
AGENT_DEBUG_LOG=1 ... # writes JSONL trace to .agent-runs/npm run dev reads .env.local. Required keys:
GROQ_API_KEY— without it, workout extraction falls back to mock data (yellow banner).HEVY_API_KEY— needed for sync + catalog refresh.DATABASE_URL,FMA_BASE_URL,FMA_API_KEY,USER_TZ— needed for the food log.GARMIN_EMAIL/GARMIN_PASSWORD,GOOGLE_SA_KEY,GCAL_ID,AGENDA_SYNC_SECRET— dashboard agenda (Garmin + Calendar). Optional; without them the agenda just shows Hevy + an empty planned side. Garmin auto-mints + refreshes its token from email+password (requires 2FA off), caching it toGARMIN_TOKEN_DIR(default/tmp/garmin-token, container-local, no volume).GARMIN_TOKEN_B64is now just an optional first-boot seed (off-boxscripts/garmin/bootstrap.py). Seedocs/agenda-integration.md.
Two domains share the app shell: Hevy workout sync and food/macro log. They have symmetric provider shapes but different sources of truth (Hevy is SOT for workouts; local Postgres is SOT for food). The dashboard reads from both providers.
Workout. Upload (/upload) → /api/process-workout (lib/vision/extractWorkout) → fuzzy + embedding match against Hevy catalog → review/edit (/review) → useHevy().commitWorkout() → /api/hevy-sync → Hevy API. EXIF date extracted from image, manual override available. /sync route was collapsed into /review.
Food. Input (/food, search/text/photo/barcode tabs) → /api/food/{search,analyze/text,analyze/photo,analyze/barcode,analyze/barcode-photo} → FMA → review/edit → POST /api/food/log (Postgres). food-log-provider re-fetches today + week + targets + quickAdd. CalorieSummary reads from it.
Vision abstraction (lib/vision/). Wraps single-shot Groq, single-shot LM Studio, and the agent harness behind one extractWorkout(buffer, mime, filename, base64) API. HEIC normalization + EXIF extraction happen here. ExtractionResult is shared by the API route regardless of which provider ran.
Agent harness alt path. When AGENT_HARNESS_PROVIDER ∈ {groq, lm-studio, claude-cli}, the vision layer swaps the single-shot call for a tool-use loop (searchCatalog / getExerciseDetails / expandAbbreviations / terminal proposeWorkout). Default off. Response shape stays byte-compatible. Full details: docs/architecture.md § Agent harness.
app/
dashboard/— main dashboard page (muscle coverage, calorie summary, body card, race timeline, weekly agenda)upload/,review/— Hevy sync flow (review absorbed the old/sync)food/— food log page (tabbed input, review, today/week summary)_providers/—workout-provider(in-flight upload→review state),hevy-provider(persistent reads + commit),food-log-provider(today/week/target/quickAdd + mutators),measurements-provider,food-locale.ts_components/— shared shell pieces (top nav, footer, viewport guard, etc.)api/process-workout/,api/hevy-sync/,api/hevy-user/,api/hevy-workouts/— Hevy routesapi/food/{search,analyze/{text,photo,barcode,barcode-photo},log,quick-add,targets}/— food routes
lib/
vision/—extractWorkoutentry point +normalize.ts(HEIC/EXIF) +single-shot.ts(Groq / LM Studio) +errors.tshevy/— Hevy API client, catalog (lib/data/hevy-exercises/catalog.json), fuzzy matcher.fuzzy-match.tsis exercise-name-specific (knows abbreviations, equipment ordering) — not a generic string utility.groq/— Groq client helpers + extraction prompts (used bylib/vision/single-shot.ts)embeddings/— pluggable provider system (LM Studio / Transformers.js). Server-only — lazy-imported bylib/hevy/exercises.tsso client bundles stay clean. Pre-computed catalogs inlib/data/exercise-embeddings/.agents/— tool-use harness (feature-flagged).tools.tsis the single source of truth for tool defs;match-loop.tsdrives the iterative path;providers/{claude-cli,groq,lm-studio}.tsare adapters.food/—schema.ts(Drizzle),db.ts(server-only pool),fma.ts(FMA fetch wrapper),queries.ts(typed read/write),targets.ts(resolve active target),types.ts,photo-prep.ts(HEIC convert + EXIF for food photos).dashboard/—muscle-coverage.ts(pure compute, used byhevy-provider),muscle-svg-loader.ts,mock-data.ts,config.ts.body/—measurements.ts(Hevy body-measurements payload shape, 1:1 with Hevy's API).workout-set-builder.ts— shared switch overExercise.type→WorkoutSet. Used by bothlib/groq/helpers.ts(single-shot path) andlib/agents/tools.ts(agent path). Don't reimplement set-shape conversion.data/— static data (Hevy catalog snapshot, embedding catalogs).mock-data.ts— mixed: fixtures + live workout helpers (calculateWorkoutMetrics,formatVolume). Known smell, split planned.types.ts,utils.ts,image-utils.ts,exercise-abbreviations.ts,upload-utils.ts— shared utilities.
drizzle/ — generated migration SQL + meta/. Run npm run db:generate after schema changes.
3D body visualisation is not in repo. Future candidate model: https://github.com/datar-psa/clad-body
Threshold ≥60. Levenshtein base (0-100) + word overlap (+10 per match) + same starting word (+20) + equipment match (+15) + official bonus (+5). Max 150. Vector mode (cosine) blends with fuzzy via env vars MATCHING_MODE (fuzzy|vector|both) and EMBEDDING_SOURCE (lm-studio|transformers|auto|off). Abbreviation expansion: BB→barbell, DB→dumbbell, KB→kettlebell, EZ→ez bar, SZ→sz bar, Swiss→swiss bar, Trap→trap bar. Equipment word reordered to end so "DB Curl" matches "Bicep Curl (Dumbbell)".
Unit tests colocated (lib/foo.test.ts next to lib/foo.ts). E2E in tests/e2e/. Ad-hoc scripts in scripts/. Agent harness tests in lib/agents/__tests__/.
workout-provider.tsx— in-flight upload → review state (image, parsed exercises, sync prefs). Page-local.hevy-provider.tsx— persistent reads (last 14 days) + computed muscle coverage +commitWorkout(). Symmetric to food provider.food-log-provider.tsx— today + week + active target + quickAdd + mutators (addMeal,deleteMeal,editGrams).measurements-provider.tsx— Hevy body-measurement state.
All server-side only — API keys never reach client.
- Hevy:
app/api/process-workout/,app/api/hevy-sync/,app/api/hevy-workouts/(used byhevy-providerfor last-14d + dup detection),app/api/hevy-user/. - Food:
app/api/food/search/,app/api/food/analyze/{text,photo,barcode,barcode-photo}/,app/api/food/log/(GET today, POST commit, DELETE batch;[itemId]/PATCH grams;week/aggregates),app/api/food/quick-add/,app/api/food/targets/.
- Git workflow. Solo side project, self-hosted locally. Direct commit + push to
mainis fine — no need to branch or open a PR unless explicitly asked. - No emoji in logs. Keep
console.log/warn/error(and any script output) plain ASCII — no 📅✅❌🔄 etc. Use word tags (PASS/FAIL/WARNING) when a status marker is needed. Typographic glyphs like→and box-drawing dividers are fine.
- Next.js client/server split.
lib/hevy/exercises.tslazy-imports embedding code (@huggingface/transformers,fs). Don't break this — adding a top-level import will pull Node-only deps into client bundle and break build. Same rule applies tolib/food/db.ts(Postgres pool) — must stayimport "server-only". - Mock fallback detection uses string match:
exercises[0].title === "Push Press". Fragile. If you change mock fixture exercises, update the check (or replace with explicit flag). - Path alias
@/*maps to repo root (seetsconfig.json). Use@/lib/...from app/components,../lib/...from scripts/tests. - Tailwind v4 uses CSS variables under
@themedirective inapp/globals.css. shadcn config (components.json) targets New York style. Gotcha:@themetree-shakes any--color-*var it can't see used in scanned source — a token referenced ONLY viavar(--x)in a JS inline style (the dashboard's pattern) gets pruned and resolves to empty. Put such tokens in a plain:root {}block instead (e.g.--color-discipline-walk). - Jest picks up
.next/standalone/lib/*.test.tsbuild artifacts — duplicates of real tests. AddtestPathIgnorePatterns: ['/node_modules/', '/.next/']tojest.config.jsif cleaning up. - Image limits: ≤20MB, ≤33 megapixels (Groq), base64 request ≤4MB.
- HEIC/HEIF: accepted on upload, converted server-side via
heic-convertbefore forwarding to Groq or FMA. EXIF runs on the original buffer (exifr supports HEIC). Workout flow returns the converted JPEG asconvertedImageBase64; client swaps it into context so the review-page preview renders. Detection uses mime + filename extension + ISO BMFF brand bytes (iOS Safari often reports emptyfile.type). Food path has its own helper atlib/food/photo-prep.ts. - Hevy sync is sequential per exercise (~1.5s each) — UI animates progress.
- E2E fixtures in
tests/fixtures/:workout-revl-1.jpeg(full-e2e),workout-revl-2.heic(heic-e2e). server-only+ tsx: scripts that import server-marked modules (prompts, embeddings, hevy/api, food/db) must run withNODE_OPTIONS=--conditions=react-serverso the package resolves to its empty.js entry instead of the throwing default. Allnpm runscripts already set this — only matters if you invoketsxdirectly.prebuildrefreshes the Hevy catalog when possible.npm run buildtriggersnpm run refresh:hevy, which paginatesGET /v1/exercise_templatesand rewriteslib/data/hevy-exercises/catalog.json. Soft-fails whenHEVY_API_KEYis missing — the script warns and continues with the catalog already committed in the repo. Embeddings rebuild only when the catalog's exerciseId set changes (--check-or-rebuildskips otherwise).- Food timezone. Day-boundary aggregation in
/api/food/log+/api/food/log/weekMUST usedate_trunc('day', logged_at AT TIME ZONE :USER_TZ). UTC group-by breaks day boundaries for AU users. - Food edit-grams. Per-gram rates (
kcal_per_g, etc.) are stored at commit time so edits rescale locally without re-querying FMA. Don't drop those columns. - Macro target overlap. Disallowed by policy — inserting a new period auto-closes the prior by setting
prior.end_date = new.start_date - 1. Always exactly one active target. - Hevy dup detection is a pure date-filter against
useHevy().workouts(last 14d). Don't reintroduce a raw API call inside/review. - Provider roles.
workout-provider= in-flight UI state.hevy-provider= persistent reads + commit.food-log-provider= persistent reads + mutators.agenda-provider= reads/api/agenda(merged week) +sync()(server action). Don't mix sync state intoworkout-provideragain — it was extracted on purpose. - Dashboard agenda. Merge is the pure
buildAgenda()inlib/dashboard/agenda.ts(unit-tested) — keep it pure (no DB/clock/network;now+tzinjected). Garmin runs as a Python subprocess (scripts/garmin/fetch.py); its stdout must stay clean JSON (logging + login prints forced to stderr — same rule as the agent CLI shims) and the script dir must be COPYed into the Docker runner (Next standalone output excludes it). Manual sync = same-origin server action (app/_actions/agenda.ts, no secret); cron =POST /api/agenda/syncwithx-sync-secret. Full design:docs/agenda-integration.md. - Agent harness — tool schemas defined ONCE. Edit
lib/agents/tools.tsAGENT_TOOLS. Adapters consume per-provider shape viatoOpenAITools()/toAnthropicTools()/toCliBashAllowlist(). Don't duplicate schema in adapters. If you add a tool, also add a CLI shim inscripts/agent-tools/<name>.ts(one-linerunShim("toolName")) and extend the--allowedToolslist inlib/agents/providers/claude-cli.ts. - Agent harness — CLI shim stdout MUST stay clean JSON. Each shim imports
./_silencefirst to redirectconsole.log(catalog boot prints) to stderr. Don't addconsole.logto anything in the shim's import path or you'll break the parent's JSON parse. - Agent harness — Vercel deploy +
claude-cli.claudebinary isn't in serverless runtimes.claude-cliprovider is local-dev / self-hosted only. Production deploys should usegroqor stayoff. - Agent harness — local model tool calling varies. Some LM Studio models (e.g.
nvidia/nemotron-3-nano-omni) emit XML-style tool calls insidereasoning_contentinstead of populatingtool_calls.lib/agents/providers/openai-shape.tsparses both. If a new model fails withfinishReason: stop, toolCalls: 0, check whether output is going toreasoning_contentand extend the parser, or pin a different model viaLM_STUDIO_AGENT_MODEL. - Agent harness — Groq's strict tool-arg validator. Groq rejects requests where the model's tool args have keys not in the schema (
additionalProperties: false). The set-object schema is intentionallyadditionalProperties: truebecause Llama models sometimes copytypeorweight_repsonto each set; our handler only reads known fields so extras are silently ignored. Don't tighten this.
- New API route: drop in
app/api/<name>/route.ts. Server-only secrets read viaprocess.env. - New shadcn component:
npx shadcn@latest add <name>— installs tocomponents/ui/. - New lib module: pick a domain (
vision/,hevy/,groq/,embeddings/,agents/,food/,dashboard/,body/) or place atlib/root if cross-cutting. Don't recreate sibling utils already inlib/utils.tsorlib/agents/env.ts. - Touching matching: regenerate embeddings (
npm run build:embeddings:both) if exercise catalog JSON files change. Runnpm run debug:match -- "<input>"to inspect score components. - New agent provider: implement
IterativeAgentProvider(orSelfHostedAgentProvider) fromlib/agents/types.ts; add acaseinlib/agents/index.ts:buildProvider; extendAgentProviderNameunion +getAgentHarnessProvider()validation. OpenAI-compat providers reusecreateOpenAIShapeSession()— pass baseUrl/model/auth. - New agent tool: add to
AGENT_TOOLSinlib/agents/tools.ts(handler + JSON Schema). Add CLI shim atscripts/agent-tools/<name>.ts. Extend--allowedToolslist inclaude-cli.ts. Mention the tool inAGENT_SYSTEM_PROMPT+AGENT_SYSTEM_PROMPT_CLIworkflow contract. - New food schema field: edit
lib/food/schema.ts, runnpm run db:generate, commit the generated SQL underdrizzle/, thennpm run db:migratelocally. - New dashboard widget: add under
components/dashboard/, wire to whichever provider owns the data. Don't add new RSC fetches atapp/page.tsx— the alignment pass made it a sync shell that reads from providers.