feat: stránka doplnit údaje předělána na samostatnou stránku jídel + přidáno přejmenování
23 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 a historie jídel),nutrients.ts(živiny),mealTemplates.ts(recepty),activities.ts(pohyb),workouts.ts(šablony tréninků),sleep.ts(spánek),weights.ts(vážení po dnech),profile.ts(tělesné údaje a výpočet BMR),bodyMetrics.ts(BMI, podíl tuku, cíle),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),MealGroupsPage(/jidla— seznam jídel podle názvu a zdroje s filtrem zdroje, hromadným doplněním údajů a přejmenováním),TemplatesPage(šablony tréninků a recepty),ProfilePage(Profil — ukazatele, vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),StatsPage(statistiky se záložkami Příjem / Výdej / Útrata a import; panelyIntakeStatsPanel,ActivityStatsPanel,SpendingStatsPanel) - Components:
components/(Header, CalorieLookup, MealSuggestions, NutrientsFieldset) acomponents/modals/(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal — výběr z historie a receptů, SettingsModal, ImportModal) - Klientské enumy patří do
enums.tsjako TypeScriptenums mapou popisků, ne jako inline union u komponenty (DayTab,StatsTab,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é. Přehled dne čte datum z parametru?datum=YYYY-MM-DD(getDayUrl), na konkrétní den se tak dá odkázat - 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. - Nutriční hodnoty (
nutrientsPer100g: bílkoviny, sacharidy, z toho cukry, tuky, z toho nasycené, vláknina, sůl) jsou volitelné, v gramech na 100 g jako na etiketě. Jako jediné nejsou celočíselné — ukládají se na desetiny gramu, u soli a vlákniny by celé gramy smazaly celou hodnotu. Hodnoty porce se dopočtou z gramáže a násobku (getPortionNutrientsvnutrients.ts), takže jídlo bez gramáže do součtu dne (DayRecord.totalNutrients) nevstupuje.
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ží. Totéž platí pro applyMultiplier (server/src/utils.ts),
deriveRecipe (server/src/mealTemplates.ts) a getPortionNutrients
(server/src/nutrients.ts).
Násobek položky dne
Jídlo i aktivita mají volitelný multiplier (i necelý, 0,01–100): dvě sušenky
jsou ×2, nedojedená polovina ×0,5. Mění se přímo z přehledu dne
(/meals/multiplier, /activities/multiplier), bez otevření formuláře.
- Uložené
caloriesjsou vždy za jednu porci a výsledek jeapplyMultiplier(calories, multiplier). Kdyby se ukládal už vynásobený výsledek, opakovaná změna násobku by kumulovala zaokrouhlení. - Všechny součty (
buildDayRecord,buildActivityDay,statsService) proto musí jít přesapplyMultiplier, ne přes holécalories. - Cena se nenásobí — za nedojedené jídlo se platí celé. Dvě kusy se stejnou cenou jsou dva záznamy, nebo jedna cena zadaná za oba.
- Násobek 1 je výchozí stav a neukládá se (stejně jako jedna série).
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, SEKUNDY, 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. Záznamy ve dni
zůstanou beze změny. U jídla nic takového není, opakovaně jedená jídla se nabízejí
z historie (viz Historie jídel a recepty).
Dodatečná zátěž (extraWeightGrams u aktivity i cviku šablony, v gramech)
je jen informace o tréninku, kalorie nemění — žádný jednoduchý vzorec
spotřebu podle zátěže nedává, takže by šlo o vymyšlené číslo. Kdo chce, promítne
ji do sazby na 100 jednotek.
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: 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).
getDayOverview ho počítá s hmotností platnou k danému dni (poslední vážení
v ten den nebo dřív), takže starší dny se se zhubnutím zpětně nepřepočítají;
settings.computedBasalCalories je hodnota z posledního vážení. 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.
Vážení (weights.ts, klíč weights:<login> — mapa den → gramy) se zapisuje
v přehledu dne nebo na stránce Profil. Hmotnost v profilu (profile.weightGrams)
je pozůstatek z doby před vážením po dnech a použije se, jen dokud uživatel
žádné vážení nemá; klient ji při uložení profilu posílá zpět beze změny.
Ukazatele (bodyMetrics.ts, UserSettings.bodyMetrics) počítá server, nic
z nich se neukládá: BMI s pásmem podle WHO, zdravé rozmezí hmotnosti (BMI
18,5–25), podíl tuku metodou US Navy z obvodů (muž pas a krk, žena navíc boky)
a z cílů (Goals) cílová denní bilance a bílkoviny. Tempo počítá s 7 700 kcal
na kilogram — −0,5 kg týdně je −550 kcal denně. Cílová bilance
(EnergyBalance.targetBalance) i cíl bílkovin (DayOverview.proteinTargetGrams)
jsou jen informace pro uživatele, do bilance nevstupují. Popisky tempa ve výběru
na stránce Profil počítají kcal/den se stejnou konstantou.
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.
Statistiky výdeje (getActivityStats ve statsService.ts, GET /api/stats/activity)
dělí pohyb na aktivity ze šablon a mimo ně a u šablon porovnává skutečnost s plánem.
Plán si applyWorkoutTemplate ukládá při použití (workoutPlans.ts, klíč
workoutPlans:<login>:<datum>) — nesrovnává se se současnou podobou šablony,
ta se mohla mezitím změnit, a smazaná aktivita by beze stopy zmizela. Plán nese
jednotlivé cviky s entryId aktivity, která z nich vznikla; podle něj
analyzeDay u každého dne pozná cvik odebraný (aktivita chybí) a upravený
(jiné množství, série nebo násobek) — podle názvu by to nešlo, šablona může mít
stejný cvik dvakrát. Smazání všech cviků šablony ve dni dropOrphanedPlans
bere jako vrácené použití a plán zahodí. Aktivity ze šablony bez plánu (cvik
přesunutý z jiného dne) se počítají jako splněné přesně dle plánu.
Statistiky příjmu (getIntakeStats, GET /api/stats/intake) jsou protějšek
výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej
a bilance se počítají jen za dny, kdy má aspoň jedno jídlo kalorie
(caloriesDayCount) — den bez zápisu nebo s jídly z importu bez kalorií by jinak
vypadal jako deficit celého výdeje. Ze stejného důvodu z nich vycházejí průměry.
Klidový výdej je k hmotnosti platné v daný den (getBasalOn v dayOverview.ts).
Peníze za jídlo jsou na samostatné záložce Útrata (getFoodStats, /api/stats/summary).
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.
Historie jídel a recepty
Hotová jídla (objednaná, koupená) nemají šablony. Při zakládání jídla se
k rozepsanému názvu nabízí historie: getMealHistory v meals.ts
(GET /api/meals/history) projde všechna jídla uživatele a každou dvojici
název a zdroj vrátí jednou (MealSuggestion). Stejnojmenné jídlo z jiného
zdroje (tikka masala z Everestu a z Namaste) je jiné jídlo a nikde se s ním
neslučuje — ani v návrzích, ani v seznamu jídel, ani při fillSameNameMeals;
na klientovi to hlídá getMealKey v Utils.tsx. Porovnává se znormalizovaně
přes normalizeName, hodnoty jsou z posledního záznamu (cena i gramáž
se časem mění) a řadí se od nejčastěji jedených.
Návrh nese jen to, co popisuje jídlo samo — chod, datum, násobek ani poznámku
ne, ty patří ke konkrétnímu dni. Gramáž dopočtená z ceny se vynechá, aby se
v novém záznamu dopočetla z jeho ceny; ruční kalorie jen tam, kde nejdou
dopočítat z gramáže. Jinak by se z dopočtené hodnoty stala ručně zadaná.
Když poslední záznam kalorie nemá (typicky import), doplní se do návrhu
z dřívějšího přes createNutritionFiller, stejně jako při importu.
Návrhy se nabízejí i při úpravě jídla, ale až po změně názvu, a doplní jen
prázdná pole (mergeSuggestion v MealModal) — slouží k přejmenování
naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla
s kaloriemi DayPage nabídne (FillSameNameOffer) doplnit stejné hodnoty do
ostatních jídel stejného názvu a zdroje, která je nemají (fillSameNameMeals,
POST /api/meals/fillSameName); jídla s kaloriemi se nepřepisují.
Seznam jídel (MealGroupsPage, položka Jídla v hlavičce) seskupí
jídla podle znormalizovaného názvu a zdroje (getMealGroups,
GET /api/meals/groups) a ukáže, kolika chybí kalorie, gramáž a živiny.
Skupina nese i dny, kdy se jídlo jedlo (MealGroup.days, s příznakem neúplných
údajů), a stránka je nabízí jako odkazy na přehled dne v nové záložce.
fillMealGroup (POST /api/meals/fillGroup) doplní do skupiny jen chybějící
hodnoty: gramáž ne tam, kde jde dopočítat z vlastní ceny a sazby, energii jen
jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živiny po
jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny;
energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.
Druhou akcí skupiny je přejmenování (renameMealGroup,
POST /api/meals/renameGroup) — změní název i zdroj všem jídlům skupiny ve
všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí,
přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do
ní sloučí (formulář na to předem upozorní).
Pole Zdroj (SourceInput) nabízí zdroje z historie (getMealSources,
GET /api/meals/sources, nejčastější první) a podniky se sazbou z nastavení;
psát jde i nový.
MealTemplate (mealTemplates.ts, klíč mealTemplates:<login>) je recept —
vařené jídlo se surovinami (ingredients), počtem porcí (servings), hmotností
hotového jídla (cookedWeight) a postupem v note. deriveRecipe z nich při
uložení spočítá hodnoty jedné porce a uloží je do plochých polí (weight,
caloriesPer100g, calories, price); použitím vznikne běžný MealEntry přes
addMeal. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu
surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje.
Recepty se nabízejí v našeptávání i ve výběru z historie (ApplyMealModal)
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
Dřív pod stejným klíčem ležely i šablony hotových jídel (kind: HOTOVE, nebo
bez kind). Ty zanikly: loadRecipes je při čtení vynechá (poznají se podle
toho, že nemají suroviny) a první zápis receptu je z úložiště odklidí. Jídla
z nich založená ve dnech zůstávají, dál nesou templateId zaniklé šablony.
Jídlo založené z receptu nese templateId. Nastavuje ho jen server
(addMeal s třetím parametrem) — MealInput ho nemá, takže ho klient
nepodstrčí a úprava jídla ho zachová.
Kontrakt má vlastní MealTemplateInput, kde je id volitelné a hodnoty porce
chybí — dopočítá je server.
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.- Luncher kalorie nezná, takže je
createNutritionFiller(meals.ts) doplní z historie: vzorem je nejnovější jídlo stejného názvu, které energii nebo živiny má, přednostně ze stejného zdroje. Cena zůstává z importu. Sazba za 100 g se přebírá jen ze stejného zdroje (gramáž se pak dopočte z nové ceny), jinak se vezme gramáž vzoru. - 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. - Režim (
ImportMode): PRIDAT (výchozí) přeskočí už naimportované řádky; NAHRADIT v dnech, do kterých se něco zakládá, odebere dřívější záznamy z importu a založí je znovu. Ručně zadaná jídla zůstávají v obou režimech. - Endpoint podporuje
dryRunpro náhled před uložením; klient ho vždy použije. Náhled vrací všechny řádky, které založit jde, i jídla, která už v dotčených dnech jsou (existing). Uživatel v něm řádky odškrtá a ostrý import dostane jenselectedKeys.
Jídlo, které Luncher zapsal jako jedno ("Chicken tikka massala+garlic nun"),
jde v přehledu dne rozdělit (MealSplitModal, splitMeal, POST /api/meals/split)
— import se kvůli tomu nekomplikuje. Původní záznam si nechá importKey, takže
ho režim PRIDAT znovu nezaloží; odštěpená část dostane jen importSource, aby ji
režim NAHRADIT odebral spolu s původním a nevzniklo jídlo dvakrát. Cena se mezi
části nedopočítává, rozdělí se jen na vyžádání (napůl).
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