402 lines
24 KiB
Markdown
402 lines
24 KiB
Markdown
# 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 a historie jídel), `mealGroups.ts` (seznam jídel —
|
||
skupiny podle názvu a zdroje, doplnění a přejmenování), `mealPhotos.ts` (fotky
|
||
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.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),
|
||
`MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
|
||
hromadným doplněním údajů a přejmenováním),
|
||
`WorkoutsPage` (`/treninky` — šablony tréninků), `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; panely
|
||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||
výběr z historie a receptů, SettingsModal, ImportModal)
|
||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s 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 v `AppRoutes.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 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.
|
||
- 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 (`getPortionNutrients` v `nutrients.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:
|
||
|
||
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`),
|
||
`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é `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. 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í). Recept a fotky jdou s jídlem; dvě
|
||
jídla s recepty sloučit nejde.
|
||
|
||
**Recept a fotky jsou rozšířením jídla v seznamu**, ne samostatnou evidencí —
|
||
patří dvojici název a zdroj, ne dni. V přehledu dne se nezobrazují.
|
||
|
||
- Recept se k jídlu váže **shodou názvu a zdroje** (`getRecipeKey`), jedno jídlo
|
||
má nejvýš jeden (`saveMealTemplate` druhý odmítne). Recept jídla, které se
|
||
ještě nejedlo, je v seznamu jako skupina s `count: 0` a bez `lastDate`.
|
||
Má-li jídlo recept, doplnění údajů předvyplní gramáž porce a energii z něj.
|
||
- Fotky (`mealPhotos.ts`): klient je před nahráním zmenší (`imageResize.ts`,
|
||
~1600 px JPEG) a pošle i náhled (240 px). Rejstřík s náhledy je jeden pro
|
||
uživatele (`mealPhotos:<login>`, klíč jídla → fotky), aby ho seznam jídel
|
||
načetl jedním čtením; plné obrázky leží každý zvlášť (`mealPhoto:<login>:<id>`)
|
||
a načtou se, až když se fotky jídla otevřou.
|
||
|
||
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`. **Chod nemá** — patří ke dni,
|
||
ne k jídlu; použije se zvolený při použití, jinak oběd (starší recepty ho v úložišti
|
||
mít můžou, `toMealTemplate` ho zahodí). `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č `;`, 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.
|
||
- 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ší `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`.
|
||
|
||
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
|