Files
food-tracer/CLAUDE.md
T
Ondřej Anděl 8dac67bdd1 feat: import jen některých dnů
feat: násobení jídel/cviků
feat: refaktor napojování šablon
2026-09-30 19:10:54 +02:00

282 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
```
### Running dev environment
```bash
./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
```bash
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 in
`types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/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.ts` factory 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.tsx` je 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) a `components/modals/`
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s 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 v `AppRoutes.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 v `App.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ě je
`meals:<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í (`formatPrice` v `Utils.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 (`pricePer100g` v haléřích za 100 g,
`caloriesPer100g` v kcal) umožňují dopočet — viz níže.
## Gramáž a kalorie
`deriveAmounts` v `server/src/meals.ts` je jediný zdroj pravdy pro dopočty:
1. **gramáž** = `price / pricePer100g * 100`, pokud není zadaná ručně
2. **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`)
a `deriveRecipe` (`server/src/mealTemplates.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é `calories` jsou vždy za jednu porci** a výsledek je
`applyMultiplier(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řes `applyMultiplier`, 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.pl` i `/api/v2/search` vrací 503.
Produkty bez `energy-kcal_100g` se 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. 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.
**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 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**. 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. Při zakládání jídla v přehledu dne se
šablony nabízejí k rozepsanému názvu (`TemplateSuggestions`).
Šablona má druh (`kind`):
- **HOTOVE** — objednané nebo koupené jídlo. Nese jen výsledné hodnoty porce:
buď `calories`, nebo `caloriesPer100g` + `weight`; obojí projde `deriveAmounts`.
- **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 stejných plochých polí (`weight`, `caloriesPer100g`, `calories`, `price`),
takže použití šablony je pro oba druhy stejné. 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.
Šablony uložené dřív, než druhy přibyly, nemají `kind`; `withKind` jim ho při
načtení doplní na `HOTOVE`, v úložišti se nic nepřepisuje.
`createMealTemplateFromMeal` recept stejného názvu **nepřepíše** (přišel by
o suroviny a postup) a odmítne to chybou.
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č `;`, datum `DD.MM.YYYY` a desetinnou čárku (český Excel).
- XLSX má dva listy — čte se `Přehled`, list `Souhrn` se 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 do `note`.
- `source` = obchod objednávky, jinak podnik. Stavy volby z `NON_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ší `importKey` slož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 `dryRun` pro 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
jen `selectedKeys`.
**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