13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Food Tracer je aplikace pro evidenci snědených jídel a útraty za jídlo. Czech-language UI.
Full-stack TypeScript monorepo. Technologicky i vzhledově navazuje na sesterský projekt
Luncher (../Luncher) — sdílí s ním stack, konvence i design systém.
Monorepo Structure
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
Package manager: Yarn Classic. Deployment: Dockerfile, compose.yml.
Development Commands
Initial setup
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
Running dev environment
./run_dev.sh # tmux (Linux/macOS)
.\run_dev.ps1 # dvě PowerShell okna (Windows)
# Nebo ručně:
cd server && NODE_ENV=development yarn startReload # Port 3001
cd client && yarn start # Port 3000, proxies /api → 3001
Building & tests
cd types && yarn openapi-ts # Regenerate types from api.yml
cd server && yarn build # tsc → server/dist
cd server && yarn test # Jest (in-memory storage)
cd server && yarn test meals # Run one file by name
cd client && yarn build # tsc --noEmit + vite build → client/dist
Architecture
API Types (types/)
- OpenAPI 3.0 spec in
types/api.yml— thin aggregator, endpoint specs intypes/paths/<domain>/*.yml, shared schemas intypes/schemas/_index.yml yarn openapi-tsgeneratestypes/gen/(client.gen.ts, sdk.gen.ts, types.gen.ts)- Both server and client import from these generated types
- When changing API contracts: update api.yml first, then regenerate
Server (server/src/)
- Entry:
index.ts— Express app, auth middleware, error middleware - Routes:
routes/—dayRoutes,mealRoutes,activityRoutes,workoutRoutes,statsRoutes,calorieRoutes,settingsRoutes,importRoutes - Domain:
meals.ts(jídlo),activities.ts(pohyb),workouts.ts(šablony tréninků),sleep.ts(spánek),profile.ts(tělesné údaje a výpočet BMR),dayOverview.ts(spojení dne a bilance),statsService.ts(agregace),luncherImport.ts(parsování exportů z Luncheru),settings.ts,calories.ts - Auth:
auth.ts— JWT + volitelná autentizace z hlavičky reverzní proxy - Storage:
storage/index.tsfactory dle proměnnéSTORAGE; backendy:json.ts(soubor, vývoj),redis.ts(produkce),memory.ts(testy) - Config:
.env.development/.env.production(viz.env.template)
Client (client/src/)
- Entry:
index.tsx→AppRoutes.tsx;Login.tsxje přihlašovací obrazovka - Pages:
pages/—DayPage(přehled dne se záložkami Příjem/Výdej),TemplatesPage(šablony tréninků),ProfilePage(tělesné údaje a klidový výdej),StatsPage(statistiky + import) - Components:
components/(Header, CalorieLookup) acomponents/modals/(MealModal, ActivityModal, ApplyWorkoutModal, SettingsModal, ImportModal) - Klientské enumy patří do
enums.tsjako TypeScriptenums mapou popisků, ne jako inline union u komponenty (DayTab,StatsRange) - Context:
context/auth.tsx(JWT),context/settings.tsx(světlý/tmavý motiv) - Routing: konstanty adres jsou v
routes.ts, ne vAppRoutes.tsx— hlavička je potřebuje a kruhový import by je nechal nedefinované - Styling: Bootstrap 5 + React Bootstrap + SCSS; design systém a proměnné
--ft-*jsou vApp.scss, stránkové styly co-located vedle komponent - API: přes OpenAPI SDK z
types/gen/
Data model
- Jídlo (
MealEntry) patří jednomu dni a jednomu uživateli; klíč úložiště jemeals:<login>:<YYYY-MM-DD>a hodnotou je pole záznamů dne. - Ceny jsou všude celá čísla v haléřích — v úložišti, v API i mezi klientem
a serverem. Na koruny se převádí až při zobrazení (
formatPricevUtils.tsx). Stejnou konvenci má Luncher, díky tomu se částky z importu přenesou beze ztráty. - Kalorie jsou volitelné celé číslo v kcal. Jídla bez kalorií se do součtů nezapočítávají.
- Gramáž (
weight, g) a jednotkové hodnoty (pricePer100gv haléřích za 100 g,caloriesPer100gv kcal) umožňují dopočet — viz níže.
Gramáž a kalorie
deriveAmounts v server/src/meals.ts je jediný zdroj pravdy pro dopočty:
- gramáž =
price / pricePer100g * 100, pokud není zadaná ručně - kalorie =
weight / 100 * caloriesPer100g, jinak ručně zadaná hodnota
Ručně zadaná gramáž má vždy přednost — porci lze zvážit přesněji, než kolik řekne
cena. Sazba je na každém záznamu zvlášť, takže hlavní jídlo za 44 Kč/100 g a salát
s jiným cenováním můžou být ve stejném dni vedle sebe. Výchozí sazby podniků drží
server/src/settings.ts (sourceRates) a klient jimi předvyplňuje pole, když
uživatel vybere zdroj.
client/src/Utils.tsx má stejnojmennou funkci pro živý náhled ve formuláři.
Když se změní jedna, musí se změnit i druhá — jinak klient ukazuje něco jiného,
než server uloží.
Zdroje energetických hodnot
server/src/calorieProvider.ts definuje rozhraní CalorieProvider a jeho
implementaci nad Open Food Facts. Poskytovatel je záměrně vyměnitelný za jeden
soubor a nikdy nevyhazuje výjimku — při nedostupnosti vrací unavailable,
takže hledání jen přijde o návrhy a nespadne. CALORIE_PROVIDER=none externí
dotazy vypne (offline provoz, testy).
Poznámky ke zdrojům, ověřené v září 2026:
- KalorickéTabulky.cz nemají veřejné API. Jejich robots.txt zakazuje
/*query.page(jejich vyhledávání) a smluvní podmínky omezují užití nad rámec zamýšleného účelu, takže se odtud nesmí scrapovat. Aplikace jen nabídne odkaz na jejich tabulku potravin a zkopíruje název jídla do schránky. Předvyplnit jejich hledání z URL nejde — běží v JavaScriptu a parametry v query stringu ignoruje (ověřeno porovnáním odpovědí). - Open Food Facts: používá se
search.openfoodfacts.org, ne hlavníworld.openfoodfacts.org— tamní/cgi/search.pli/api/v2/searchvrací 503. Produkty bezenergy-kcal_100gse odfiltrují. - Vlastní knihovna (
server/src/calories.ts) si pamatuje kcal/100 g pod znormalizovaným názvem jídla a řadí se před externí návrhy — na kantýnová jídla sedí líp než databáze balených potravin. Plní se sama při uložení jídla.
Pohyb a energetická bilance
Den má dvě strany a DayPage je dělí do záložek:
- Příjem — jídla (
MealEntry), klíčmeals:<login>:<datum> - Výdej — pohyb (
ActivityEntry), klíčactivities:<login>:<datum>
Aktivita se měří v jednotce (ActivityUnit: KROKY, MINUTY, KM, OPAKOVANI) a
kalorie se dopočtou jako getTotalQuantity(quantity, sets) / 100 * caloriesPer100Units.
Sto jednotek, ne jedna — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal)
a aplikace všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g.
quantity je množství na jednu sérii, sets je počet sérií; celkové množství
je jejich součin. Drží se odděleně schválně: 3 × 40 opakování s pauzami je jiný
trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série je
výchozí stav a neukládá se.
Opačný směr k použití šablony je createWorkoutTemplateFromActivities —
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. U jídla to
umí createMealTemplateFromMeal, které navíc přepíše šablonu stejného názvu;
opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik
stejných. Záznamy ve dni zůstanou v obou případech beze změny.
WorkoutTemplate (workouts.ts) je pojmenovaný seznam položek. Položka je buď
cvik (kind: CVIK), nebo odkaz na jinou šablonu (kind: SABLONA), jejíž cviky se
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou templateId — jsou
samostatné, takže úprava založené položky šablonu nemění.
Zanoření je povolené jen na jednu úroveň a checkNesting to hlídá z obou stran:
šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít
skládat, pokud ji už někdo používá. Bez druhé kontroly by druhá úroveň vznikla
oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — zůstal by odkaz
do prázdna. Šablony uložené dřív, než skládání přibylo, nemají kind; withKind
jim ho při načtení doplní na CVIK.
buildEnergyBalance v dayOverview.ts počítá:
bilance = příjem − (klidový výdej + pohyb). Záporná hodnota je deficit.
Bez nastaveného klidového výdeje porovnává bilance jen jídlo proti pohybu —
to není skutečný deficit, proto se to přes hasBasal: false propisuje do UI,
aby to číslo nikoho nemátlo.
Klidový výdej se bere z settings.effectiveBasalCalories: ručně zadaný
basalCalories má přednost před hodnotou spočítanou z tělesných údajů
(computeBasalCalories v profile.ts, rovnice Mifflin–St Jeor). Výpočet
záměrně nenásobí koeficientem fyzické aktivity (PAL), jak to dělají kalkulačky
TDEE — pohyb se eviduje zvlášť a přičítá se, takže vynásobení by ho započítalo
dvakrát.
Spánek (sleep.ts, klíč sleep:<login>:<datum>) se eviduje jako kontext dne
a do bilance nevstupuje. Klidový výdej je hodnota za celých 24 hodin včetně
spánku a žádná ze standardních rovnic délku spánku jako proměnnou nemá — přičítat
nebo odečítat za něj kalorie by bylo vymýšlení čísla. Test to hlídá:
bilance se po zadání spánku nesmí změnit.
GET /api/day?date= vrací DayOverview se vším naráz (jídlo, pohyb, bilance),
takže DayPage si vystačí s jedním voláním.
Šablony jídel
MealTemplate (mealTemplates.ts, klíč mealTemplates:<login>) je opakovaně
jedené jídlo. Na rozdíl od šablon tréninků se neskládá z jiných — je to plochý
záznam. Použitím vznikne běžný MealEntry přes addMeal, takže gramáž, cenu
i kalorie dopočítá stejná logika jako u ručně zadaného jídla.
Šablona může nést buď calories (celá porce), nebo caloriesPer100g + weight.
Obojí projde deriveAmounts, takže stačí vyplnit, co o jídle víte.
Pole note slouží i na postup přípravy u domácích jídel. Strukturované suroviny
(recept jako seznam položek s množstvím) mohou přibýt později jako další volitelné
pole, aniž by se muselo měnit cokoli stávajícího.
Kontrakt má vlastní MealTemplateInput, kde je id volitelné — MealTemplate
ho vyžaduje a nešlo by jím posílat novou šablonu.
Import z Luncheru
server/src/luncherImport.ts čte měsíční přehled ze stránky statistik Luncheru
ve všech třech formátech, které Luncher exportuje (XLSX, CSV, JSON — viz
../Luncher/server/src/userExport.ts).
- Sloupce se hledají podle názvu hlavičky, ne podle pozice.
- CSV má BOM, oddělovač
;, datumDD.MM.YYYYa desetinnou čárku (český Excel). - XLSX má dva listy — čte se
Přehled, listSouhrnse ignoruje. - Řádky se zakládají jako oběd, pokud se neurčí jinak (Luncher řeší obědy).
name= jídlo → poznámka → typ záznamu. U voleb "Budu objednávat" a "Rozhoduji se" bývá sloupec s jídlem prázdný a co se reálně jedlo stojí v poznámce ("Chefie - Těstovinový salát"), proto ten mezikrok. Poznámka, ze které se stal název, se už nekopíruje donote.source= obchod objednávky, jinak podnik. Stavy volby zNON_PLACE_TYPES("Budu objednávat", "Rozhoduji se", "Objednávka", "Mám vlastní/neobědvám") nejsou místa, takže se jako zdroj nepoužijí a záznam zůstane bez zdroje.- Duplicity řeší
importKeysložený z data, pořadí řádku v rámci dne, typu, jídla a částky — opakovaný import stejného měsíce nic nezduplikuje. - Endpoint podporuje
dryRunpro náhled před uložením; klient ho vždy použije.
Když se změní formát exportu v Luncheru, je potřeba upravit i tento parser
a testy v server/src/tests/luncherImport.test.ts, které si export sestavují
přesně tak, jak ho Luncher generuje.
Conventions
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
- Komentáře v češtině, u netriviálních míst vysvětlují proč, ne co