feat: předělání historie a profilu

This commit is contained in:
Ondřej Anděl committed 2026-10-07 14:48:21 +02:00
1 parent 9e72f431a1
commit 8fa7f601f2
97 files changed
+7502 -1034

No files matched your search

+85 -37
View File
@@ -59,8 +59,10 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
- **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),
- **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
@@ -71,10 +73,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### 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),
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
`StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`)
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
- **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`, `StatsRange`)
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
@@ -95,6 +99,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
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
@@ -111,8 +121,9 @@ 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`).
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
@@ -170,10 +181,9 @@ trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série
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.
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
@@ -198,13 +208,29 @@ 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
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
@@ -226,37 +252,54 @@ přesunutý z jiného dne) se počítají jako splněné přesně dle plánu.
`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
## Historie jídel a recepty
`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`).
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ždý název vrátí
jednou (`MealSuggestion`). Názvy se porovnávají znormalizované přes
`normalizeName`, hodnoty jsou z **posledního** záznamu toho jména (cena i gramáž
se časem mění) a řadí se od nejčastěji jedených.
Šablona má druh (`kind`):
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.
- **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.
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, která je nemají (`fillSameNameMeals`,
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
Š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.
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ý.
Jídlo založené ze šablony nese `templateId`. Nastavuje ho jen server
`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é — `MealTemplate`
ho vyžaduje a nešlo by jím posílat novou šablonu.
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné a hodnoty porce
chybí — dopočítá je server.
## Import z Luncheru
@@ -275,6 +318,11 @@ ve všech třech formátech, které Luncher exportuje (XLSX, CSV, JSON — viz
- `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;