From 8fa7f601f2548cf2bef4e0298a4ac3d4902b0fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Wed, 7 Oct 2026 14:48:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20p=C5=99ed=C4=9Bl=C3=A1n=C3=AD=20histori?= =?UTF-8?q?e=20a=20profilu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 122 ++-- client/src/App.scss | 45 +- client/src/Utils.tsx | 64 +- client/src/components/BodyMetricsTiles.tsx | 74 +++ client/src/components/FillSameNameOffer.tsx | 55 ++ client/src/components/Header.tsx | 9 +- client/src/components/ImportPreview.tsx | 13 +- client/src/components/MealRow.tsx | 21 +- client/src/components/MealSuggestions.tsx | 70 +++ client/src/components/MealTemplateCard.tsx | 49 +- client/src/components/MealTemplateForm.tsx | 271 ++------ client/src/components/NutrientSummary.tsx | 31 + client/src/components/NutrientsFieldset.tsx | 93 +++ client/src/components/SourceInput.tsx | 50 ++ client/src/components/TemplateSuggestions.tsx | 53 -- client/src/components/WeightHistoryPanel.tsx | 180 ++++++ .../src/components/modals/ApplyMealModal.tsx | 154 +++-- client/src/components/modals/MealModal.tsx | 180 ++++-- client/src/enums.ts | 42 +- client/src/pages/DayPage.scss | 31 + client/src/pages/DayPage.tsx | 106 +++- client/src/pages/MealTemplatesPanel.tsx | 49 +- client/src/pages/ProfilePage.scss | 69 +++ client/src/pages/ProfilePage.tsx | 217 +++++-- client/src/pages/TemplatesPage.tsx | 21 +- run_dev.ps1 | 4 + run_dev.sh | 2 + server/dist/server/src/activities.js | 289 +++++++++ server/dist/server/src/auth.js | 61 ++ server/dist/server/src/calorieProvider.js | 78 +++ server/dist/server/src/calories.js | 128 ++++ server/dist/server/src/dayOverview.js | 58 ++ server/dist/server/src/index.js | 198 ++++++ server/dist/server/src/luncherImport.js | 439 +++++++++++++ server/dist/server/src/mealTemplates.js | 319 ++++++++++ server/dist/server/src/meals.js | 343 ++++++++++ server/dist/server/src/profile.js | 74 +++ .../dist/server/src/routes/activityRoutes.js | 59 ++ .../dist/server/src/routes/calorieRoutes.js | 21 + server/dist/server/src/routes/dayRoutes.js | 37 ++ server/dist/server/src/routes/importRoutes.js | 38 ++ server/dist/server/src/routes/mealRoutes.js | 85 +++ .../server/src/routes/mealTemplateRoutes.js | 65 ++ .../dist/server/src/routes/settingsRoutes.js | 52 ++ server/dist/server/src/routes/statsRoutes.js | 35 ++ .../dist/server/src/routes/workoutRoutes.js | 66 ++ server/dist/server/src/settings.js | 135 ++++ server/dist/server/src/sleep.js | 49 ++ server/dist/server/src/statsService.js | 265 ++++++++ .../server/src/storage/StorageInterface.js | 2 + server/dist/server/src/storage/index.js | 36 ++ server/dist/server/src/storage/json.js | 76 +++ server/dist/server/src/storage/memory.js | 37 ++ server/dist/server/src/storage/redis.js | 77 +++ server/dist/server/src/utils.js | 116 ++++ server/dist/server/src/workoutPlans.js | 48 ++ server/dist/server/src/workouts.js | 300 +++++++++ server/dist/types/gen/client.gen.js | 6 + server/dist/types/gen/index.js | 19 + server/dist/types/gen/sdk.gen.js | 585 ++++++++++++++++++ server/dist/types/gen/types.gen.js | 66 ++ server/dist/types/index.js | 17 + server/dist/types/openapi-ts.config.js | 15 + server/package.json | 5 +- server/src/bodyMetrics.ts | 154 +++++ server/src/dayOverview.ts | 34 +- server/src/luncherImport.ts | 9 +- server/src/mealTemplates.ts | 217 +++---- server/src/meals.ts | 213 ++++++- server/src/nutrients.ts | 83 +++ server/src/profile.ts | 35 +- server/src/routes/dayRoutes.ts | 19 + server/src/routes/mealRoutes.ts | 30 +- server/src/routes/mealTemplateRoutes.ts | 21 +- server/src/routes/settingsRoutes.ts | 10 +- server/src/settings.ts | 40 +- server/src/tests/activityStats.test.ts | 10 +- server/src/tests/bodyMetrics.test.ts | 157 +++++ server/src/tests/luncherImport.test.ts | 81 ++- server/src/tests/mealHistory.test.ts | 233 +++++++ server/src/tests/setsAndMealTemplates.test.ts | 171 +++-- server/src/tests/toTemplate.test.ts | 66 +- server/src/weights.ts | 60 ++ server/yarn.lock | 13 + types/api.yml | 16 +- types/paths/day/weight.yml | 28 + types/paths/day/weights.yml | 14 + types/paths/mealTemplates/apply.yml | 10 +- types/paths/mealTemplates/delete.yml | 4 +- types/paths/mealTemplates/fromMeal.yml | 33 - types/paths/mealTemplates/list.yml | 4 +- types/paths/mealTemplates/save.yml | 4 +- types/paths/meals/fillSameName.yml | 39 ++ types/paths/meals/history.yml | 17 + types/paths/meals/sources.yml | 17 + types/paths/settings/goals.yml | 21 + types/schemas/_index.yml | 299 +++++++-- 97 files changed, 7502 insertions(+), 1034 deletions(-) create mode 100644 client/src/components/BodyMetricsTiles.tsx create mode 100644 client/src/components/FillSameNameOffer.tsx create mode 100644 client/src/components/MealSuggestions.tsx create mode 100644 client/src/components/NutrientSummary.tsx create mode 100644 client/src/components/NutrientsFieldset.tsx create mode 100644 client/src/components/SourceInput.tsx delete mode 100644 client/src/components/TemplateSuggestions.tsx create mode 100644 client/src/components/WeightHistoryPanel.tsx create mode 100644 client/src/pages/ProfilePage.scss create mode 100644 server/dist/server/src/activities.js create mode 100644 server/dist/server/src/auth.js create mode 100644 server/dist/server/src/calorieProvider.js create mode 100644 server/dist/server/src/calories.js create mode 100644 server/dist/server/src/dayOverview.js create mode 100644 server/dist/server/src/index.js create mode 100644 server/dist/server/src/luncherImport.js create mode 100644 server/dist/server/src/mealTemplates.js create mode 100644 server/dist/server/src/meals.js create mode 100644 server/dist/server/src/profile.js create mode 100644 server/dist/server/src/routes/activityRoutes.js create mode 100644 server/dist/server/src/routes/calorieRoutes.js create mode 100644 server/dist/server/src/routes/dayRoutes.js create mode 100644 server/dist/server/src/routes/importRoutes.js create mode 100644 server/dist/server/src/routes/mealRoutes.js create mode 100644 server/dist/server/src/routes/mealTemplateRoutes.js create mode 100644 server/dist/server/src/routes/settingsRoutes.js create mode 100644 server/dist/server/src/routes/statsRoutes.js create mode 100644 server/dist/server/src/routes/workoutRoutes.js create mode 100644 server/dist/server/src/settings.js create mode 100644 server/dist/server/src/sleep.js create mode 100644 server/dist/server/src/statsService.js create mode 100644 server/dist/server/src/storage/StorageInterface.js create mode 100644 server/dist/server/src/storage/index.js create mode 100644 server/dist/server/src/storage/json.js create mode 100644 server/dist/server/src/storage/memory.js create mode 100644 server/dist/server/src/storage/redis.js create mode 100644 server/dist/server/src/utils.js create mode 100644 server/dist/server/src/workoutPlans.js create mode 100644 server/dist/server/src/workouts.js create mode 100644 server/dist/types/gen/client.gen.js create mode 100644 server/dist/types/gen/index.js create mode 100644 server/dist/types/gen/sdk.gen.js create mode 100644 server/dist/types/gen/types.gen.js create mode 100644 server/dist/types/index.js create mode 100644 server/dist/types/openapi-ts.config.js create mode 100644 server/src/bodyMetrics.ts create mode 100644 server/src/nutrients.ts create mode 100644 server/src/tests/bodyMetrics.test.ts create mode 100644 server/src/tests/mealHistory.test.ts create mode 100644 server/src/weights.ts create mode 100644 types/paths/day/weight.yml create mode 100644 types/paths/day/weights.yml delete mode 100644 types/paths/mealTemplates/fromMeal.yml create mode 100644 types/paths/meals/fillSameName.yml create mode 100644 types/paths/meals/history.yml create mode 100644 types/paths/meals/sources.yml create mode 100644 types/paths/settings/goals.yml diff --git a/CLAUDE.md b/CLAUDE.md index 3c5f681..d6e3378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` — 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::`) 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:`) 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:`) 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; diff --git a/client/src/App.scss b/client/src/App.scss index 0142ed5..270ff58 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -510,6 +510,35 @@ body { } } +// Nutriční hodnoty jsou volitelné, ve formuláři jídla jsou sbalené pod odkazem +.nutrients-toggle, +.nutrients-legend-toggle { + padding: 0; + border: none; + background: transparent; + font-size: 0.8rem; + font-weight: 600; + color: var(--ft-text-muted); + text-decoration: none; + + &:hover { + color: var(--ft-primary); + } +} + +.nutrients-legend-toggle { + font-size: inherit; + font-weight: inherit; + text-transform: inherit; + letter-spacing: inherit; +} + +// "z toho" položky jsou součástí živiny nad nimi +.nutrient-sub-label { + padding-left: 10px; + color: var(--ft-text-secondary); +} + // Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole .derived-value { display: flex; @@ -853,17 +882,17 @@ body { } // ============================================ -// ŠABLONY JÍDEL — návrhy a skupiny +// NÁVRHY JÍDEL A RECEPTY // ============================================ -// Návrhy šablon pod názvem jídla ve formuláři -.template-suggestions { +// Návrhy z historie a receptů pod názvem jídla ve formuláři +.meal-suggestions { margin-top: 6px; border: 1px solid var(--ft-border-light); border-radius: var(--ft-radius); overflow: hidden; - .template-suggestions-title { + .meal-suggestions-title { padding: 4px 10px; font-size: 0.72rem; font-weight: 700; @@ -873,7 +902,7 @@ body { background: var(--ft-bg-hover); } - .template-suggestion { + .meal-suggestion { display: flex; align-items: baseline; gap: 8px; @@ -892,16 +921,16 @@ body { } } - .template-suggestion-icon { + .meal-suggestion-icon { color: var(--ft-primary); font-size: 0.75rem; } - .template-suggestion-name { + .meal-suggestion-name { font-weight: 600; } - .template-suggestion-detail { + .meal-suggestion-detail { margin-left: auto; color: var(--ft-text-muted); font-size: 0.8rem; diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index dd02ac4..10a957a 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -1,4 +1,5 @@ -import { MealTemplate } from "../../types"; +import { MealSuggestion, MealTemplate, Nutrients } from "../../types"; +import { NUTRIENTS_IN_ORDER } from "./enums"; const TOKEN_KEY = "token"; @@ -341,17 +342,64 @@ export const deriveRecipe = ( export const normalizeSearch = (value: string): string => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); +/** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */ +export const describeMealTemplate = (template: MealTemplate): string => [ + template.source, + template.weight != null ? `porce ${formatWeight(template.weight)}` : undefined, + template.calories != null ? formatCalories(template.calories) : undefined, + template.price != null ? formatPrice(template.price) : undefined, +].filter(item => !!item).join(' · '); + /** - * Popíše šablonu jídla jednou řádkou — zdroj, gramáž porce, kalorie a cena, - * co je vyplněné. U receptu jsou to hodnoty jedné porce, které dopočítal server. + * Popíše jídlo z historie jednou řádkou — zdroj, gramáž, kalorie a cena, co je + * vyplněné. Gramáž a kalorie se dopočtou stejně, jako je dopočte nový záznam. */ -export const describeMealTemplate = (template: MealTemplate): string => { - const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g); - const calories = derived.calories ?? template.calories; +export const describeMealSuggestion = (suggestion: MealSuggestion): string => { + const derived = deriveAmounts(suggestion.price, suggestion.pricePer100g, suggestion.weight, suggestion.caloriesPer100g); + const calories = derived.calories ?? suggestion.calories; return [ - template.source, + suggestion.source, derived.weight != null ? formatWeight(derived.weight) : undefined, calories != null ? formatCalories(calories) : undefined, - template.price != null ? formatPrice(template.price) : undefined, + suggestion.price != null ? formatPrice(suggestion.price) : undefined, ].filter(item => !!item).join(' · '); }; + +/** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */ +const roundTenth = (value: number): number => Math.round(value * 10) / 10; + +/** + * Spočítá živiny porce z hodnot na 100 g, gramáže a násobku. + * Musí dávat stejné výsledky jako `getPortionNutrients` na serveru. + */ +export const getPortionNutrients = ( + nutrientsPer100g: Nutrients | undefined, + weight: number | undefined, + multiplier?: number, +): Nutrients | undefined => { + if (!nutrientsPer100g || weight == null) return undefined; + const result: Nutrients = {}; + for (const key of NUTRIENTS_IN_ORDER) { + const per100g = nutrientsPer100g[key]; + if (per100g != null) { + result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1)); + } + } + return result; +}; + +/** Naformátuje množství živiny v gramech, např. 6.3 → "6,3 g". */ +export const formatNutrient = (grams?: number): string => + grams == null ? '—' : `${grams.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })} g`; + +/** Převede gramy živiny zadané uživatelem (i s desetinnou čárkou) na číslo; záporný vstup odmítne. */ +export const parseNutrientInput = (value: string): number | undefined => { + const trimmed = value.trim().replace(',', '.'); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed >= 0 ? roundTenth(parsed) : undefined; +}; + +/** Převede gramy živiny na text pro editační pole. */ +export const formatNutrientInput = (grams?: number): string => + grams == null ? '' : String(grams).replace('.', ','); diff --git a/client/src/components/BodyMetricsTiles.tsx b/client/src/components/BodyMetricsTiles.tsx new file mode 100644 index 0000000..82ca13d --- /dev/null +++ b/client/src/components/BodyMetricsTiles.tsx @@ -0,0 +1,74 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faWeightScale, faRulerCombined, faPercent, faFire, faBullseye } from "@fortawesome/free-solid-svg-icons"; +import { UserSettings } from "../../../types"; +import { BMI_KATEGORIE_NAMES } from "../enums"; +import { formatBalance, formatCalories, formatWeightKg, getHumanDate } from "../Utils"; + +type Props = { + settings: UserSettings, +}; + +/** + * Přehled tělesných ukazatelů — hmotnost, BMI, podíl tuku, klidový výdej a cíl. + * Všechno počítá server; dlaždice bez údajů říká, co doplnit. + */ +const BodyMetricsTiles = ({ settings }: Readonly) => { + const metrics = settings.bodyMetrics ?? {}; + const formatDecimal = (value: number) => value.toLocaleString('cs-CZ', { maximumFractionDigits: 1 }); + + return ( +
+
+
Hmotnost
+
{formatWeightKg(metrics.weightGrams)}
+
+ {metrics.weightDate && `zváženo ${getHumanDate(metrics.weightDate)}`} + {!metrics.weightDate && (metrics.weightGrams != null ? 'z profilu, zapište vážení' : 'zapište vážení')} +
+
+
+
BMI
+
{metrics.bmi != null ? formatDecimal(metrics.bmi) : '—'}
+
+ {metrics.bmiCategory && <>{BMI_KATEGORIE_NAMES[metrics.bmiCategory]}
} + {metrics.healthyWeightMinGrams != null && metrics.healthyWeightMaxGrams != null + ? `zdravá hmotnost ${formatDecimal(metrics.healthyWeightMinGrams / 1000)}–${formatWeightKg(metrics.healthyWeightMaxGrams)}` + : 'vyplňte výšku'} +
+
+
+
Podíl tuku
+
+ {metrics.bodyFatPercent != null ? `${formatDecimal(metrics.bodyFatPercent)} %` : '—'} +
+
+ {metrics.bodyFatPercent != null ? 'odhad US Navy, ±3–4 %' : 'vyplňte obvody níže'} +
+
+
+
Klidový výdej
+
{formatCalories(settings.effectiveBasalCalories)}
+
+ {settings.basalCalories != null && 'ručně zadaný'} + {settings.basalCalories == null && (settings.computedBasalCalories != null + ? 'Mifflin–St Jeor, odhad ±10 %' + : 'vyplňte údaje a vážení')} +
+
+
+
Cíl
+
+ {metrics.targetDailyBalance != null ? formatBalance(metrics.targetDailyBalance) : '—'} +
+
+ {metrics.targetDailyBalance == null && 'nastavte tempo níže'} + {metrics.targetDailyBalance != null && 'bilance za den'} + {metrics.weeksToTarget != null && `, cílová hmotnost za ${metrics.weeksToTarget} týd.`} + {metrics.proteinTargetGrams != null && <>
bílkoviny {metrics.proteinTargetGrams} g denně} +
+
+
+ ); +}; + +export default BodyMetricsTiles; diff --git a/client/src/components/FillSameNameOffer.tsx b/client/src/components/FillSameNameOffer.tsx new file mode 100644 index 0000000..ec2c786 --- /dev/null +++ b/client/src/components/FillSameNameOffer.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import { Alert, Button } from "react-bootstrap"; +import { FillSameNameResult, MealEntry, fillSameNameMeals } from "../../../types"; + +type Props = { + /** Právě uložené jídlo s kaloriemi nebo živinami. */ + entry: MealEntry, + /** Zavolá se po doplnění s počtem doplněných jídel. */ + onFilled: (count: number) => void, + onDismiss: () => void, +}; + +/** + * Nabídka doplnit kalorie a živiny právě uloženého jídla do ostatních jídel + * stejného názvu, která je nemají — typicky naimportovaných z Luncheru. + * Když takové jídlo není, nic se nezobrazí. + */ +const FillSameNameOffer = ({ entry, onFilled, onDismiss }: Readonly) => { + const [preview, setPreview] = useState(); + const [busy, setBusy] = useState(false); + + useEffect(() => { + setPreview(undefined); + fillSameNameMeals({ body: { id: entry.id, date: entry.date, dryRun: true } }) + .then(response => setPreview(response.data)); + }, [entry]); + + if (!preview?.count) return null; + + const fill = async () => { + setBusy(true); + const response = await fillSameNameMeals({ body: { id: entry.id, date: entry.date } }); + setBusy(false); + if (response.data) onFilled(response.data.count); + }; + + return ( + onDismiss()} + > + + {preview.count === 1 + ? `Další jídlo „${entry.name}“ nemá kalorie.` + : `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`} + {' '}Doplnit stejné hodnoty? + + + + ); +}; + +export default FillSameNameOffer; diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 9019f17..4b98820 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Navbar, Nav, NavDropdown } from "react-bootstrap"; import { useLocation, useNavigate } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSun, faMoon, faGear, faUser } from "@fortawesome/free-solid-svg-icons"; +import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons"; import { useAuth } from "../context/auth"; import { useSettings, ThemePreference } from "../context/settings"; import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes"; @@ -13,6 +13,7 @@ const NAV_ITEMS = [ { url: DEN_URL, label: 'Můj den' }, { url: SABLONY_URL, label: 'Šablony' }, { url: STATISTIKY_URL, label: 'Statistiky' }, + { url: PROFIL_URL, label: 'Profil' }, ]; export default function Header() { @@ -53,12 +54,6 @@ export default function Header() { - navigate(DEN_URL)}>Můj den - navigate(SABLONY_URL)}>Šablony tréninků - navigate(STATISTIKY_URL)}>Statistiky - navigate(PROFIL_URL)}> - Osobní údaje - setSettingsOpen(true)}> Nastavení diff --git a/client/src/components/ImportPreview.tsx b/client/src/components/ImportPreview.tsx index b6e680f..feecf29 100644 --- a/client/src/components/ImportPreview.tsx +++ b/client/src/components/ImportPreview.tsx @@ -1,7 +1,7 @@ import { Form } from "react-bootstrap"; import { ImportMode, ImportResult, MealEntry } from "../../../types"; import { MEAL_TYPE_ICONS } from "../enums"; -import { formatPrice, getDayOfWeek, getHumanDate } from "../Utils"; +import { formatCalories, formatPrice, getDayOfWeek, getHumanDate } from "../Utils"; type Props = { preview: ImportResult, @@ -17,7 +17,8 @@ type Props = { * Náhled importu rozdělený po dnech. * * U každého dne je vidět, co v něm už je — ručně zadaná jídla i dřívější import — - * aby šlo posoudit, jestli se něco nezdvojí, a řádek odškrtnout. + * aby šlo posoudit, jestli se něco nezdvojí, a řádek odškrtnout. Luncher kalorie + * nezná, takže kalorie u řádku znamenají, že se doplnily z historie. */ const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }: Readonly) => { const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort(); @@ -67,6 +68,14 @@ const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }: {entry.name} {entry.source && · {entry.source}} + {entry.calories != null && ( + + {' · '}{formatCalories(entry.calories)} z historie + + )} {formatPrice(entry.price)} diff --git a/client/src/components/MealRow.tsx b/client/src/components/MealRow.tsx index 3af447c..0e91387 100644 --- a/client/src/components/MealRow.tsx +++ b/client/src/components/MealRow.tsx @@ -1,5 +1,6 @@ import { MealEntry } from "../../../types"; -import { applyMultiplier, formatCalories, formatPrice, formatWeight } from "../Utils"; +import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES, NUTRIENT_SHORT_NAMES } from "../enums"; +import { applyMultiplier, formatCalories, formatNutrient, formatPrice, formatWeight, getPortionNutrients } from "../Utils"; import RowActions from "./RowActions"; import MultiplierControl from "./MultiplierControl"; @@ -7,13 +8,14 @@ type Props = { entry: MealEntry, onEdit: () => void, onDelete: () => void, - onSaveTemplate: () => void, onMultiplierChange: (multiplier: number) => void, }; /** Jeden řádek seznamu jídel v přehledu dne. */ -const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly) => { +const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly) => { const calories = applyMultiplier(entry.calories, entry.multiplier); + const nutrients = getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier); + const nutrientKeys = NUTRIENTS_IN_ORDER.filter(key => nutrients?.[key] != null); return (
@@ -30,6 +32,17 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange } {formatCalories(calories)} )} + {nutrients && nutrientKeys.some(key => NUTRIENT_SHORT_NAMES[key]) && ( + `${NUTRIENT_NAMES[key]} ${formatNutrient(nutrients[key])}`).join(', ')} + > + {nutrientKeys + .filter(key => NUTRIENT_SHORT_NAMES[key]) + .map(key => `${NUTRIENT_SHORT_NAMES[key]} ${formatNutrient(nutrients[key])}`) + .join(' · ')} + + )}
{entry.note &&
{entry.note}
}
@@ -40,7 +53,7 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange } label="jídla" />
{formatPrice(entry.price)}
- + ); }; diff --git a/client/src/components/MealSuggestions.tsx b/client/src/components/MealSuggestions.tsx new file mode 100644 index 0000000..9c0d209 --- /dev/null +++ b/client/src/components/MealSuggestions.tsx @@ -0,0 +1,70 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBookOpen, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import { MealSuggestion, MealTemplate } from "../../../types"; +import { describeMealSuggestion, describeMealTemplate, normalizeSearch } from "../Utils"; + +type Props = { + recipes: MealTemplate[], + history: MealSuggestion[], + /** Rozepsaný název jídla, podle kterého se hledá. */ + query: string, + onPickRecipe: (recipe: MealTemplate) => void, + onPickHistory: (suggestion: MealSuggestion) => void, +}; + +/** Kolik návrhů se nabídne — víc by formulář zbytečně natahovalo. */ +const SUGGESTION_LIMIT = 5; + +/** + * Návrhy k rozepsanému názvu jídla — recepty a jídla z historie. + * + * Když uživatel jí něco, co už někdy měl, nemusí hodnoty dohledávat znovu — + * stačí začít psát název a vybrat. Jídlo z historie, které se jmenuje stejně + * jako recept, se nenabízí podruhé; vzniklo nejspíš právě z receptu. + */ +const MealSuggestions = ({ recipes, history, query, onPickRecipe, onPickHistory }: Readonly) => { + const key = normalizeSearch(query); + if (key.length < 2) return null; + const matches = (name: string) => normalizeSearch(name).includes(key); + + const recipeList = recipes.filter(recipe => matches(recipe.name)).slice(0, SUGGESTION_LIMIT); + const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name))); + const historyList = history + .filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name))) + .slice(0, SUGGESTION_LIMIT - recipeList.length); + if (!recipeList.length && !historyList.length) return null; + + return ( +
+
Už jste to měli?
+ {recipeList.map(recipe => ( + + ))} + {historyList.map(item => ( + + ))} +
+ ); +}; + +export default MealSuggestions; diff --git a/client/src/components/MealTemplateCard.tsx b/client/src/components/MealTemplateCard.tsx index b8df47d..589b87c 100644 --- a/client/src/components/MealTemplateCard.tsx +++ b/client/src/components/MealTemplateCard.tsx @@ -1,10 +1,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { MealTemplate, MealTemplateKind } from "../../../types"; +import { MealTemplate } from "../../../types"; import { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; -import { - deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories, -} from "../Utils"; +import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils"; type Props = { template: MealTemplate, @@ -14,18 +12,11 @@ type Props = { }; /** - * Karta šablony jídla na stránce Šablony. - * - * U receptu ukazuje suroviny, hodnoty celého hrnce i jedné porce a postup; - * u hotového jídla jen výsledné hodnoty, které se do dne zakládají. + * Karta receptu na stránce Šablony — suroviny, hodnoty celého hrnce i jedné + * porce a postup. */ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly) => { - const isRecipe = template.kind === MealTemplateKind.RECEPT; - const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g); - const calories = derived.calories ?? template.calories; - const recipe = isRecipe - ? deriveRecipe(template.ingredients ?? [], template.servings, template.cookedWeight) - : undefined; + const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight); return (
@@ -38,18 +29,18 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly) )} {template.source && {template.source}} - {derived.weight != null && ( - {isRecipe ? 'porce ' : ''}{formatWeight(derived.weight)} - )} - {isRecipe && (template.servings ?? 1) > 1 && ( + {template.weight != null && porce {formatWeight(template.weight)}} + {(template.servings ?? 1) > 1 && ( {template.servings} porcí )}
- {calories != null && {formatCalories(calories)}} + {template.calories != null && ( + {formatCalories(template.calories)} + )} {template.price != null && ( {formatPrice(template.price)} )} - - {isRecipe && !!template.ingredients?.length && ( + {!!template.ingredients.length && (
    {template.ingredients.map((ingredient, index) => ( // Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní @@ -80,14 +71,12 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly)
)} - {recipe && ( -
- Celkem {formatCalories(recipe.totalCalories)} - {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} - {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} - {recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}} -
- )} +
+ Celkem {formatCalories(recipe.totalCalories)} + {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} + {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} + {recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}} +
{template.note &&

{template.note}

} diff --git a/client/src/components/MealTemplateForm.tsx b/client/src/components/MealTemplateForm.tsx index 5fa9b62..3ec2c0b 100644 --- a/client/src/components/MealTemplateForm.tsx +++ b/client/src/components/MealTemplateForm.tsx @@ -1,47 +1,33 @@ import { useState } from "react"; -import { Alert, Button, ButtonGroup, Form } from "react-bootstrap"; -import { MealTemplate, MealTemplateKind, MealType, saveMealTemplate } from "../../../types"; -import { - MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, -} from "../enums"; -import { - deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatPriceInput, formatWeight, - parseIntInput, parsePriceInput, -} from "../Utils"; -import CalorieLookup from "./CalorieLookup"; +import { Alert, Button, Form } from "react-bootstrap"; +import { MealTemplate, MealType, saveMealTemplate } from "../../../types"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; +import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils"; +import SourceInput from "./SourceInput"; import RecipeIngredientsEditor, { DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient, } from "./RecipeIngredientsEditor"; type Props = { - /** Upravovaná šablona. Pokud chybí, zakládá se nová. */ + /** Upravovaný recept. Pokud chybí, zakládá se nový. */ template?: MealTemplate, onSaved: (templates: MealTemplate[]) => void, onCancel: () => void, }; -/** Naplní formulář z šablony, nebo ho nechá prázdný pro novou. */ +/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */ const createForm = (template?: MealTemplate) => ({ - kind: template?.kind ?? MealTemplateKind.HOTOVE, name: template?.name ?? '', source: template?.source ?? '', mealType: (template?.mealType ?? '') as MealType | '', - weight: template?.weight != null ? String(template.weight) : '', - caloriesPer100g: template?.caloriesPer100g != null ? String(template.caloriesPer100g) : '', - calories: template?.calories != null ? String(template.calories) : '', - price: formatPriceInput(template?.price), - pricePer100g: formatPriceInput(template?.pricePer100g), note: template?.note ?? '', servings: template?.servings != null ? String(template.servings) : '', cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '', }); /** - * Formulář šablony jídla. - * - * Hotové jídlo (objednané, koupené) si pamatuje jen výsledné hodnoty porce. - * Recept je vařené jídlo: zapisují se suroviny a postup a hodnoty porce se - * z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled. + * Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty + * porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled. */ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { const [form, setForm] = useState(() => createForm(template)); @@ -53,96 +39,57 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => { const update = (changes: Partial>) => setForm(current => ({ ...current, ...changes })); - const isRecipe = form.kind === MealTemplateKind.RECEPT; const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient); const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight)); const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0); - const preview = deriveAmounts( - parsePriceInput(form.price), - parsePriceInput(form.pricePer100g), - parseIntInput(form.weight), - parseIntInput(form.caloriesPer100g)); - const previewCalories = preview.calories ?? parseIntInput(form.calories); - - const canSave = form.name.trim().length > 0 && (!isRecipe || filledIngredients.length > 0) && !busy; + const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy; const save = async () => { if (!canSave) return; setBusy(true); setError(undefined); - const common = { + // Hodnoty porce si server dopočte ze surovin sám + const body = { ...(template ? { id: template.id } : {}), - kind: form.kind, name: form.name.trim(), ...(form.source.trim() ? { source: form.source.trim() } : {}), ...(form.mealType ? { mealType: form.mealType } : {}), ...(form.note.trim() ? { note: form.note.trim() } : {}), + ingredients: filledIngredients, + servings: parseIntInput(form.servings), + cookedWeight: parseIntInput(form.cookedWeight), }; - // Hodnoty porce receptu si server dopočte ze surovin sám - const body = isRecipe - ? { - ...common, - ingredients: filledIngredients, - servings: parseIntInput(form.servings), - cookedWeight: parseIntInput(form.cookedWeight), - } - : { - ...common, - weight: parseIntInput(form.weight), - caloriesPer100g: parseIntInput(form.caloriesPer100g), - calories: parseIntInput(form.calories), - price: parsePriceInput(form.price), - pricePer100g: parsePriceInput(form.pricePer100g), - }; const response = await saveMealTemplate({ body }); setBusy(false); if (response.data) { onSaved(response.data); } else { - setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit'); + setError((response.error as { error?: string })?.error ?? 'Recept se nepodařilo uložit'); } }; return (
-

{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}

+

{template ? 'Úprava receptu' : 'Nový recept'}

{error && {error}} - - {MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => ( - - ))} - -

- {isRecipe - ? 'Vařené jídlo — zapíšete suroviny a postup, kalorie a cena porce se dopočtou samy.' - : 'Objednané nebo koupené jídlo — stačí jednou zapsat hodnoty a příště je nemusíte dohledávat.'} -

-
Název update({ name: event.target.value })} />
Zdroj - update({ source: event.target.value })} + placeholder="doma" + onChange={source => update({ source })} />
@@ -161,159 +108,71 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly) => {
- {isRecipe ? ( - <> -
- Suroviny - -
+
+ Suroviny + +
-
- Výsledek -
-
- Počet porcí - update({ servings: event.target.value.replace(/\D/g, '') })} - /> -
-
- Hmotnost hotového jídla -
- update({ cookedWeight: event.target.value.replace(/\D/g, '') })} - /> - g -
- vařením se mění, bez ní se vezme součet surovin -
-
- Celkem / jedna porce -
- {formatCalories(recipe.totalCalories)} - {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} - {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} -
- porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)} - {recipe.price != null && <> · {formatPrice(recipe.price)}} -
-
-
-
-
- - ) : ( -
- Kalorie, gramáž a cena porce -
-
- Gramáž -
- update({ weight: event.target.value.replace(/\D/g, '') })} - /> - g -
-
-
- Energie na 100 g -
- update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })} - /> - kcal -
-
-
- Cena -
- update({ price: event.target.value })} - /> - Kč -
-
-
- Cena za 100 g -
- update({ pricePer100g: event.target.value })} - /> - Kč -
-
+
+ Výsledek +
+
+ Počet porcí + update({ servings: event.target.value.replace(/\D/g, '') })} + />
- -
- {preview.calories == null && ( -
- Kalorie porce -
- update({ calories: event.target.value.replace(/\D/g, '') })} - /> - kcal -
- když neznáte gramáž ani hodnotu na 100 g -
- )} -
- update({ caloriesPer100g: String(value) })} +
+ Hmotnost hotového jídla +
+ update({ cookedWeight: event.target.value.replace(/\D/g, '') })} /> + g +
+ vařením se mění, bez ní se vezme součet surovin +
+
+ Celkem / jedna porce +
+ {formatCalories(recipe.totalCalories)} + {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} + {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} +
+ porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)} + {recipe.price != null && <> · {formatPrice(recipe.price)}} +
-
- )} +
+
- {isRecipe ? 'Postup' : 'Poznámka'} + Postup update({ note: event.target.value })} />
- {!isRecipe && previewCalories != null && ( - - {preview.weight != null && `${formatWeight(preview.weight)} · `} - {formatCalories(previewCalories)} - - )} - {isRecipe && recipe.calories != null && ( + {recipe.calories != null && ( porce {formatCalories(recipe.calories)} )} {template && ( )}
diff --git a/client/src/components/NutrientSummary.tsx b/client/src/components/NutrientSummary.tsx new file mode 100644 index 0000000..d9a102e --- /dev/null +++ b/client/src/components/NutrientSummary.tsx @@ -0,0 +1,31 @@ +import { Nutrients } from "../../../types"; +import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums"; +import { formatNutrient } from "../Utils"; + +type Props = { + nutrients: Nutrients, + /** Cílový příjem bílkovin dne v gramech, pokud si ho uživatel nastavil. */ + proteinTarget?: number, +}; + +/** + * Součet živin za den. Započítávají se jen jídla s gramáží a nutričními + * hodnotami, takže jde o spodní odhad, pokud je některé jídlo nemá. + */ +const NutrientSummary = ({ nutrients, proteinTarget }: Readonly) => ( +
+ {NUTRIENTS_IN_ORDER.filter(key => nutrients[key] != null).map(key => ( +
+ {NUTRIENT_NAMES[key]} + + {formatNutrient(nutrients[key])} + {key === 'protein' && proteinTarget != null && ( + / {proteinTarget} g + )} + +
+ ))} +
+); + +export default NutrientSummary; diff --git a/client/src/components/NutrientsFieldset.tsx b/client/src/components/NutrientsFieldset.tsx new file mode 100644 index 0000000..29d4990 --- /dev/null +++ b/client/src/components/NutrientsFieldset.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { Button, Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { Nutrients } from "../../../types"; +import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums"; +import { formatNutrient, getPortionNutrients, parseNutrientInput } from "../Utils"; + +/** Rozepsané hodnoty živin tak, jak je uživatel píše do polí. */ +export type NutrientInputs = Record; + +type Props = { + /** Hodnoty na 100 g, jak je uživatel zapsal. */ + values: NutrientInputs, + /** Gramáž porce — zadaná, nebo dopočtená z ceny. Bez ní se porce nespočítá. */ + weight?: number, + onChange: (values: NutrientInputs) => void, +}; + +/** Položky "z toho" se odsadí pod živinu, jejíž jsou součástí. */ +const SUB_NUTRIENTS = new Set(['sugars', 'saturatedFat']); + +/** Prázdné hodnoty pro nové jídlo. */ +export const emptyNutrientInputs = (): NutrientInputs => ({ + protein: '', carbohydrates: '', sugars: '', fat: '', saturatedFat: '', fiber: '', salt: '', +}); + +/** Převede rozepsané hodnoty na živiny k odeslání; bez vyplněné hodnoty vrátí undefined. */ +export const toNutrients = (values: NutrientInputs): Nutrients | undefined => { + const result: Nutrients = {}; + for (const key of NUTRIENTS_IN_ORDER) { + const value = parseNutrientInput(values[key]); + if (value != null) result[key] = value; + } + return Object.keys(result).length ? result : undefined; +}; + +/** + * Nutriční hodnoty jídla na 100 g, ve stejném členění jako na etiketě. + * + * Jsou volitelné, a proto ve výchozím stavu sbalené — rozbalí se samy, když + * jídlo už nějaké hodnoty má. Hodnoty porce se dopočtou z gramáže stejně jako + * kalorie; bez gramáže se jen uloží a do součtu dne nevstoupí. + */ +const NutrientsFieldset = ({ values, weight, onChange }: Readonly) => { + const filled = NUTRIENTS_IN_ORDER.some(key => values[key].trim().length > 0); + const [expanded, setExpanded] = useState(filled); + const portion = getPortionNutrients(toNutrients(values), weight); + + if (!expanded && !filled) { + return ( + + ); + } + + return ( +
+ + {!filled ? ( + + ) : 'Nutriční hodnoty na 100 g'} + +
+ {NUTRIENTS_IN_ORDER.map(key => ( +
+ + {NUTRIENT_NAMES[key]} + +
+ 0 && parseNutrientInput(values[key]) == null} + onChange={event => onChange({ ...values, [key]: event.target.value })} + /> + g +
+ {portion?.[key] != null && porce {formatNutrient(portion[key])}} +
+ ))} +
+ {filled && weight == null && ( + Bez gramáže se hodnoty porce nespočítají a do součtu dne nevstoupí. + )} +
+ ); +}; + +export default NutrientsFieldset; diff --git a/client/src/components/SourceInput.tsx b/client/src/components/SourceInput.tsx new file mode 100644 index 0000000..24fe86c --- /dev/null +++ b/client/src/components/SourceInput.tsx @@ -0,0 +1,50 @@ +import { useEffect, useId, useState } from "react"; +import { Form } from "react-bootstrap"; +import { getMealSources, getSettings } from "../../../types"; +import { normalizeSearch } from "../Utils"; + +type Props = { + value: string, + onChange: (value: string) => void, + placeholder?: string, +}; + +/** + * Pole pro zdroj jídla s nabídkou už použitých zdrojů. + * + * Zdroj je v praxi číselník — většinou se vybírá podnik, který už v historii je, + * a jen občas se zapíše nový. Nabízejí se zdroje z historie (nejčastější první) + * a podniky s nastavenou sazbou za 100 g; psát jde i cokoli jiného. + */ +const SourceInput = ({ value, onChange, placeholder }: Readonly) => { + const listId = useId(); + const [sources, setSources] = useState([]); + + // Načítá se při každém zobrazení pole, zdroje mohly mezitím přibýt + useEffect(() => { + Promise.all([getMealSources(), getSettings()]).then(([sourceResponse, settingsResponse]) => { + const resultList = [...(sourceResponse.data ?? [])]; + const known = new Set(resultList.map(source => normalizeSearch(source))); + for (const rate of settingsResponse.data?.sourceRates ?? []) { + if (!known.has(normalizeSearch(rate.source))) resultList.push(rate.source); + } + setSources(resultList); + }); + }, []); + + return ( + <> + onChange(event.target.value)} + /> + + {sources.map(source => + + ); +}; + +export default SourceInput; diff --git a/client/src/components/TemplateSuggestions.tsx b/client/src/components/TemplateSuggestions.tsx deleted file mode 100644 index b887758..0000000 --- a/client/src/components/TemplateSuggestions.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBookmark, faBookOpen } from "@fortawesome/free-solid-svg-icons"; -import { MealTemplate, MealTemplateKind } from "../../../types"; -import { describeMealTemplate, normalizeSearch } from "../Utils"; - -type Props = { - templates: MealTemplate[], - /** Rozepsaný název jídla, podle kterého se šablony hledají. */ - query: string, - onPick: (template: MealTemplate) => void, -}; - -/** Kolik návrhů se nabídne — víc by formulář zbytečně natahovalo. */ -const SUGGESTION_LIMIT = 5; - -/** - * Návrhy šablon k rozepsanému názvu jídla. - * - * Když uživatel jí něco, co už někdy měl (sušenka, oblíbená objednávka), - * nemusí hodnoty dohledávat znovu — stačí začít psát název a vybrat šablonu. - */ -const TemplateSuggestions = ({ templates, query, onPick }: Readonly) => { - const key = normalizeSearch(query); - if (key.length < 2) return null; - const matchList = templates - .filter(template => normalizeSearch(template.name).includes(key)) - .slice(0, SUGGESTION_LIMIT); - if (!matchList.length) return null; - - return ( -
-
Už jste to měli?
- {matchList.map(template => ( - - ))} -
- ); -}; - -export default TemplateSuggestions; diff --git a/client/src/components/WeightHistoryPanel.tsx b/client/src/components/WeightHistoryPanel.tsx new file mode 100644 index 0000000..eacf175 --- /dev/null +++ b/client/src/components/WeightHistoryPanel.tsx @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + CartesianGrid, Line, LineChart, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, +} from "recharts"; +import { BodyMetrics, Goals, WeightEntry, getWeights, saveWeight } from "../../../types"; +import { + formatDate, formatDateString, formatWeightKg, getHumanDate, parseWeightKgInput, shiftDate, +} from "../Utils"; +import { TOOLTIP_STYLE } from "../pages/statsShared"; + +type Props = { + metrics?: BodyMetrics, + goals?: Goals, + /** Zavolá se po změně vážení — ukazatele se počítají z posledního. */ + onChanged: () => void, +}; + +/** Kolik posledních vážení se vypíše pod grafem. */ +const RECENT_COUNT = 5; + +/** + * Změna hmotnosti za posledních N dní — proti poslednímu vážení, které je + * aspoň N dní staré. Bez takového vážení nic. + */ +const getChange = (weightList: WeightEntry[], days: number): number | undefined => { + const latest = weightList[weightList.length - 1]; + if (!latest) return undefined; + const limit = shiftDate(latest.date, -days); + const previous = weightList.filter(entry => entry.date <= limit).pop(); + return previous ? latest.weightGrams - previous.weightGrams : undefined; +}; + +/** Naformátuje změnu hmotnosti se znaménkem, např. −1,2 kg. */ +const formatChange = (grams?: number): string => { + if (grams == null) return '—'; + const kg = (grams / 1000).toLocaleString('cs-CZ', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + return grams > 0 ? `+${kg} kg` : `${kg.replace('-', '−')} kg`; +}; + +/** + * Vývoj hmotnosti — graf vážení se zdravým rozmezím a cílovou hmotností, + * zápis nového vážení a změna za týden a měsíc. Vážení jde zapsat i v přehledu + * dne; zápis ke dni, který už vážení má, ho přepíše. + */ +const WeightHistoryPanel = ({ metrics, goals, onChanged }: Readonly) => { + const [weightList, setWeightList] = useState(); + const [date, setDate] = useState(() => formatDate(new Date())); + const [weightInput, setWeightInput] = useState(''); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const response = await getWeights(); + setWeightList(response.data ?? []); + }, []); + + useEffect(() => { load(); }, [load]); + + const weightGrams = parseWeightKgInput(weightInput); + + const save = async (forDate: string, grams?: number) => { + setBusy(true); + await saveWeight({ body: { date: forDate, ...(grams != null ? { weightGrams: grams } : {}) } }); + setBusy(false); + setWeightInput(''); + await load(); + onChanged(); + }; + + // Do načtení nic, ať neproblikne hláška pro uživatele bez vážení + if (!weightList) return null; + + const chartData = weightList.map(entry => ({ + label: formatDateString(entry.date).slice(0, 6), + kg: entry.weightGrams / 1000, + })); + const minKg = metrics?.healthyWeightMinGrams != null ? metrics.healthyWeightMinGrams / 1000 : undefined; + const maxKg = metrics?.healthyWeightMaxGrams != null ? metrics.healthyWeightMaxGrams / 1000 : undefined; + const targetKg = goals?.targetWeightGrams != null ? goals.targetWeightGrams / 1000 : undefined; + + return ( +
+

Vývoj hmotnosti

+ +
+ event.target.value && setDate(event.target.value)} + aria-label="Den vážení" + /> +
+ setWeightInput(event.target.value)} + /> + kg +
+ +
+ týden {formatChange(getChange(weightList, 7))} + měsíc {formatChange(getChange(weightList, 30))} +
+
+ + {weightList.length > 1 && ( + + + + + Math.round(value).toString()} + tick={{ fontSize: 12 }} + stroke="var(--ft-text-muted)" + width={40} + /> + {minKg != null && maxKg != null && ( + + )} + {targetKg != null && ( + + )} + [`${Number(value).toLocaleString('cs-CZ')} kg`, 'Hmotnost']} + contentStyle={TOOLTIP_STYLE} + /> + + + + )} + {weightList.length <= 1 && ( +

+ Graf se ukáže od druhého vážení. Vážit se jde i v přehledu dne vedle spánku. +

+ )} + + {!!weightList.length && ( +
    + {weightList.slice(-RECENT_COUNT).reverse().map(entry => ( +
  • + {getHumanDate(entry.date)} + {formatWeightKg(entry.weightGrams)} + +
  • + ))} +
+ )} +
+ ); +}; + +export default WeightHistoryPanel; diff --git a/client/src/components/modals/ApplyMealModal.tsx b/client/src/components/modals/ApplyMealModal.tsx index 2078853..8a1070b 100644 --- a/client/src/components/modals/ApplyMealModal.tsx +++ b/client/src/components/modals/ApplyMealModal.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from "react"; import { Button, Form, Modal, Spinner } from "react-bootstrap"; import { useNavigate } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons"; -import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types"; +import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; import { - MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, -} from "../../enums"; -import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils"; + MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates, +} from "../../../../types"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums"; +import { describeMealSuggestion, describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils"; import { SABLONY_URL } from "../../routes"; type Props = { @@ -15,20 +15,24 @@ type Props = { /** Den, do kterého se jídlo založí. */ date: string, onClose: () => void, - /** Zavolá se po založení jídla s aktualizovaným přehledem dne. */ - onApplied: (day: DayOverview) => void, + /** Zavolá se po založení jídla; přehled dne je potřeba načíst znovu. */ + onApplied: () => void, }; +/** Kolik jídel z historie se vypíše — dlouhý seznam se zúží hledáním. */ +const HISTORY_LIMIT = 30; + /** - * Výběr šablony jídla k založení do dne. + * Výběr jídla z historie nebo receptu k založení do dne jedním kliknutím. * - * Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen - * vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat, - * protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se - * zapíší třeba dvě stejné sušenky naráz. + * Jídlo z historie se založí s hodnotami z posledního záznamu toho jména, recept + * jako jedna porce. Chod lze zvolit, protože totéž jídlo může být jednou oběd + * a podruhé večeře, a násobkem se zapíší třeba dvě stejné sušenky naráz. + * Kdo chce hodnoty před založením upravit, použije našeptávání ve formuláři jídla. */ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) => { - const [templates, setTemplates] = useState([]); + const [recipes, setRecipes] = useState([]); + const [history, setHistory] = useState([]); const [mealType, setMealType] = useState(''); const [multiplierInput, setMultiplierInput] = useState(''); const [search, setSearch] = useState(''); @@ -42,8 +46,9 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = setMealType(''); setMultiplierInput(''); setSearch(''); - getMealTemplates().then(response => { - setTemplates(response.data ?? []); + Promise.all([getMealTemplates(), getMealHistory()]).then(([recipeResponse, historyResponse]) => { + setRecipes(recipeResponse.data ?? []); + setHistory(historyResponse.data ?? []); setLoading(false); }); }, [isOpen]); @@ -51,30 +56,50 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = const multiplier = parseMultiplierInput(multiplierInput); const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null; - const apply = async (id: string) => { + const applyRecipe = async (recipe: MealTemplate) => { setBusy(true); const response = await applyMealTemplate({ - body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) }, + body: { + id: recipe.id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}), + }, }); setBusy(false); - if (response.data) { - onApplied(response.data); - } + if (response.data) onApplied(); + }; + + const applyHistory = async (suggestion: MealSuggestion) => { + setBusy(true); + const response = await addMeal({ + body: { + date, + mealType: mealType || MealType.OBED, + name: suggestion.name, + source: suggestion.source, + price: suggestion.price, + pricePer100g: suggestion.pricePer100g, + weight: suggestion.weight, + caloriesPer100g: suggestion.caloriesPer100g, + calories: suggestion.calories, + nutrientsPer100g: suggestion.nutrientsPer100g, + multiplier, + }, + }); + setBusy(false); + if (response.data) onApplied(); }; const key = normalizeSearch(search); - const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER - .map(kind => ({ - kind, - templates: templates.filter(template => template.kind === kind - && (!key.length || normalizeSearch(template.name).includes(key))), - })) - .filter(group => group.templates.length > 0); + const matches = (name: string) => !key.length || normalizeSearch(name).includes(key); + const recipeList = recipes.filter(recipe => matches(recipe.name)); + // Jídlo stejného jména jako recept vzniklo nejspíš z něj, nabízí se jen recept + const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name))); + const historyMatchList = history.filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name))); + const historyList = historyMatchList.slice(0, HISTORY_LIMIT); return ( - Použít šablonu jídla + Vybrat z historie {loading && ( @@ -83,18 +108,18 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = )} - {!loading && !templates.length && ( + {!loading && !recipes.length && !history.length && (
-
-
Zatím nemáte žádnou šablonu jídla
+
+
Zatím nemáte co vybírat

- Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem - „Uložit jako šablonu". + Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako + recept na stránce Šablony.

)} - {!loading && !!templates.length && ( + {!loading && (!!recipes.length || !!history.length) && ( <>
@@ -110,9 +135,10 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) = Založit jako setMealType(event.target.value as MealType | '')} > - + {MEAL_TYPES_IN_ORDER.map(type => (
- {!groupList.length && ( -

Žádná šablona neodpovídá hledání.

+ {!recipeList.length && !historyList.length && ( +

Hledání nic neodpovídá.

)} - {groupList.map(group => ( -
-

{MEAL_TEMPLATE_KIND_NAMES[group.kind]}

- {group.templates.map(template => ( -
+ {!!recipeList.length && ( +
+

Recepty

+ {recipeList.map(recipe => ( +
-
{template.name}
-
- {describeMealTemplate(template)} - {!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`} -
+
{recipe.name}
+
{describeMealTemplate(recipe)}
))}
- ))} + )} + + {!!historyList.length && ( +
+

Dříve snědené

+ {historyList.map(item => ( +
+
+
{item.name}
+
+ {describeMealSuggestion(item)} + {item.count > 1 && ` · ${item.count}×`} +
+
+ +
+ ))} + {historyMatchList.length > historyList.length && ( +

+ A dalších {historyMatchList.length - historyList.length}, upřesněte hledání. +

+ )} +
+ )} )} diff --git a/client/src/components/modals/MealModal.tsx b/client/src/components/modals/MealModal.tsx index 7c22e5f..3d638c4 100644 --- a/client/src/components/modals/MealModal.tsx +++ b/client/src/components/modals/MealModal.tsx @@ -1,16 +1,19 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; import { - DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings, - addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal, + DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings, + addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal, } from "../../../../types"; -import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums"; import { applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput, - formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput, + formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput, + parsePriceInput, } from "../../Utils"; import CalorieLookup from "../CalorieLookup"; -import TemplateSuggestions from "../TemplateSuggestions"; +import MealSuggestions from "../MealSuggestions"; +import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset"; +import SourceInput from "../SourceInput"; type Props = { isOpen: boolean, @@ -19,7 +22,8 @@ type Props = { /** Upravovaný záznam. Pokud chybí, jde o založení nového. */ entry?: MealEntry, onClose: () => void, - onSaved: (day: DayRecord) => void, + /** Zavolá se po uložení s přehledem dne a uloženým jídlem. */ + onSaved: (day: DayRecord, saved?: MealEntry) => void, }; /** Prázdný formulář pro nové jídlo v daném dni. */ @@ -36,9 +40,35 @@ function emptyForm(date: string) { calories: '', note: '', multiplier: '', + nutrients: emptyNutrientInputs(), }; } +type MealForm = ReturnType; + +/** + * Spojí formulář s hodnotami z návrhu. U nového jídla návrh přepíše vše — + * uživatel může mezi návrhy přeskakovat. U upravovaného jídla jen doplní prázdná + * pole: cena a zdroj z importu jsou skutečné a návrh je nesmí přepsat. + */ +const mergeSuggestion = (current: MealForm, values: Partial, onlyEmpty: boolean): MealForm => { + if (!onlyEmpty) return { ...current, ...values }; + const result = { ...current }; + for (const [key, value] of Object.entries(values) as [keyof MealForm, MealForm[keyof MealForm]][]) { + if (key === 'nutrients') { + const empty = Object.values(current.nutrients).every(item => !item.trim().length); + if (empty) result.nutrients = value as NutrientInputs; + } else if (key === 'name' || !String(current[key]).trim().length) { + (result as Record)[key] = value; + } + } + return result; +}; + +/** Převede uložené živiny na text pro editační pole. */ +const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs => + Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs; + /** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */ function normalizeSource(source: string): string { return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); @@ -52,23 +82,24 @@ function normalizeSource(source: string): string { * pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát * s jiným cenováním můžou být ve stejném dni vedle sebe. * - * U nového jídla se k rozepsanému názvu nabízejí šablony, takže jídlo, které už - * uživatel někdy měl, nemusí dohledávat znovu. + * K rozepsanému názvu se nabízejí recepty a jídla z historie, takže jídlo, které + * už uživatel někdy měl, nemusí dohledávat znovu. U upravovaného jídla se nabídnou + * až po změně názvu a doplní jen prázdná pole — hodí se k pojmenování + * naimportovaného jídla, jehož název Luncher složil z poznámky. */ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly) { const [form, setForm] = useState(() => emptyForm(date)); const [settings, setSettings] = useState(); const [saving, setSaving] = useState(false); - const [alsoTemplate, setAlsoTemplate] = useState(false); - const [templates, setTemplates] = useState([]); - /** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */ - const [pickedTemplate, setPickedTemplate] = useState(); + const [recipes, setRecipes] = useState([]); + const [history, setHistory] = useState([]); + /** Název návrhu, ze kterého se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */ + const [pickedSuggestion, setPickedSuggestion] = useState(); // Při každém otevření se formulář naplní dle toho, co se upravuje useEffect(() => { if (!isOpen) return; - setAlsoTemplate(false); - setPickedTemplate(undefined); + setPickedSuggestion(entry?.name); setForm(entry ? { date: entry.date, @@ -82,15 +113,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea calories: entry.calories != null ? String(entry.calories) : '', note: entry.note ?? '', multiplier: formatMultiplierInput(entry.multiplier), + nutrients: toNutrientInputs(entry.nutrientsPer100g), } : emptyForm(date)); }, [isOpen, entry, date]); - // Šablony se nabízejí jen při zakládání; načtou se při každém otevření, mohly přibýt + // Návrhy se načtou při každém otevření, mohly přibýt useEffect(() => { - if (!isOpen || entry) return; - getMealTemplates().then(response => setTemplates(response.data ?? [])); - }, [isOpen, entry]); + if (!isOpen) return; + getMealTemplates().then(response => setRecipes(response.data ?? [])); + getMealHistory().then(response => setHistory(response.data ?? [])); + }, [isOpen]); // Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g useEffect(() => { @@ -100,22 +133,41 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea const update = (changes: Partial) => setForm(current => ({ ...current, ...changes })); - /** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */ - const applyTemplate = (template: MealTemplate) => { - setPickedTemplate(template.name); - setForm(current => ({ - ...current, - name: template.name, - mealType: template.mealType ?? current.mealType, - source: template.source ?? '', - price: formatPriceInput(template.price), - pricePer100g: formatPriceInput(template.pricePer100g), - weight: template.weight != null ? String(template.weight) : '', - caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '', - calories: template.calories != null ? String(template.calories) : '', - // Postup receptu do záznamu dne nepatří, zůstává u šablony - note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '', - })); + /** + * Vyplní formulář jednou porcí receptu. Datum a násobek zůstanou, jak jsou, + * a postup do záznamu dne nepatří, zůstává u receptu. + */ + const applyRecipe = (recipe: MealTemplate) => { + setPickedSuggestion(recipe.name); + setForm(current => mergeSuggestion(current, { + name: recipe.name, + ...(recipe.mealType ? { mealType: recipe.mealType } : {}), + source: recipe.source ?? '', + price: formatPriceInput(recipe.price), + pricePer100g: '', + weight: recipe.weight != null ? String(recipe.weight) : '', + caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '', + calories: recipe.calories != null ? String(recipe.calories) : '', + nutrients: emptyNutrientInputs(), + }, !!entry)); + }; + + /** + * Vyplní formulář hodnotami jídla z historie. Chod, datum, násobek a poznámka + * patří ke konkrétnímu dni, takže zůstanou, jak je uživatel má. + */ + const applyHistory = (suggestion: MealSuggestion) => { + setPickedSuggestion(suggestion.name); + setForm(current => mergeSuggestion(current, { + name: suggestion.name, + source: suggestion.source ?? '', + price: formatPriceInput(suggestion.price), + pricePer100g: formatPriceInput(suggestion.pricePer100g), + weight: suggestion.weight != null ? String(suggestion.weight) : '', + caloriesPer100g: suggestion.caloriesPer100g != null ? String(suggestion.caloriesPer100g) : '', + calories: suggestion.calories != null ? String(suggestion.calories) : '', + nutrients: toNutrientInputs(suggestion.nutrientsPer100g), + }, !!entry)); }; /** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */ @@ -147,8 +199,10 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea const multiplier = parseMultiplierInput(form.multiplier); const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null; const totalCalories = applyMultiplier(effectiveCalories, multiplier); + const nutrientsInvalid = NUTRIENTS_IN_ORDER + .some(key => form.nutrients[key].trim().length > 0 && parseNutrientInput(form.nutrients[key]) == null); - const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving; + const canSave = form.name.trim().length > 0 && !multiplierInvalid && !nutrientsInvalid && !saving; const save = async () => { if (!canSave) return; @@ -166,22 +220,18 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea calories: derived.calories != null ? undefined : manualCalories, note: form.note.trim() || undefined, multiplier, + nutrientsPer100g: toNutrients(form.nutrients), }; const response = entry ? await updateMeal({ body: { id: entry.id, ...body } }) : await addMeal({ body }); - if (response.data && alsoTemplate) { + setSaving(false); + if (response.data) { // U nového jídla je ten správný záznam vždy ten poslední založený const saved = entry ? response.data.entries.find(item => item.id === entry.id) : [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop(); - if (saved) { - await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } }); - } - } - setSaving(false); - if (response.data) { - onSaved(response.data); + onSaved(response.data, saved); } }; @@ -198,13 +248,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea autoFocus value={form.name} placeholder="Např. Kuřecí gyros s hranolkami" - onChange={event => { update({ name: event.target.value }); setPickedTemplate(undefined); }} + onChange={event => { update({ name: event.target.value }); setPickedSuggestion(undefined); }} /> - {!entry && pickedTemplate !== form.name && ( - + {pickedSuggestion !== form.name && ( + normalizeSearch(item.name) !== normalizeSearch(entry.name)) + : history} + query={form.name} + onPickRecipe={recipe => applyRecipe(recipe)} + onPickHistory={suggestion => applyHistory(suggestion)} + /> )} - {!entry && pickedTemplate === form.name && ( - vyplněno ze šablony, hodnoty jde upravit + {pickedSuggestion === form.name && pickedSuggestion !== entry?.name && ( + + {entry ? 'doplněna prázdná pole z dřívějška' : 'vyplněno z dřívějška, hodnoty jde upravit'} + )} @@ -232,17 +293,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
Zdroj - update({ source: event.target.value })} + onChange={source => update({ source })} /> - - {settings?.sourceRates.map(rate => ( -
@@ -353,6 +408,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea + update({ nutrients })} + /> + Poznámka
- setAlsoTemplate(event.target.checked)} - />
{derived.weight != null && {formatWeight(derived.weight)}} {totalCalories != null && ( diff --git a/client/src/enums.ts b/client/src/enums.ts index 5bb2c74..ef4ef58 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -1,4 +1,4 @@ -import { ActivityUnit, ImportMode, MealTemplateKind, MealType, Pohlavi } from "../../types"; +import { ActivityUnit, BmiKategorie, ImportMode, MealType, Nutrients, Pohlavi } from "../../types"; /** Lidsky čitelné názvy typů jídel. */ export const MEAL_TYPE_NAMES: Record = { @@ -110,24 +110,50 @@ export const POHLAVI_NAMES: Record = { export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA]; +/** Názvy pásem BMI. */ +export const BMI_KATEGORIE_NAMES: Record = { + PODVAHA: 'podváha', + NORMA: 'normální hmotnost', + NADVAHA: 'nadváha', + OBEZITA: 'obezita', +}; + +/** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */ +export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500]; + /** Záložky stránky se šablonami. */ export enum TemplateTab { TRENINKY = 'TRENINKY', - JIDLA = 'JIDLA', + RECEPTY = 'RECEPTY', } export const TEMPLATE_TAB_NAMES: Record = { TRENINKY: 'Tréninky', - JIDLA: 'Jídla', + RECEPTY: 'Recepty', }; -/** Lidsky čitelné názvy druhů šablon jídel. */ -export const MEAL_TEMPLATE_KIND_NAMES: Record = { - HOTOVE: 'Hotové jídlo', - RECEPT: 'Recept', +/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */ +export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [ + 'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt', +]; + +/** Názvy živin; "z toho" položky jsou součástí té nad nimi. */ +export const NUTRIENT_NAMES: Record = { + protein: 'Bílkoviny', + carbohydrates: 'Sacharidy', + sugars: 'z toho cukry', + fat: 'Tuky', + saturatedFat: 'z toho nasycené', + fiber: 'Vláknina', + salt: 'Sůl', }; -export const MEAL_TEMPLATE_KINDS_IN_ORDER: MealTemplateKind[] = [MealTemplateKind.HOTOVE, MealTemplateKind.RECEPT]; +/** Krátké značky hlavních živin pro řádek jídla. */ +export const NUTRIENT_SHORT_NAMES: Partial> = { + protein: 'B', + carbohydrates: 'S', + fat: 'T', +}; /** Názvy režimů importu. */ export const IMPORT_MODE_NAMES: Record = { diff --git a/client/src/pages/DayPage.scss b/client/src/pages/DayPage.scss index 99658c3..57dbf88 100644 --- a/client/src/pages/DayPage.scss +++ b/client/src/pages/DayPage.scss @@ -66,6 +66,37 @@ } } +// Součet živin dne nad seznamem jídel +.nutrient-summary { + display: flex; + flex-wrap: wrap; + gap: 6px 20px; + padding: 10px 16px; + margin-bottom: 16px; + + .nutrient-summary-item { + display: flex; + flex-direction: column; + } + + .nutrient-summary-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ft-text-muted); + } + + .nutrient-summary-value { + font-weight: 700; + } + + .nutrient-summary-target { + font-weight: 400; + color: var(--ft-text-muted); + } +} + .meal-list { padding: 0; overflow: hidden; diff --git a/client/src/pages/DayPage.tsx b/client/src/pages/DayPage.tsx index 77a9bc8..840405f 100644 --- a/client/src/pages/DayPage.tsx +++ b/client/src/pages/DayPage.tsx @@ -3,16 +3,20 @@ import { Alert, Button, Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning, - faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark, + faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faClockRotateLeft, faBookmark, + faWeightScale, } from "@fortawesome/free-solid-svg-icons"; import { - ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal, - deleteActivity, deleteMeal, getDayOverview, saveSleep, setActivityMultiplier, setMealMultiplier, + ActivityEntry, DayOverview, MealEntry, + deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier, } from "../../../types"; import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; +import NutrientSummary from "../components/NutrientSummary"; +import FillSameNameOffer from "../components/FillSameNameOffer"; import { applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep, - formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate, + formatSleepInput, formatWeightKgInput, getDayOfWeek, getHumanDate, parseSleepInput, parseWeightKgInput, + shiftDate, } from "../Utils"; import MealModal from "../components/modals/MealModal"; import ActivityModal from "../components/modals/ActivityModal"; @@ -43,12 +47,14 @@ export default function DayPage() { const [editedActivity, setEditedActivity] = useState(); const [activityModalOpen, setActivityModalOpen] = useState(false); const [workoutModalOpen, setWorkoutModalOpen] = useState(false); - const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false); + const [mealHistoryModalOpen, setMealHistoryModalOpen] = useState(false); const [sleepInput, setSleepInput] = useState(''); + const [weightInput, setWeightInput] = useState(''); const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false); const [preselectedActivities, setPreselectedActivities] = useState([]); const [savedNotice, setSavedNotice] = useState(); - const [errorNotice, setErrorNotice] = useState(); + /** Jídlo, jehož kalorie jde nabídnout ostatním jídlům stejného názvu. */ + const [fillOfferEntry, setFillOfferEntry] = useState(); const loadDay = useCallback(async (forDate: string) => { setLoading(true); @@ -70,6 +76,21 @@ export default function DayPage() { if (response.data) setDay(response.data); }; + useEffect(() => { setWeightInput(formatWeightKgInput(day.weightGrams)); }, [day.date, day.weightGrams]); + + /** Uloží vážení při opuštění pole; prázdné pole vážení dne smaže. */ + const commitWeight = async () => { + const grams = parseWeightKgInput(weightInput); + if ((grams ?? null) === (day.weightGrams ?? null)) return; + const response = await saveWeight({ body: { date, ...(grams != null ? { weightGrams: grams } : {}) } }); + if (response.data) { + setDay(response.data); + } else { + // Neplatnou hodnotu vrátíme na uloženou, ať pole nelže + setWeightInput(formatWeightKgInput(day.weightGrams)); + } + }; + const isToday = date === formatDate(new Date()); /** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */ @@ -94,17 +115,6 @@ export default function DayPage() { setTimeout(() => setSavedNotice(undefined), 3000); }; - const saveMealAsTemplate = async (entry: MealEntry) => { - setErrorNotice(undefined); - const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } }); - if (response.data) { - showNotice(`„${entry.name}" uloženo jako šablonu jídla.`); - } else { - // Typicky recept stejného názvu, který se ze záznamu dne přepsat nesmí - setErrorNotice((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit'); - } - }; - const changeMealMultiplier = async (entry: MealEntry, multiplier: number) => { const response = await setMealMultiplier({ body: { id: entry.id, date: entry.date, multiplier } }); if (response.data) setDay(response.data); @@ -132,8 +142,8 @@ export default function DayPage() {

Můj den

{tab === DayTab.PRIJEM ? (
-
@@ -236,13 +250,35 @@ export default function DayPage() { {day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
+
+
Vážení
+
+ setWeightInput(event.target.value)} + onBlur={() => commitWeight()} + /> + kg +
+
vývoj na stránce Profil
+
{savedNotice && {savedNotice}} - {errorNotice && ( - setErrorNotice(undefined)}> - {errorNotice} - + {fillOfferEntry && ( + { + setFillOfferEntry(undefined); + showNotice(count === 1 ? 'Kalorie doplněny do 1 dalšího jídla.' : `Kalorie doplněny do ${count} dalších jídel.`); + loadDay(date); + }} + onDismiss={() => setFillOfferEntry(undefined)} + /> )}
@@ -279,6 +315,9 @@ export default function DayPage() {

)} + {day.meals.totalNutrients && ( + + )} {mealGroups.map(group => (

@@ -298,7 +337,6 @@ export default function DayPage() { entry={entry} onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }} onDelete={() => removeMeal(entry)} - onSaveTemplate={() => saveMealAsTemplate(entry)} onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)} /> ))} @@ -341,8 +379,10 @@ export default function DayPage() { date={date} entry={editedMeal} onClose={() => setMealModalOpen(false)} - onSaved={saved => { + onSaved={(saved, savedEntry) => { setMealModalOpen(false); + const hasNutrition = savedEntry?.calories != null || !!savedEntry?.nutrientsPer100g; + setFillOfferEntry(hasNutrition ? savedEntry : undefined); // Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den if (saved.date !== date) { setDate(saved.date); @@ -380,12 +420,12 @@ export default function DayPage() { /> setMealTemplateModalOpen(false)} - onApplied={saved => { - setDay(saved); - setMealTemplateModalOpen(false); + onClose={() => setMealHistoryModalOpen(false)} + onApplied={() => { + setMealHistoryModalOpen(false); + loadDay(date); }} /> diff --git a/client/src/pages/MealTemplatesPanel.tsx b/client/src/pages/MealTemplatesPanel.tsx index ddf6c27..c75059f 100644 --- a/client/src/pages/MealTemplatesPanel.tsx +++ b/client/src/pages/MealTemplatesPanel.tsx @@ -1,18 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBowlFood } from "@fortawesome/free-solid-svg-icons"; +import { faBookOpen } from "@fortawesome/free-solid-svg-icons"; import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types"; -import { MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES } from "../enums"; import MealTemplateCard from "../components/MealTemplateCard"; import MealTemplateForm from "../components/MealTemplateForm"; /** - * Panel se správou šablon jídel. - * - * Šablony jsou dvojího druhu: hotová jídla (objednané, koupené — pamatují si - * výsledné hodnoty) a recepty (vařené — nesou suroviny a postup a hodnoty porce - * se z nich dopočtou). Na rozdíl od šablon tréninků se neskládají z jiných. + * Panel se správou receptů — vařených jídel se surovinami a postupem, ze kterých + * se hodnoty porce dopočtou. Objednaná a koupená jídla šablony nemají, při + * zakládání jídla se nabízejí z historie. */ const MealTemplatesPanel = () => { const [templates, setTemplates] = useState([]); @@ -46,17 +43,12 @@ const MealTemplatesPanel = () => { globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }; - const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER - .map(kind => ({ kind, templates: templates.filter(template => template.kind === kind) })) - .filter(group => group.templates.length > 0); - return ( <>

- Šablona jídla se v přehledu dne založí jedním kliknutím, nebo se nabídne, když začnete - psát název jídla. Hotové jídlo je objednávka nebo koupená věc, u které si - jen pamatujete kalorie a cenu. Recept je vařené jídlo — zapíšete suroviny - a postup a kalorie porce se spočítají samy. + Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy. + V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát + název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie.

{loading && ( @@ -67,25 +59,20 @@ const MealTemplatesPanel = () => { {!loading && !templates.length && (
-
-
Zatím nemáte žádnou šablonu jídla
-

Sestavte si ji ve formuláři níže.

+
+
Zatím nemáte žádný recept
+

Zapište si ho ve formuláři níže.

)} - {!loading && groupList.map(group => ( -
-

{MEAL_TEMPLATE_KIND_NAMES[group.kind]}

- {group.templates.map(template => ( - edit(template)} - onDelete={() => remove(template.id)} - /> - ))} -
+ {!loading && templates.map(template => ( + edit(template)} + onDelete={() => remove(template.id)} + /> ))} { + if (grams === 0) return 'udržovat hmotnost'; + const kg = (grams / 1000).toLocaleString('cs-CZ', { maximumFractionDigits: 2 }); + const perDay = formatBalance(Math.round(grams / 1000 * 7700 / 7)); + return `${grams > 0 ? '+' : ''}${kg.replace('-', '−')} kg týdně (${perDay}/den)`; +}; /** - * Osobní údaje a klidový výdej. + * Profil — tělesné údaje, ukazatele z nich, vývoj hmotnosti a cíle. * - * Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá bazální - * metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní - * hodnotu, může ji zadat ručně a ta má přednost. + * Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá + * klidový výdej, který vstupuje do bilance dne; z výšky a obvodů BMI a odhad + * podílu tuku. Hmotnost se bere z posledního vážení. Kdo má změřený vlastní + * klidový výdej, může ho zadat ručně a ten má přednost. */ const ProfilePage = () => { const [settings, setSettings] = useState(); @@ -19,7 +33,12 @@ const ProfilePage = () => { const [sex, setSex] = useState(''); const [birthYear, setBirthYear] = useState(''); const [heightCm, setHeightCm] = useState(''); - const [weightKg, setWeightKg] = useState(''); + const [waistCm, setWaistCm] = useState(''); + const [neckCm, setNeckCm] = useState(''); + const [hipCm, setHipCm] = useState(''); + const [weeklyChange, setWeeklyChange] = useState(''); + const [targetWeightKg, setTargetWeightKg] = useState(''); + const [proteinPerKg, setProteinPerKg] = useState(''); const [manualBasal, setManualBasal] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(); @@ -27,12 +46,18 @@ const ProfilePage = () => { /** Naplní formulář z nastavení vráceného serverem. */ const fillForm = (data?: UserSettings) => { + const intText = (value?: number) => value != null ? String(value) : ''; setSettings(data); setSex(data?.profile?.sex ?? ''); - setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : ''); - setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : ''); - setWeightKg(formatWeightKgInput(data?.profile?.weightGrams)); - setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : ''); + setBirthYear(intText(data?.profile?.birthYear)); + setHeightCm(intText(data?.profile?.heightCm)); + setWaistCm(intText(data?.profile?.waistCm)); + setNeckCm(intText(data?.profile?.neckCm)); + setHipCm(intText(data?.profile?.hipCm)); + setWeeklyChange(intText(data?.goals?.weeklyChangeGrams)); + setTargetWeightKg(formatWeightKgInput(data?.goals?.targetWeightGrams)); + setProteinPerKg(data?.goals?.proteinPerKg != null ? String(data.goals.proteinPerKg).replace('.', ',') : ''); + setManualBasal(intText(data?.basalCalories)); }; const load = useCallback(async () => { @@ -44,22 +69,48 @@ const ProfilePage = () => { useEffect(() => { load(); }, [load]); + /** Po změně vážení se jen přenačtou ukazatele, rozepsaný formulář zůstane. */ + const reloadMetrics = async () => { + const response = await getSettings(); + if (response.data) setSettings(response.data); + }; + const saveAll = async () => { setSaving(true); setError(undefined); setSaved(false); + const errorOf = (response: { error?: unknown }, fallback: string) => + (response.error as { error?: string })?.error ?? fallback; const profileResponse = await saveProfile({ body: { ...(sex ? { sex: sex as Pohlavi } : {}), ...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}), ...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}), - ...(parseWeightKgInput(weightKg) != null ? { weightGrams: parseWeightKgInput(weightKg) } : {}), + ...(parseIntInput(waistCm) != null ? { waistCm: parseIntInput(waistCm) } : {}), + ...(parseIntInput(neckCm) != null ? { neckCm: parseIntInput(neckCm) } : {}), + ...(parseIntInput(hipCm) != null ? { hipCm: parseIntInput(hipCm) } : {}), + // Hmotnost z doby před vážením po dnech se nepřepisuje, dokud se vážit nezačne + ...(settings?.profile?.weightGrams != null ? { weightGrams: settings.profile.weightGrams } : {}), }, }); if (!profileResponse.data) { setSaving(false); - setError((profileResponse.error as { error?: string })?.error ?? 'Údaje se nepodařilo uložit'); + setError(errorOf(profileResponse, 'Údaje se nepodařilo uložit')); + return; + } + + const goalsResponse = await saveGoals({ + body: { + ...(weeklyChange !== '' ? { weeklyChangeGrams: Number(weeklyChange) } : {}), + ...(parseWeightKgInput(targetWeightKg) != null + ? { targetWeightGrams: parseWeightKgInput(targetWeightKg) } : {}), + ...(parseMultiplierInput(proteinPerKg) != null ? { proteinPerKg: parseMultiplierInput(proteinPerKg) } : {}), + }, + }); + if (!goalsResponse.data) { + setSaving(false); + setError(errorOf(goalsResponse, 'Cíle se nepodařilo uložit')); return; } @@ -67,14 +118,14 @@ const ProfilePage = () => { const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } }); setSaving(false); if (!basalResponse.data) { - setError((basalResponse.error as { error?: string })?.error ?? 'Klidový výdej se nepodařilo uložit'); + setError(errorOf(basalResponse, 'Klidový výdej se nepodařilo uložit')); return; } fillForm(basalResponse.data); setSaved(true); }; - if (loading) { + if (loading || !settings) { return (
@@ -82,25 +133,22 @@ const ProfilePage = () => { ); } - const computed = settings?.computedBasalCalories; - const effective = settings?.effectiveBasalCalories; - const manualWins = settings?.basalCalories != null; + const isFemale = sex === Pohlavi.ZENA; return ( <>
-

Osobní údaje

+

Profil

-

- Z těchto údajů se spočítá klidový výdej (bazální metabolismus) rovnicí - Mifflin–St Jeor. Ten pak v přehledu dne tvoří základ výdeje, ke kterému - se přičítá zaznamenaný pohyb. -

+ -
+ reloadMetrics()} /> + +
+

Tělesné údaje

-
+
Pohlaví setSex(event.target.value)}> @@ -109,7 +157,7 @@ const ProfilePage = () => { ))}
-
+
Rok narození { onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))} />
-
+
Výška
{ cm
+
+ +

Obvody pro odhad podílu tuku

+
+
+ Pas +
+ setWaistCm(event.target.value.replace(/\D/g, ''))} + /> + cm +
+ {isFemale ? 'v nejužším místě' : 'v úrovni pupku'} +
+
+ Krk +
+ setNeckCm(event.target.value.replace(/\D/g, ''))} + /> + cm +
+ pod ohryzkem +
+
+ Boky +
+ setHipCm(event.target.value.replace(/\D/g, ''))} + /> + cm +
+ v nejširším místě; do výpočtu jen u žen +
+
+
+ +
+

Cíle

+
+
+ Tempo + setWeeklyChange(event.target.value)}> + + {WEEKLY_CHANGE_OPTIONS.map(grams => ( + + ))} + + v přehledu dne se bilance porovná s cílem +
- Hmotnost + Cílová hmotnost
setWeightKg(event.target.value)} + value={targetWeightKg} + placeholder="85,0" + onChange={event => setTargetWeightKg(event.target.value)} /> kg
-
-
- -
-
-
Spočítaný klidový výdej
-
{formatCalories(computed)}
-
- {computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'} -
-
-
-
Použije se v bilanci
-
{formatCalories(effective)}
-
- {manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')} +
+ Bílkoviny +
+ setProteinPerKg(event.target.value)} + /> + g / kg +
+ běžně 1,2–2 g na kg, při hubnutí a silovém tréninku víc
-
+

Ruční klidový výdej

Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou. @@ -173,7 +276,8 @@ const ProfilePage = () => { setManualBasal(event.target.value.replace(/\D/g, ''))} /> kcal / den @@ -185,14 +289,15 @@ const ProfilePage = () => { {saved && !error && Uloženo.}

- Poznámka k výpočtu: klidový výdej je hodnota za celých 24 hodin včetně spánku. + Poznámka k výpočtům: klidový výdej je hodnota za celých 24 hodin včetně spánku. Záměrně se nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky TDEE — pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení - by ho započítalo dvakrát. + by ho započítalo dvakrát. Tempo počítá s 7 700 kcal na kilogram; skutečná změna + hmotnosti kolísá s vodou, proto se vyplatí sledovat spíš trend než jednotlivá vážení.

); diff --git a/client/src/pages/TemplatesPage.tsx b/client/src/pages/TemplatesPage.tsx index 6d9a0c1..98a08f0 100644 --- a/client/src/pages/TemplatesPage.tsx +++ b/client/src/pages/TemplatesPage.tsx @@ -1,20 +1,25 @@ import { useState } from "react"; +import { useLocation } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faDumbbell, faBowlFood } from "@fortawesome/free-solid-svg-icons"; +import { faDumbbell, faBookOpen } from "@fortawesome/free-solid-svg-icons"; import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums"; import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel"; import MealTemplatesPanel from "./MealTemplatesPanel"; import "./TemplatesPage.scss"; /** - * Šablony — tréninky a jídla vedle sebe. + * Šablony — tréninky a recepty vedle sebe. * * Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají - * jinou strukturu (tréninky se skládají z jiných šablon, jídla ne), proto mají - * vlastní panely a společné jen záložky. + * jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin), + * proto mají vlastní panely a společné jen záložky. Hotová jídla šablony + * nemají, nabízejí se z historie. */ const TemplatesPage = () => { - const [tab, setTab] = useState(TemplateTab.TRENINKY); + // Odkaz může rovnou otevřít konkrétní záložku, třeba recepty z výběru jídla + const location = useLocation(); + const [tab, setTab] = useState(() => + (location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY); return ( <> @@ -30,10 +35,10 @@ const TemplatesPage = () => { {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
diff --git a/run_dev.ps1 b/run_dev.ps1 index 35a2e57..1b169a1 100644 --- a/run_dev.ps1 +++ b/run_dev.ps1 @@ -1,6 +1,10 @@ # Spustí vývojové prostředí — server na 3001, klienta na 3000, každý ve svém okně. +$ErrorActionPreference = 'Stop' $root = $PSScriptRoot +Push-Location (Join-Path $root 'types') +try { yarn openapi-ts } finally { Pop-Location } + Start-Process powershell -ArgumentList @( '-NoExit', '-Command', "Set-Location '$root\server'; `$env:NODE_ENV='development'; yarn startReload" diff --git a/run_dev.sh b/run_dev.sh index 332818b..08061c2 100644 --- a/run_dev.sh +++ b/run_dev.sh @@ -10,6 +10,8 @@ if ! command -v tmux &> /dev/null; then exit 1 fi +cd types && yarn openapi-ts && cd .. + SESSION=food-tracer tmux kill-session -t $SESSION 2>/dev/null || true tmux new-session -d -s $SESSION -n dev diff --git a/server/dist/server/src/activities.js b/server/dist/server/src/activities.js new file mode 100644 index 0000000..6ffee5c --- /dev/null +++ b/server/dist/server/src/activities.js @@ -0,0 +1,289 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getTotalQuantity = void 0; +exports.isActivityUnit = isActivityUnit; +exports.buildActivityDay = buildActivityDay; +exports.getActivityDay = getActivityDay; +exports.deriveActivityCalories = deriveActivityCalories; +exports.normalizeActivityInput = normalizeActivityInput; +exports.addActivity = addActivity; +exports.addActivities = addActivities; +exports.updateActivity = updateActivity; +exports.deleteActivity = deleteActivity; +exports.setActivityMultiplier = setActivityMultiplier; +const crypto_1 = require("crypto"); +const types_gen_1 = require("../../types/gen/types.gen"); +const storage_1 = __importDefault(require("./storage")); +const utils_1 = require("./utils"); +const workoutPlans_1 = require("./workoutPlans"); +const storage = (0, storage_1.default)(); +/** Maximální délka textových polí. */ +const MAX_TEXT_LENGTH = 500; +/** Podporované jednotky aktivit. */ +const UNITS = [ + types_gen_1.ActivityUnit.KROKY, + types_gen_1.ActivityUnit.MINUTY, + types_gen_1.ActivityUnit.SEKUNDY, + types_gen_1.ActivityUnit.KM, + types_gen_1.ActivityUnit.OPAKOVANI, +]; +/** Vrátí true, pokud je hodnota podporovanou jednotkou aktivity. */ +function isActivityUnit(value) { + return typeof value === 'string' && UNITS.includes(value); +} +/** Klíč úložiště s aktivitami jednoho dne jednoho uživatele. */ +function getDayKey(login, date) { + return `activities:${login}:${date}`; +} +/** Sestaví přehled pohybu dne včetně součtu spálených kcal. */ +function buildActivityDay(date, entries) { + const sorted = [...entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return { + date, + entries: sorted, + totalCalories: sorted.reduce((sum, entry) => sum + ((0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0), 0), + }; +} +/** + * Vrátí pohyb uživatele za jeden den. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +async function getActivityDay(login, date) { + const entries = await storage.getData(getDayKey(login, date)); + return buildActivityDay(date, entries ?? []); +} +/** Ořízne a znormalizuje volitelný textový vstup. */ +function normalizeText(value, name) { + if (value == null) + return undefined; + if (typeof value !== 'string') { + throw new utils_1.BadRequestError(`Pole '${name}' musí být text`); + } + const trimmed = value.trim(); + if (!trimmed.length) + return undefined; + if (trimmed.length > MAX_TEXT_LENGTH) { + throw new utils_1.BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`); + } + return trimmed; +} +/** Ověří volitelnou nezápornou celočíselnou hodnotu. */ +function normalizeNumber(value, name) { + if (value == null) + return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`); + } + const rounded = Math.round(value); + if (rounded < 0) { + throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`); + } + return rounded; +} +/** + * Vrátí celkové množství aktivity, tedy počet sérií krát množství v jedné. + * + * Série se drží odděleně, protože 3 × 40 opakování s pauzami je jiný trénink + * než 120 opakování v kuse — spálené kalorie jsou ale stejné, takže do výpočtu + * jde součin. + */ +const getTotalQuantity = (quantity, sets) => quantity * Math.max(sets ?? 1, 1); +exports.getTotalQuantity = getTotalQuantity; +/** + * Dopočte spálené kalorie z množství a sazby na 100 jednotek. + * + * Sto jednotek místo jedné proto, že u kroků by sazba na jeden krok byla + * zlomek (~0,04 kcal) a celá aplikace pracuje s celými čísly. + */ +function deriveActivityCalories(input) { + const calories = input.caloriesPer100Units + ? Math.round((0, exports.getTotalQuantity)(input.quantity, input.sets) / 100 * input.caloriesPer100Units) + : input.calories; + return { ...input, calories }; +} +/** Ověří a znormalizuje vstupní data aktivity. */ +function normalizeActivityInput(input) { + const value = (input ?? {}); + if (!(0, utils_1.isValidIsoDate)(value.date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (!isActivityUnit(value.unit)) { + throw new utils_1.BadRequestError("Nebyla předána platná jednotka aktivity"); + } + const name = normalizeText(value.name, 'name'); + if (!name) { + throw new utils_1.BadRequestError("Nebyl předán název aktivity"); + } + const quantity = normalizeNumber(value.quantity, 'quantity'); + if (quantity == null) { + throw new utils_1.BadRequestError("Nebylo předáno množství aktivity"); + } + const sets = normalizeNumber(value.sets, 'sets'); + return deriveActivityCalories({ + date: value.date, + name, + unit: value.unit, + quantity, + // Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl + ...(sets && sets > 1 ? { sets } : {}), + caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'), + calories: normalizeNumber(value.calories, 'calories'), + note: normalizeText(value.note, 'note'), + extraWeightGrams: normalizeNumber(value.extraWeightGrams, 'extraWeightGrams') || undefined, + multiplier: (0, utils_1.normalizeMultiplier)(value.multiplier), + }); +} +/** + * Přidá aktivitu do zvoleného dne. + * + * @param login přihlašovací jméno uživatele + * @param input data aktivity + * @returns datum, do kterého se aktivita založila + */ +async function addActivity(login, input) { + const normalized = normalizeActivityInput(input); + const entry = { + id: (0, crypto_1.randomUUID)(), + createdAt: new Date().toISOString(), + ...normalized, + }; + await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), entry]); + return normalized.date; +} +/** + * Uloží najednou více aktivit do jednoho dne (používá aplikace šablony). + * + * @param login přihlašovací jméno uživatele + * @param date den, do kterého se aktivity založí + * @param entries aktivity k uložení + */ +async function addActivities(login, date, entries) { + if (!entries.length) + return; + await storage.updateData(getDayKey(login, date), current => [...(current ?? []), ...entries]); +} +/** + * Upraví existující aktivitu. Změna data ji přesune do jiného dne. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor upravovaného záznamu + * @param input nová data aktivity + * @returns datum, ve kterém aktivita po úpravě je + */ +async function updateActivity(login, id, input) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + const normalized = normalizeActivityInput(input); + const originalDate = await findActivityDate(login, id, normalized.date); + if (!originalDate) { + throw new utils_1.NotFoundError("Aktivita nebyla nalezena"); + } + let original; + const remaining = await storage.updateData(getDayKey(login, originalDate), current => { + original = (current ?? []).find(entry => entry.id === id); + return (current ?? []).filter(entry => entry.id !== id); + }); + if (!original) { + throw new utils_1.NotFoundError("Aktivita nebyla nalezena"); + } + // Volitelná pole se přepisují včetně vymazání, proto se berou z normalizovaného vstupu + const updated = { + ...original, + ...normalized, + sets: normalized.sets, + caloriesPer100Units: normalized.caloriesPer100Units, + calories: normalized.calories, + note: normalized.note, + extraWeightGrams: normalized.extraWeightGrams, + multiplier: normalized.multiplier, + updatedAt: new Date().toISOString(), + }; + await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), updated]); + if (originalDate !== normalized.date) { + await (0, workoutPlans_1.dropOrphanedPlans)(login, originalDate, remaining); + } + return normalized.date; +} +/** + * Smaže aktivitu. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor mazaného záznamu + * @param date datum záznamu (YYYY-MM-DD) + */ +async function deleteActivity(login, id, date) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + let found = false; + const remaining = await storage.updateData(getDayKey(login, date), current => { + found = (current ?? []).some(entry => entry.id === id); + return (current ?? []).filter(entry => entry.id !== id); + }); + if (!found) { + throw new utils_1.NotFoundError("Aktivita nebyla nalezena"); + } + await (0, workoutPlans_1.dropOrphanedPlans)(login, date, remaining); + return date; +} +/** + * Nastaví násobek aktivity přímo z přehledu dne. Ostatní údaje zůstanou beze změny. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor záznamu + * @param date datum záznamu (YYYY-MM-DD) + * @param multiplier nový násobek; 1 násobek zruší + * @returns datum, ve kterém aktivita je + */ +async function setActivityMultiplier(login, id, date, multiplier) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (multiplier == null) { + throw new utils_1.BadRequestError("Nebyl předán násobek"); + } + const normalized = (0, utils_1.normalizeMultiplier)(multiplier); + let found = false; + await storage.updateData(getDayKey(login, date), current => (current ?? []).map(entry => { + if (entry.id !== id) + return entry; + found = true; + const updated = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() }; + if (normalized == null) + delete updated.multiplier; + return updated; + })); + if (!found) { + throw new utils_1.NotFoundError("Aktivita nebyla nalezena"); + } + return date; +} +/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */ +async function findActivityDate(login, id, expectedDate) { + const expected = await storage.getData(getDayKey(login, expectedDate)); + if (expected?.some(entry => entry.id === id)) { + return expectedDate; + } + const prefix = `activities:${login}:`; + for (const key of await storage.listKeys(prefix)) { + if (!key.startsWith(prefix)) + continue; + const entries = await storage.getData(key); + if (entries?.some(entry => entry.id === id)) { + return key.substring(prefix.length); + } + } + return undefined; +} diff --git a/server/dist/server/src/auth.js b/server/dist/server/src/auth.js new file mode 100644 index 0000000..e281948 --- /dev/null +++ b/server/dist/server/src/auth.js @@ -0,0 +1,61 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generateToken = generateToken; +exports.verify = verify; +exports.getLogin = getLogin; +const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); +/** + * Vygeneruje a vrátí podepsaný JWT token pro daný login. + * + * @param login přihlašovací jméno uživatele + * @param trusted příznak, zda se jedná o ověřeného uživatele + * @returns JWT token + */ +function generateToken(login, trusted) { + if (!process.env.JWT_SECRET) { + throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); + } + if (process.env.JWT_SECRET.length < 32) { + throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků"); + } + if (!login || login.trim().length === 0) { + throw new Error("Nebyl předán login"); + } + const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL }; + return jsonwebtoken_1.default.sign(payload, process.env.JWT_SECRET); +} +/** + * Vrátí true, pokud je předaný JWT token platný. + * + * @param token JWT token + */ +function verify(token) { + if (!process.env.JWT_SECRET) { + throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); + } + try { + jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET); + return true; + } + catch { + return false; + } +} +/** + * Vrátí login z daného JWT tokenu, pokud je token platný. + * + * @param token JWT token + */ +function getLogin(token) { + if (!process.env.JWT_SECRET) { + throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); + } + if (!token) { + throw new Error("Nebyl předán token"); + } + const payload = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET); + return payload.login; +} diff --git a/server/dist/server/src/calorieProvider.js b/server/dist/server/src/calorieProvider.js new file mode 100644 index 0000000..f11fbad --- /dev/null +++ b/server/dist/server/src/calorieProvider.js @@ -0,0 +1,78 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NoopProvider = exports.OpenFoodFactsProvider = void 0; +exports.getCalorieProvider = getCalorieProvider; +/** Timeout dotazu na poskytovatele. Hledání kalorií nesmí blokovat zadávání jídla. */ +const REQUEST_TIMEOUT_MS = 6000; +/** + * Open Food Facts jako zdroj energetických hodnot. + * + * Používá `search.openfoodfacts.org`, ne hlavní `world.openfoodfacts.org` — + * tamní `/cgi/search.pl` i `/api/v2/search` vrací kvůli zátěži 503, zatímco + * samostatná vyhledávací služba odpovídá. Data jsou CC, klíč není potřeba. + */ +class OpenFoodFactsProvider { + name = 'openfoodfacts'; + baseUrl = 'https://search.openfoodfacts.org/search'; + async search(query, limit) { + // Poskytovatel vrací i produkty bez energetické hodnoty, tak si jich vyžádáme víc + const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&page_size=${limit * 4}`; + try { + const response = await fetch(url, { + headers: { 'User-Agent': 'FoodTracer (osobni projekt)' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + console.warn(`Open Food Facts odpověděl ${response.status}`); + return { unavailable: true }; + } + const body = await response.json(); + return { unavailable: false, suggestions: this.toSuggestions(body.hits ?? [], limit) }; + } + catch (error) { + console.warn('Open Food Facts není dostupný:', error.message); + return { unavailable: true }; + } + } + /** Vybere z odpovědi produkty s použitelnou energetickou hodnotou. */ + toSuggestions(hits, limit) { + const suggestions = []; + for (const hit of hits) { + const product = hit; + const kcal = product.nutriments?.['energy-kcal_100g']; + const name = typeof product.product_name === 'string' ? product.product_name.trim() : ''; + if (!name.length || typeof kcal !== 'number' || !Number.isFinite(kcal) || kcal <= 0) { + continue; + } + suggestions.push({ + name, + caloriesPer100g: Math.round(kcal), + origin: this.name, + ...(Array.isArray(product.brands) && typeof product.brands[0] === 'string' + ? { brand: product.brands[0] } + : {}), + }); + if (suggestions.length >= limit) + break; + } + return suggestions; + } +} +exports.OpenFoodFactsProvider = OpenFoodFactsProvider; +/** Poskytovatel, který nic nenajde. Používá se, když je externí vyhledávání vypnuté. */ +class NoopProvider { + name = 'none'; + search() { + return Promise.resolve({ unavailable: false, suggestions: [] }); + } +} +exports.NoopProvider = NoopProvider; +/** + * Vrátí poskytovatele dle konfigurace. + * `CALORIE_PROVIDER=none` vypne externí dotazy úplně (offline provoz, testy). + */ +function getCalorieProvider() { + return process.env.CALORIE_PROVIDER?.toLowerCase() === 'none' + ? new NoopProvider() + : new OpenFoodFactsProvider(); +} diff --git a/server/dist/server/src/calories.js b/server/dist/server/src/calories.js new file mode 100644 index 0000000..32dabd6 --- /dev/null +++ b/server/dist/server/src/calories.js @@ -0,0 +1,128 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizeName = normalizeName; +exports.rememberCalories = rememberCalories; +exports.searchCalories = searchCalories; +const storage_1 = __importDefault(require("./storage")); +const calorieProvider_1 = require("./calorieProvider"); +const utils_1 = require("./utils"); +const storage = (0, storage_1.default)(); +/** Označení návrhů pocházejících z vlastní knihovny uživatele. */ +const LIBRARY_ORIGIN = 'library'; +/** Kolik návrhů celkem vrátit. */ +const SUGGESTION_LIMIT = 8; +/** Kolik z nich smí obsadit vlastní knihovna — zbytek zůstane na poskytovatele. */ +const LIBRARY_LIMIT = 3; +/** + * Tabulka potravin na KalorickéTabulky.cz. + * + * Odkaz se uživateli jen otevře v novém panelu, nic se odtud nestahuje — veřejné + * API neexistuje, jejich robots.txt vyhledávání robotům zakazuje a smluvní + * podmínky omezují užití nad rámec zamýšleného účelu. + * + * Vede na tabulku potravin bez předvyplněného hledání: jejich vyhledávání běží + * v JavaScriptu a nedá se nastavit z URL (ověřeno — parametry v query stringu + * stránka ignoruje). Název jídla proto klient zkopíruje do schránky, aby ho + * uživatel jen vložil do jejich vyhledávacího pole. + */ +const EXTERNAL_SEARCH_BASE = 'https://www.kaloricketabulky.cz/tabulka-potravin'; +/** Klíč úložiště s knihovnou energetických hodnot jednoho uživatele. */ +function getLibraryKey(login) { + return `calorieLibrary:${login}`; +} +/** + * Znormalizuje název jídla na klíč knihovny — bez diakritiky, malými písmeny + * a bez interpunkce, aby si "Kuřecí gyros" a "kureci gyros!" odpovídaly. + */ +function normalizeName(name) { + return name + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} +/** + * Zapamatuje si energetickou hodnotu pod názvem jídla, aby ji šlo příště nabídnout. + * Volá se při uložení jídla, které má vyplněné caloriesPer100g. + * + * @param login přihlašovací jméno uživatele + * @param name název jídla + * @param caloriesPer100g energetická hodnota na 100 g v kcal + */ +async function rememberCalories(login, name, caloriesPer100g) { + const key = normalizeName(name); + if (!key.length || !Number.isFinite(caloriesPer100g) || caloriesPer100g <= 0) + return; + await storage.updateData(getLibraryKey(login), current => { + const library = current ?? {}; + const existing = library[key]; + return { + ...library, + [key]: { + name: name.trim(), + caloriesPer100g: Math.round(caloriesPer100g), + // Opakované použití stejné hodnoty ji posouvá výš mezi návrhy + uses: (existing?.uses ?? 0) + 1, + updatedAt: new Date().toISOString(), + }, + }; + }); +} +/** Najde v knihovně záznamy, jejichž název odpovídá hledanému výrazu. */ +async function searchLibrary(login, query) { + const library = await storage.getData(getLibraryKey(login)) ?? {}; + const needle = normalizeName(query); + if (!needle.length) + return []; + return Object.entries(library) + .filter(([key]) => key === needle || key.includes(needle) || needle.includes(key)) + // Přesná shoda první, pak nejpoužívanější + .sort(([keyA, a], [keyB, b]) => Number(keyB === needle) - Number(keyA === needle) || b.uses - a.uses) + .slice(0, LIBRARY_LIMIT) + .map(([, entry]) => ({ + name: entry.name, + caloriesPer100g: entry.caloriesPer100g, + origin: LIBRARY_ORIGIN, + })); +} +/** + * Najde návrhy energetické hodnoty pro název jídla. + * + * Nejdřív se hledá ve vlastní knihovně (dřívější zadání uživatele sedí na + * kantýnová jídla líp než veřejná databáze balených potravin), teprve pak se + * doplní návrhy od poskytovatele. Když je poskytovatel nedostupný, vrátí se + * jen knihovna a příznak `providerAvailable: false`. + * + * @param login přihlašovací jméno uživatele + * @param query hledaný název jídla + */ +async function searchCalories(login, query) { + if (typeof query !== 'string' || !query.trim().length) { + throw new utils_1.BadRequestError("Nebyl předán hledaný název"); + } + const trimmed = query.trim(); + const provider = (0, calorieProvider_1.getCalorieProvider)(); + const fromLibrary = await searchLibrary(login, trimmed); + const result = await provider.search(trimmed, SUGGESTION_LIMIT - fromLibrary.length); + const suggestions = [...fromLibrary]; + if (!result.unavailable) { + // Co už nabídla knihovna, nemá smysl opakovat z externího zdroje + const seen = new Set(fromLibrary.map(item => normalizeName(item.name))); + for (const suggestion of result.suggestions) { + if (seen.has(normalizeName(suggestion.name))) + continue; + seen.add(normalizeName(suggestion.name)); + suggestions.push(suggestion); + } + } + return { + query: trimmed, + providerAvailable: !result.unavailable, + providerName: provider.name, + suggestions, + externalSearchUrl: EXTERNAL_SEARCH_BASE, + }; +} diff --git a/server/dist/server/src/dayOverview.js b/server/dist/server/src/dayOverview.js new file mode 100644 index 0000000..dcbd1da --- /dev/null +++ b/server/dist/server/src/dayOverview.js @@ -0,0 +1,58 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getDayOverview = exports.buildEnergyBalance = void 0; +const meals_1 = require("./meals"); +const activities_1 = require("./activities"); +const settings_1 = require("./settings"); +const sleep_1 = require("./sleep"); +/** + * Spočítá energetickou bilanci dne. + * + * Výdej je součet klidového metabolismu a pohybu. Bez nastaveného klidového + * výdeje porovnává bilance jen jídlo proti pohybu — to není skutečný deficit, + * proto se to příznakem `hasBasal` propisuje do UI. + * + * Délka spánku do bilance **nevstupuje**: klidový výdej je hodnota za celých + * 24 hodin včetně spánku a žádná ze standardních rovnic (Mifflin–St Jeor, + * Harris–Benedict) délku spánku jako proměnnou nemá. + * + * @param intake přijaté kcal z jídla + * @param activityBurn spálené kcal pohybem + * @param basal klidový výdej v kcal za den, nebo nic + */ +const buildEnergyBalance = (intake, activityBurn, basal) => { + const resolvedBasal = basal ?? 0; + const totalBurn = resolvedBasal + activityBurn; + return { + intake, + activityBurn, + basal: resolvedBasal, + totalBurn, + balance: intake - totalBurn, + hasBasal: !!basal, + }; +}; +exports.buildEnergyBalance = buildEnergyBalance; +/** + * Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +const getDayOverview = async (login, date) => { + const [meals, activities, settings, sleepMinutes] = await Promise.all([ + (0, meals_1.getDay)(login, date), + (0, activities_1.getActivityDay)(login, date), + (0, settings_1.getSettings)(login), + (0, sleep_1.getSleep)(login, date), + ]); + return { + date, + meals, + activities, + // Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů + energy: (0, exports.buildEnergyBalance)(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories), + ...(sleepMinutes ? { sleepMinutes } : {}), + }; +}; +exports.getDayOverview = getDayOverview; diff --git a/server/dist/server/src/index.js b/server/dist/server/src/index.js new file mode 100644 index 0000000..32963df --- /dev/null +++ b/server/dist/server/src/index.js @@ -0,0 +1,198 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.app = void 0; +const express_1 = __importDefault(require("express")); +const cors_1 = __importDefault(require("cors")); +const dotenv_1 = __importDefault(require("dotenv")); +const path_1 = __importDefault(require("path")); +const auth_1 = require("./auth"); +const utils_1 = require("./utils"); +const storage_1 = __importStar(require("./storage")); +const redis_1 = require("./storage/redis"); +const mealRoutes_1 = __importDefault(require("./routes/mealRoutes")); +const statsRoutes_1 = __importDefault(require("./routes/statsRoutes")); +const importRoutes_1 = __importDefault(require("./routes/importRoutes")); +const calorieRoutes_1 = __importDefault(require("./routes/calorieRoutes")); +const settingsRoutes_1 = __importDefault(require("./routes/settingsRoutes")); +const activityRoutes_1 = __importDefault(require("./routes/activityRoutes")); +const workoutRoutes_1 = __importDefault(require("./routes/workoutRoutes")); +const dayRoutes_1 = __importDefault(require("./routes/dayRoutes")); +const mealTemplateRoutes_1 = __importDefault(require("./routes/mealTemplateRoutes")); +const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; +dotenv_1.default.config({ path: path_1.default.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); +if (!process.env.JWT_SECRET) { + throw new Error("Není vyplněna proměnná prostředí JWT_SECRET"); +} +exports.app = (0, express_1.default)(); +const server = require("http").createServer(exports.app); +// Keep-alive delší než idle timeout reverzní proxy; headersTimeout musí být větší než keepAliveTimeout +server.keepAliveTimeout = 65_000; +server.headersTimeout = 66_000; +server.requestTimeout = 30_000; +// Importovaný XLSX chodí v těle jako Base64, výchozí limit 100 kB by na měsíční přehled nestačil +exports.app.use(express_1.default.json({ limit: '15mb' })); +exports.app.use((0, cors_1.default)({ origin: '*' })); +const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true'; +const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; +if (HTTP_REMOTE_USER_ENABLED) { + if (!process.env.HTTP_REMOTE_TRUSTED_IPS) { + throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); + } + exports.app.set('trust proxy', process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim())); + console.log('Zapnutý login přes hlavičky z proxy.'); +} +// ─── Shutdown ───────────────────────────────────────────────────────────────── +let shuttingDown = false; +async function shutdown(signal) { + if (shuttingDown) + return; + shuttingDown = true; + console.log(`${signal} received — initiating graceful shutdown`); + setTimeout(() => { + console.error('Graceful shutdown timed out, forcing exit'); + process.exit(1); + }, 25_000).unref(); + server.closeIdleConnections?.(); + await new Promise(resolve => server.close(() => resolve())); + if (process.env.STORAGE?.toLowerCase() === 'redis') { + await (0, redis_1.shutdownRedisStorage)(); + } + console.log('Graceful shutdown complete'); + process.exit(0); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); +// ─── Routes bez autentizace ─────────────────────────────────────────────────── +/** Liveness probe — levná, bez externích závislostí. */ +exports.app.get("/api/health", (_req, res) => { + res.status(200).json({ ok: true }); +}); +/** Readiness probe — ověří dostupnost úložiště a odmítá provoz při shutdownu. */ +exports.app.get("/api/health/ready", async (_req, res) => { + if (shuttingDown) { + res.status(503).json({ ok: false, reason: 'shutting down' }); + return; + } + const healthy = await (0, storage_1.default)().healthCheck?.() ?? true; + if (!healthy) { + res.status(503).json({ ok: false, reason: 'storage unavailable' }); + return; + } + res.status(200).json({ ok: true }); +}); +/** Veřejná runtime konfigurace pro klienta. */ +exports.app.get("/api/config", (_req, res) => { + res.status(200).json({ + sentry: { + dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null, + environment: ENVIRONMENT, + }, + }); +}); +exports.app.post("/api/login", (req, res, next) => { + try { + if (HTTP_REMOTE_USER_ENABLED) { + const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); + if (!remoteUser?.length) { + throw new Error("Je zapnuto přihlášení přes hlavičky, ale hlavička nedorazila"); + } + res.status(200).json((0, auth_1.generateToken)(Buffer.from(remoteUser, 'latin1').toString(), true)); + return; + } + if (!req.body?.login || req.body.login.trim().length === 0) { + throw new utils_1.BadRequestError("Nebyl předán login"); + } + res.status(200).json((0, auth_1.generateToken)(req.body.login, false)); + } + catch (e) { + next(e); + } +}); +// ─── Autentizace ────────────────────────────────────────────────────────────── +exports.app.use("/api/", (req, res, next) => { + if (!req.headers.authorization) { + res.status(401).json({ error: 'Nebyl předán autentizační token' }); + return; + } + const token = req.headers.authorization.split(' ')[1]; + if (!(0, auth_1.verify)(token)) { + res.status(403).json({ error: 'Neplatný autentizační token' }); + return; + } + next(); +}); +// ─── Routes vyžadující autentizaci ──────────────────────────────────────────── +exports.app.use("/api/meals", mealRoutes_1.default); +exports.app.use("/api/stats", statsRoutes_1.default); +exports.app.use("/api/import", importRoutes_1.default); +exports.app.use("/api/calories", calorieRoutes_1.default); +exports.app.use("/api/settings", settingsRoutes_1.default); +exports.app.use("/api/activities", activityRoutes_1.default); +exports.app.use("/api/workouts", workoutRoutes_1.default); +exports.app.use("/api/day", dayRoutes_1.default); +exports.app.use("/api/mealTemplates", mealTemplateRoutes_1.default); +exports.app.use(express_1.default.static(path_1.default.join(process.cwd(), 'public'))); +exports.app.get('*splat', (_req, res) => { + res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html')); +}); +// Error handling middleware +exports.app.use((err, _req, res, next) => { + if (err instanceof utils_1.BadRequestError) { + res.status(400).send({ error: err.message }); + } + else if (err instanceof utils_1.NotFoundError) { + res.status(404).send({ error: err.message }); + } + else { + console.error(err); + res.status(500).send({ error: err.message }); + } + next(); +}); +// ─── Bootstrap ──────────────────────────────────────────────────────────────── +const PORT = process.env.PORT ?? 3001; +const HOST = process.env.HOST ?? '0.0.0.0'; +// Testy si aplikaci importují a volají přes supertest, server v nich nespouštíme +if (require.main === module) { + storage_1.storageReady.then(() => { + server.listen(PORT, () => { + console.log(`Server listening on ${HOST}, port ${PORT}`); + }); + }); +} diff --git a/server/dist/server/src/luncherImport.js b/server/dist/server/src/luncherImport.js new file mode 100644 index 0000000..accb93a --- /dev/null +++ b/server/dist/server/src/luncherImport.js @@ -0,0 +1,439 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.IMPORT_FORMATS = exports.IMPORT_SOURCE = void 0; +exports.detectFormat = detectFormat; +exports.importLuncherExport = importLuncherExport; +const exceljs_1 = __importDefault(require("exceljs")); +const crypto_1 = require("crypto"); +const types_gen_1 = require("../../types/gen/types.gen"); +const meals_1 = require("./meals"); +const utils_1 = require("./utils"); +/** Označení zdroje, kterým se importované záznamy značí. */ +exports.IMPORT_SOURCE = 'luncher'; +/** Podporované formáty importu (shodné s formáty exportu Luncheru). */ +exports.IMPORT_FORMATS = ['xlsx', 'csv', 'json']; +/** Název listu s jednotlivými záznamy v XLSX exportu. Druhý list ("Souhrn") se ignoruje. */ +const DETAIL_SHEET_NAME = 'Přehled'; +/** Oddělovač polí v CSV exportu — Luncher generuje CSV pro český Excel. */ +const CSV_SEPARATOR = ';'; +/** + * Mapování hlaviček sloupců exportu na klíče. Sloupce se hledají podle názvu, + * ne podle pozice, aby import přežil případné přeuspořádání exportu. + */ +const COLUMN_HEADERS = { + 'datum': 'date', + 'den': 'dayOfWeek', + 'typ': 'type', + 'vybrané jídlo': 'food', + 'poznámka': 'note', + 'objednávka': 'store', + 'objednával': 'orderedBy', + 'částka': 'amount', +}; +/** + * Typy záznamů, které nejsou podnikem — jde o stav volby, ne o místo, kde se jedlo. + * Odpovídají volbám OBJEDNAVAM / ROZHODUJI / NEOBEDVAM a objednávce bez volby + * v exportu Luncheru. Jako zdroj jídla by nedávaly smysl, tak se nepoužijí. + */ +const NON_PLACE_TYPES = new Set([ + 'Budu objednávat', + 'Rozhoduji se', + 'Mám vlastní/neobědvám', + 'Objednávka', +]); +/** + * Rozpozná formát souboru podle přípony. + * + * @param fileName název souboru + */ +function detectFormat(fileName) { + const extension = fileName.toLowerCase().split('.').pop(); + if (extension && exports.IMPORT_FORMATS.includes(extension)) { + return extension; + } + throw new utils_1.BadRequestError(`Nepodporovaný formát souboru '${fileName}'. Podporované jsou .xlsx, .csv a .json.`); +} +/** Ořízne text a prázdnou hodnotu vrátí jako undefined. */ +function text(value) { + if (value == null) + return undefined; + const trimmed = String(value).trim(); + return trimmed.length ? trimmed : undefined; +} +/** + * Převede částku v korunách na haléře. + * Přijímá i český zápis s desetinnou čárkou a mezerami mezi řády ("1 234,50 Kč"). + */ +function parseAmount(value) { + if (value == null || value === '') + return undefined; + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.round(value * 100) : undefined; + } + const cleaned = String(value) + .replace(/\s| /g, '') // mezery i nezlomitelné mezery mezi řády + .replace(/Kč/gi, '') + .replace(',', '.'); + if (!cleaned.length) + return undefined; + const parsed = Number(cleaned); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : undefined; +} +/** + * Převede datum z exportu na ISO formát YYYY-MM-DD. + * Zvládne ISO řetězec (JSON export), český zápis DD.MM.YYYY (CSV) i Date (XLSX). + */ +function parseDate(value) { + if (value == null) + return undefined; + if (value instanceof Date) { + // XLSX ukládá datum v UTC (viz export Luncheru), proto se čte v UTC + const year = value.getUTCFullYear(); + const month = String(value.getUTCMonth() + 1).padStart(2, '0'); + const day = String(value.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + const raw = String(value).trim(); + if ((0, utils_1.isValidIsoDate)(raw)) { + return raw; + } + const czech = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})$/.exec(raw); + if (czech) { + const [, day, month, year] = czech; + const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + return (0, utils_1.isValidIsoDate)(iso) ? iso : undefined; + } + return undefined; +} +/** + * Rozdělí CSV na buňky. Respektuje uvozovky i zdvojené uvozovky uvnitř hodnoty, + * takže si poradí i s poznámkou obsahující oddělovač nebo konec řádku. + */ +function parseCsv(content) { + const rows = []; + let row = []; + let cell = ''; + let inQuotes = false; + for (let index = 0; index < content.length; index++) { + const char = content[index]; + if (inQuotes) { + if (char === '"') { + if (content[index + 1] === '"') { + cell += '"'; + index++; + } + else { + inQuotes = false; + } + } + else { + cell += char; + } + continue; + } + if (char === '"') { + inQuotes = true; + } + else if (char === CSV_SEPARATOR) { + row.push(cell); + cell = ''; + } + else if (char === '\n') { + row.push(cell); + rows.push(row); + row = []; + cell = ''; + } + else if (char !== '\r') { + cell += char; + } + } + if (cell.length || row.length) { + row.push(cell); + rows.push(row); + } + return rows.filter(current => current.some(value => value.trim().length)); +} +/** + * Sestaví z hlavičkového řádku mapu index sloupce -> klíč řádku. + * Nerozpoznané sloupce se ignorují. + */ +function mapHeaders(headers) { + const mapping = new Map(); + headers.forEach((header, index) => { + const key = COLUMN_HEADERS[String(header ?? '').trim().toLowerCase()]; + if (key) { + mapping.set(index, key); + } + }); + return mapping; +} +/** Poskládá řádek přehledu z buněk dle mapování sloupců. */ +function buildRow(cells, mapping) { + const raw = {}; + for (const [index, key] of mapping) { + raw[key] = cells[index]; + } + const date = parseDate(raw.date); + if (!date) + return undefined; + return { + date, + dayOfWeek: text(raw.dayOfWeek), + type: text(raw.type) ?? '', + food: text(raw.food), + note: text(raw.note), + store: text(raw.store), + orderedBy: text(raw.orderedBy), + amount: parseAmount(raw.amount), + }; +} +/** Zpracuje CSV podobu exportu. */ +function parseCsvExport(content) { + // Luncher píše CSV s BOM kvůli Excelu, JS ho musí odstranit ručně + const csv = content.toString('utf-8').replace(/^/, ''); + const table = parseCsv(csv); + if (!table.length) { + throw new utils_1.BadRequestError("CSV soubor je prázdný"); + } + const mapping = mapHeaders(table[0]); + if (!mapping.size) { + throw new utils_1.BadRequestError("V CSV se nepodařilo najít očekávané sloupce přehledu z Luncheru"); + } + const rows = []; + const warnings = []; + table.slice(1).forEach((cells, index) => { + const row = buildRow(cells, mapping); + if (row) { + rows.push(row); + } + else { + warnings.push(`Řádek ${index + 2} přeskočen — nepodařilo se přečíst datum`); + } + }); + return { rows, warnings }; +} +/** Zpracuje XLSX podobu exportu. */ +async function parseXlsxExport(content) { + const workbook = new exceljs_1.default.Workbook(); + try { + await workbook.xlsx.load(content); + } + catch { + throw new utils_1.BadRequestError("Soubor se nepodařilo přečíst jako XLSX"); + } + const sheet = workbook.getWorksheet(DETAIL_SHEET_NAME) ?? workbook.worksheets[0]; + if (!sheet) { + throw new utils_1.BadRequestError("XLSX soubor neobsahuje žádný list"); + } + /** Vrátí hodnoty buněk řádku v poli indexovaném od nuly. */ + const cellsOf = (rowNumber) => { + const row = sheet.getRow(rowNumber); + const values = []; + for (let column = 1; column <= sheet.columnCount; column++) { + const cell = row.getCell(column); + // Vzorce vracejí objekt { formula, result } — zajímá nás spočtená hodnota + const value = cell.value; + values.push(value && typeof value === 'object' && 'result' in value ? value.result : value); + } + return values; + }; + const mapping = mapHeaders(cellsOf(1)); + if (!mapping.size) { + throw new utils_1.BadRequestError("V XLSX se nepodařilo najít očekávané sloupce přehledu z Luncheru"); + } + const rows = []; + const warnings = []; + for (let rowNumber = 2; rowNumber <= sheet.rowCount; rowNumber++) { + const cells = cellsOf(rowNumber); + if (cells.every(value => value == null || String(value).trim() === '')) + continue; + const row = buildRow(cells, mapping); + if (row) { + rows.push(row); + } + else { + warnings.push(`Řádek ${rowNumber} přeskočen — nepodařilo se přečíst datum`); + } + } + return { rows, warnings }; +} +/** Zpracuje JSON podobu exportu (schéma UserExport z Luncheru). */ +function parseJsonExport(content) { + let parsed; + try { + parsed = JSON.parse(content.toString('utf-8')); + } + catch { + throw new utils_1.BadRequestError("Soubor se nepodařilo přečíst jako JSON"); + } + const rawRows = Array.isArray(parsed) ? parsed : parsed?.rows; + if (!Array.isArray(rawRows)) { + throw new utils_1.BadRequestError("JSON neobsahuje pole 'rows' s řádky přehledu"); + } + const rows = []; + const warnings = []; + rawRows.forEach((raw, index) => { + const date = parseDate(raw?.date); + if (!date) { + warnings.push(`Záznam ${index + 1} přeskočen — nepodařilo se přečíst datum`); + return; + } + rows.push({ + date, + dayOfWeek: text(raw.dayOfWeek), + type: text(raw.type) ?? '', + food: text(raw.food), + note: text(raw.note), + store: text(raw.store), + orderedBy: text(raw.orderedBy), + amount: parseAmount(raw.amount), + }); + }); + return { rows, warnings }; +} +/** + * Sestaví klíč pro rozpoznání duplicit. + * + * Součástí je i pořadí řádku v rámci dne — v jednom dni může být víc stejných + * záznamů (dvě totožné objednávky) a ty se nesmí navzájem odfiltrovat. + * Export Luncheru řadí řádky deterministicky, takže při opakovaném exportu + * téhož měsíce vyjde stejné pořadí. + */ +function buildImportKey(row, indexWithinDay) { + return [ + exports.IMPORT_SOURCE, + row.date, + indexWithinDay, + row.type, + row.food ?? '', + row.amount ?? '', + ].join('|'); +} +/** + * Převede řádek přehledu na záznam o jídle. + * + * Název se bere z vybraného jídla. U voleb jako "Budu objednávat" nebo + * "Rozhoduji se" je ale sloupec s jídlem prázdný a co uživatel reálně jedl stojí + * v poznámce ("Chefie - Těstovinový salát"), tak se použije ta. Teprve když není + * ani poznámka, zbude typ záznamu — uživatel si zvolil podnik, ale jídlo neuvedl. + * + * Zdrojem je obchod objednávky, jinak podnik. Stav volby se jako zdroj nepoužije. + */ +function toMealEntry(row, mealType, importKey) { + const noteAsName = !row.food && !!row.note; + const name = row.food ?? row.note ?? row.type; + // Poznámku, ze které se stal název, už do poznámky nekopírujeme + const remainingNote = noteAsName ? undefined : row.note; + const note = row.orderedBy + ? [remainingNote, `objednal(a): ${row.orderedBy}`].filter(Boolean).join(' | ') + : remainingNote; + return { + id: (0, crypto_1.randomUUID)(), + date: row.date, + mealType, + name, + source: row.store ?? (NON_PLACE_TYPES.has(row.type) ? undefined : row.type), + price: row.amount, + note, + importSource: exports.IMPORT_SOURCE, + importKey, + createdAt: new Date().toISOString(), + }; +} +/** Vrátí true, pokud je hodnota podporovaným režimem importu. */ +const isImportMode = (value) => value === types_gen_1.ImportMode.PRIDAT || value === types_gen_1.ImportMode.NAHRADIT; +/** + * Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele. + * + * Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří. + * V režimu PRIDAT se řádky, které už z dřívějšího importu existují, přeskočí, + * takže opakovaný import stejného měsíce data nezduplikuje. Režim NAHRADIT + * dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se + * v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech. + * + * Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už + * v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý + * import pak dostane jen vybrané klíče. + * + * @param login přihlašovací jméno uživatele + * @param fileName název souboru (určuje formát) + * @param content obsah souboru + * @param defaultMealType typ jídla pro importované řádky, výchozí je oběd + * @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží + * @param mode režim importu, výchozí je PRIDAT + * @param selectedKeys klíče řádků k založení; bez nich se založí všechny + */ +async function importLuncherExport(login, fileName, content, defaultMealType, dryRun = false, mode, selectedKeys) { + const format = detectFormat(fileName); + if (defaultMealType != null && !(0, meals_1.isMealType)(defaultMealType)) { + throw new utils_1.BadRequestError("Nebyl předán platný typ jídla"); + } + if (mode != null && !isImportMode(mode)) { + throw new utils_1.BadRequestError("Nebyl předán platný režim importu"); + } + if (selectedKeys != null && !Array.isArray(selectedKeys)) { + throw new utils_1.BadRequestError("Vybrané řádky musí být pole klíčů"); + } + // Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd + const mealType = (0, meals_1.isMealType)(defaultMealType) ? defaultMealType : types_gen_1.MealType.OBED; + const resolvedMode = isImportMode(mode) ? mode : types_gen_1.ImportMode.PRIDAT; + const replace = resolvedMode === types_gen_1.ImportMode.NAHRADIT; + let parsed; + if (format === 'csv') { + parsed = parseCsvExport(content); + } + else if (format === 'json') { + parsed = parseJsonExport(content); + } + else { + parsed = await parseXlsxExport(content); + } + const dates = Array.from(new Set(parsed.rows.map(row => row.date))); + const existing = await (0, meals_1.findMealsForDays)(login, dates); + // Při nahrazení se dřívější import založí znovu, duplicitou tedy není + const existingKeys = new Set(replace + ? [] + : existing.map(entry => entry.importKey).filter(key => !!key)); + const perDayIndex = new Map(); + const candidates = []; + let skipped = 0; + for (const row of parsed.rows) { + const indexWithinDay = perDayIndex.get(row.date) ?? 0; + perDayIndex.set(row.date, indexWithinDay + 1); + const importKey = buildImportKey(row, indexWithinDay); + if (existingKeys.has(importKey)) { + skipped++; + continue; + } + candidates.push(toMealEntry(row, mealType, importKey)); + } + const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String)); + const selected = candidates.filter(entry => !selectedKeyList || selectedKeyList.has(entry.importKey)); + skipped += candidates.length - selected.length; + // Nahrazuje se jen ve dnech, do kterých se opravdu něco zakládá + const selectedDays = new Set(selected.map(entry => entry.date)); + const replaced = replace + ? existing.filter(entry => entry.importSource === exports.IMPORT_SOURCE && selectedDays.has(entry.date)).length + : 0; + const days = dryRun + ? Array.from(selectedDays).sort() + : await (0, meals_1.addEntries)(login, selected, replace ? exports.IMPORT_SOURCE : undefined); + return { + dryRun, + format, + mode: resolvedMode, + rowCount: parsed.rows.length, + imported: selected.length, + skipped, + replaced, + days, + totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0), + entries: dryRun ? candidates : selected, + existing, + warnings: parsed.warnings, + }; +} diff --git a/server/dist/server/src/mealTemplates.js b/server/dist/server/src/mealTemplates.js new file mode 100644 index 0000000..53d31db --- /dev/null +++ b/server/dist/server/src/mealTemplates.js @@ -0,0 +1,319 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createMealTemplateFromMeal = exports.applyMealTemplate = exports.deleteMealTemplate = exports.saveMealTemplate = exports.deriveRecipe = exports.getMealTemplates = void 0; +const crypto_1 = require("crypto"); +const types_gen_1 = require("../../types/gen/types.gen"); +const storage_1 = __importDefault(require("./storage")); +const meals_1 = require("./meals"); +const calories_1 = require("./calories"); +const utils_1 = require("./utils"); +const storage = (0, storage_1.default)(); +/** Maximální délka textových polí. Poznámka je delší, aby se do ní vešel postup. */ +const MAX_NAME_LENGTH = 200; +const MAX_NOTE_LENGTH = 4000; +/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */ +const MAX_INGREDIENTS = 50; +/** Klíč úložiště se šablonami jídel jednoho uživatele. */ +const getKey = (login) => `mealTemplates:${login}`; +/** + * Doplní šabloně druh, pokud chybí. + * + * Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou + * ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic + * nepřepisuje, druh se doplní jen při čtení. + */ +const withKind = (template) => ({ + ...template, + kind: template.kind ?? types_gen_1.MealTemplateKind.HOTOVE, +}); +/** + * Vrátí šablony jídel uživatele seřazené podle názvu. + * + * @param login přihlašovací jméno uživatele + */ +const getMealTemplates = async (login) => (await storage.getData(getKey(login)) ?? []) + .map(withKind) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +exports.getMealTemplates = getMealTemplates; +/** + * Energie jedné suroviny — z gramáže a energie na 100 g, jinak ručně zadaná. + * Stejné pravidlo jako u jídla v `deriveAmounts`. + */ +const ingredientCalories = (ingredient) => ingredient.weight != null && ingredient.caloriesPer100g + ? Math.round(ingredient.weight / 100 * ingredient.caloriesPer100g) + : ingredient.calories; +/** Sečte vyplněné hodnoty; když není vyplněná žádná, vrátí undefined. */ +const sumDefined = (valueList) => { + const definedList = valueList.filter(value => value != null); + return definedList.length ? definedList.reduce((sum, value) => sum + value, 0) : undefined; +}; +/** + * Dopočte ze surovin hodnoty hotového jídla a jedné porce. + * + * Energie na 100 g se počítá z hmotnosti hotového jídla, ne ze součtu surovin — + * vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně. + * Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla. + * + * Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky. + * + * @param ingredients suroviny receptu + * @param servings počet porcí, nevyplněný znamená jednu + * @param cookedWeight hmotnost hotového jídla v gramech + */ +const deriveRecipe = (ingredients, servings, cookedWeight) => { + const portions = Math.max(servings ?? 1, 1); + const totalCalories = sumDefined(ingredients.map(ingredientCalories)); + const totalWeight = cookedWeight || sumDefined(ingredients.map(ingredient => ingredient.weight)) || undefined; + const totalPrice = sumDefined(ingredients.map(ingredient => ingredient.price)); + return { + totalCalories, + totalWeight, + totalPrice, + weight: totalWeight != null ? Math.round(totalWeight / portions) : undefined, + calories: totalCalories != null ? Math.round(totalCalories / portions) : undefined, + caloriesPer100g: totalCalories != null && totalWeight + ? Math.round(totalCalories / totalWeight * 100) + : undefined, + price: totalPrice != null ? Math.round(totalPrice / portions) : undefined, + }; +}; +exports.deriveRecipe = deriveRecipe; +/** Ořízne a znormalizuje volitelný textový vstup. */ +const normalizeText = (value, name, maxLength = MAX_NAME_LENGTH) => { + if (value == null) + return undefined; + if (typeof value !== 'string') { + throw new utils_1.BadRequestError(`Pole '${name}' musí být text`); + } + const trimmed = value.trim(); + if (!trimmed.length) + return undefined; + if (trimmed.length > maxLength) { + throw new utils_1.BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`); + } + return trimmed; +}; +/** Ověří volitelnou nezápornou celočíselnou hodnotu. */ +const normalizeNumber = (value, name) => { + if (value == null) + return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`); + } + const rounded = Math.round(value); + if (rounded < 0) { + throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`); + } + return rounded; +}; +/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */ +const definedOnly = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)); +/** Ověří a znormalizuje jednu surovinu receptu. */ +const normalizeIngredient = (raw, index) => { + const value = (raw ?? {}); + const name = normalizeText(value.name, 'name'); + if (!name) { + throw new utils_1.BadRequestError(`Surovina ${index + 1} nemá název`); + } + const weight = normalizeNumber(value.weight, 'weight'); + const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'); + const calories = normalizeNumber(value.calories, 'calories'); + const price = normalizeNumber(value.price, 'price'); + return definedOnly({ + name, + weight, + caloriesPer100g, + // Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže + calories: weight != null && caloriesPer100g ? undefined : calories, + price, + }); +}; +/** + * Založí novou šablonu jídla, nebo přepíše existující podle předaného id. + * + * U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty + * porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí — + * použití šablony pak funguje pro oba druhy stejně. + * + * @param login přihlašovací jméno uživatele + * @param input data šablony + */ +const saveMealTemplate = async (login, input) => { + const value = (input ?? {}); + const name = normalizeText(value.name, 'name'); + if (!name) { + throw new utils_1.BadRequestError("Nebyl předán název šablony"); + } + if (value.mealType != null && !(0, meals_1.isMealType)(value.mealType)) { + throw new utils_1.BadRequestError("Nebyl předán platný typ jídla"); + } + if (value.kind != null && value.kind !== types_gen_1.MealTemplateKind.HOTOVE && value.kind !== types_gen_1.MealTemplateKind.RECEPT) { + throw new utils_1.BadRequestError("Nebyl předán platný druh šablony"); + } + const kind = value.kind === types_gen_1.MealTemplateKind.RECEPT ? types_gen_1.MealTemplateKind.RECEPT : types_gen_1.MealTemplateKind.HOTOVE; + const common = { + id: typeof value.id === 'string' && value.id.length ? value.id : (0, crypto_1.randomUUID)(), + kind, + name, + mealType: value.mealType != null ? value.mealType : undefined, + source: normalizeText(value.source, 'source'), + note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH), + }; + let template; + if (kind === types_gen_1.MealTemplateKind.RECEPT) { + if (!Array.isArray(value.ingredients) || !value.ingredients.length) { + throw new utils_1.BadRequestError("Recept musí mít alespoň jednu surovinu"); + } + if (value.ingredients.length > MAX_INGREDIENTS) { + throw new utils_1.BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`); + } + const ingredients = value.ingredients.map(normalizeIngredient); + const servings = normalizeNumber(value.servings, 'servings'); + const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined; + const summary = (0, exports.deriveRecipe)(ingredients, servings, cookedWeight); + template = definedOnly({ + ...common, + ingredients, + // Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl + servings: servings && servings > 1 ? servings : undefined, + cookedWeight, + weight: summary.weight, + caloriesPer100g: summary.caloriesPer100g, + calories: summary.calories, + price: summary.price, + }); + for (const ingredient of ingredients) { + if (ingredient.caloriesPer100g) { + await (0, calories_1.rememberCalories)(login, ingredient.name, ingredient.caloriesPer100g); + } + } + } + else { + template = definedOnly({ + ...common, + calories: normalizeNumber(value.calories, 'calories'), + caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'), + weight: normalizeNumber(value.weight, 'weight'), + price: normalizeNumber(value.price, 'price'), + pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'), + }); + } + await storage.updateData(getKey(login), current => { + const others = (current ?? []).filter(item => item.id !== template.id); + return [...others, template]; + }); + return (0, exports.getMealTemplates)(login); +}; +exports.saveMealTemplate = saveMealTemplate; +/** + * Smaže šablonu jídla. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor šablony + */ +const deleteMealTemplate = async (login, id) => { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony"); + } + let found = false; + await storage.updateData(getKey(login), current => { + found = (current ?? []).some(item => item.id === id); + return (current ?? []).filter(item => item.id !== id); + }); + if (!found) { + throw new utils_1.NotFoundError("Šablona nebyla nalezena"); + } + return (0, exports.getMealTemplates)(login); +}; +exports.deleteMealTemplate = deleteMealTemplate; +/** + * Založí do zvoleného dne jídlo ze šablony. + * + * Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by + * to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako + * u ručně zadaného jídla. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor šablony + * @param date den, do kterého se jídlo založí + * @param mealType chod; bez něj se použije chod ze šablony, jinak oběd + * @param multiplier násobek porce, třeba 2 za dvě stejné sušenky + */ +const applyMealTemplate = async (login, id, date, mealType, multiplier) => { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (mealType != null && !(0, meals_1.isMealType)(mealType)) { + throw new utils_1.BadRequestError("Nebyl předán platný typ jídla"); + } + const template = (await (0, exports.getMealTemplates)(login)).find(item => item.id === id); + if (!template) { + throw new utils_1.NotFoundError("Šablona nebyla nalezena"); + } + await (0, meals_1.addMeal)(login, { + date, + mealType: mealType ?? template.mealType ?? types_gen_1.MealType.OBED, + name: template.name, + source: template.source, + weight: template.weight, + price: template.price, + pricePer100g: template.pricePer100g, + calories: template.calories, + caloriesPer100g: template.caloriesPer100g, + // Postup receptu do záznamu dne nepatří, zůstává u šablony + note: template.kind === types_gen_1.MealTemplateKind.RECEPT ? undefined : template.note, + multiplier: (0, utils_1.normalizeMultiplier)(multiplier), + }, template.id); + return date; +}; +exports.applyMealTemplate = applyMealTemplate; +/** + * Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. + * + * Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit + * jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných. + * Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by + * přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo. + * + * @param login přihlašovací jméno uživatele + * @param date den, ve kterém jídlo je + * @param id identifikátor jídla + */ +const createMealTemplateFromMeal = async (login, date, id) => { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor jídla"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + const meal = (await (0, meals_1.getDay)(login, date)).entries.find(entry => entry.id === id); + if (!meal) { + throw new utils_1.NotFoundError("Jídlo nebylo nalezeno"); + } + const key = (0, calories_1.normalizeName)(meal.name); + const existing = (await (0, exports.getMealTemplates)(login)).find(item => (0, calories_1.normalizeName)(item.name) === key); + if (existing?.kind === types_gen_1.MealTemplateKind.RECEPT) { + throw new utils_1.BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`); + } + return (0, exports.saveMealTemplate)(login, { + ...(existing ? { id: existing.id } : {}), + kind: types_gen_1.MealTemplateKind.HOTOVE, + name: meal.name, + source: meal.source, + mealType: meal.mealType, + weight: meal.weight, + price: meal.price, + pricePer100g: meal.pricePer100g, + // Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže + calories: meal.caloriesPer100g ? undefined : meal.calories, + caloriesPer100g: meal.caloriesPer100g, + note: meal.note, + }); +}; +exports.createMealTemplateFromMeal = createMealTemplateFromMeal; diff --git a/server/dist/server/src/meals.js b/server/dist/server/src/meals.js new file mode 100644 index 0000000..7a01a64 --- /dev/null +++ b/server/dist/server/src/meals.js @@ -0,0 +1,343 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isMealType = isMealType; +exports.buildDayRecord = buildDayRecord; +exports.getDay = getDay; +exports.getRange = getRange; +exports.deriveAmounts = deriveAmounts; +exports.normalizeInput = normalizeInput; +exports.addMeal = addMeal; +exports.addEntries = addEntries; +exports.updateMeal = updateMeal; +exports.deleteMeal = deleteMeal; +exports.setMealMultiplier = setMealMultiplier; +exports.findMealsForDays = findMealsForDays; +const crypto_1 = require("crypto"); +const types_gen_1 = require("../../types/gen/types.gen"); +const storage_1 = __importDefault(require("./storage")); +const utils_1 = require("./utils"); +const calories_1 = require("./calories"); +const storage = (0, storage_1.default)(); +/** Maximální délka textových polí, aby se do úložiště nedostaly nesmyslně velké hodnoty. */ +const MAX_TEXT_LENGTH = 500; +/** + * Pořadí typů jídel v rámci dne. Podle něj se řadí záznamy dne, + * aby přehled odpovídal skutečnému průběhu dne, ne pořadí zadání. + */ +const MEAL_TYPE_ORDER = [ + types_gen_1.MealType.SNIDANE, + types_gen_1.MealType.DOPOLEDNI_SVACINA, + types_gen_1.MealType.OBED, + types_gen_1.MealType.ODPOLEDNI_SVACINA, + types_gen_1.MealType.VECERE, + types_gen_1.MealType.PULNOCNI_SVACINA, + types_gen_1.MealType.JINE, +]; +/** Vrátí true, pokud je hodnota podporovaným typem jídla. */ +function isMealType(value) { + return typeof value === 'string' && MEAL_TYPE_ORDER.includes(value); +} +/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */ +function getDayKey(login, date) { + return `meals:${login}:${date}`; +} +/** Seřadí jídla dne dle typu (snídaně -> večeře) a při shodě dle času vzniku. */ +function sortEntries(entries) { + return [...entries].sort((a, b) => { + const typeDiff = MEAL_TYPE_ORDER.indexOf(a.mealType) - MEAL_TYPE_ORDER.indexOf(b.mealType); + return typeDiff !== 0 ? typeDiff : a.createdAt.localeCompare(b.createdAt); + }); +} +/** Sestaví přehled dne včetně součtů z předaných záznamů. */ +function buildDayRecord(date, entries) { + const sorted = sortEntries(entries); + return { + date, + entries: sorted, + totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0), + totalCalories: sorted.reduce((sum, entry) => sum + ((0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0), 0), + }; +} +/** + * Vrátí jídla uživatele za jeden den. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +async function getDay(login, date) { + const entries = await storage.getData(getDayKey(login, date)); + return buildDayRecord(date, entries ?? []); +} +/** + * Vrátí dny uživatele v rozsahu, které obsahují alespoň jeden záznam. + * + * @param login přihlašovací jméno uživatele + * @param from první den rozsahu (včetně), YYYY-MM-DD + * @param to poslední den rozsahu (včetně), YYYY-MM-DD + */ +async function getRange(login, from, to) { + if (from > to) { + throw new utils_1.BadRequestError("Začátek rozsahu je až za jeho koncem"); + } + const days = await Promise.all((0, utils_1.listDatesInRange)(from, to).map(date => getDay(login, date))); + return days.filter(day => day.entries.length > 0); +} +/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */ +function normalizeText(value, name) { + if (value == null) + return undefined; + if (typeof value !== 'string') { + throw new utils_1.BadRequestError(`Pole '${name}' musí být text`); + } + const trimmed = value.trim(); + if (!trimmed.length) + return undefined; + if (trimmed.length > MAX_TEXT_LENGTH) { + throw new utils_1.BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`); + } + return trimmed; +} +/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */ +function normalizeNumber(value, name) { + if (value == null) + return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`); + } + const rounded = Math.round(value); + if (rounded < 0) { + throw new utils_1.BadRequestError(`Pole '${name}' nesmí být záporné`); + } + return rounded; +} +/** + * Dopočte gramáž a kalorie z jednotkových hodnot. + * + * Podniky jako TechTower účtují podle váhy, takže z ceny a sazby za 100 g jde + * gramáž odvodit. Ručně zadaná gramáž má přednost — porce se dá zvážit přesněji, + * než kolik řekne cena, a u položek s jinou sazbou (salát) uživatel sazbu přepíše. + * Kalorie se stejně tak dopočtou z gramáže, pokud je známá energie na 100 g. + */ +function deriveAmounts(input) { + const weight = input.weight ?? (input.price != null && input.pricePer100g + ? Math.round(input.price / input.pricePer100g * 100) + : undefined); + const calories = weight != null && input.caloriesPer100g + ? Math.round(weight / 100 * input.caloriesPer100g) + : input.calories; + return { ...input, weight, calories }; +} +/** Ověří a znormalizuje vstupní data jídla. */ +function normalizeInput(input) { + const value = (input ?? {}); + if (!(0, utils_1.isValidIsoDate)(value.date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (!isMealType(value.mealType)) { + throw new utils_1.BadRequestError("Nebyl předán platný typ jídla"); + } + const name = normalizeText(value.name, 'name'); + if (!name) { + throw new utils_1.BadRequestError("Nebyl předán název jídla"); + } + return deriveAmounts({ + date: value.date, + mealType: value.mealType, + name, + source: normalizeText(value.source, 'source'), + note: normalizeText(value.note, 'note'), + price: normalizeNumber(value.price, 'price'), + calories: normalizeNumber(value.calories, 'calories'), + weight: normalizeNumber(value.weight, 'weight'), + pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'), + caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'), + multiplier: (0, utils_1.normalizeMultiplier)(value.multiplier), + }); +} +/** + * Přidá jídlo do zvoleného dne a vrátí aktualizovaný přehled dne. + * + * @param login přihlašovací jméno uživatele + * @param input data jídla + * @param templateId šablona, ze které jídlo vzniká — klient ji poslat nemůže, nastavuje ji jen server + */ +async function addMeal(login, input, templateId) { + const normalized = normalizeInput(input); + const entry = { + id: (0, crypto_1.randomUUID)(), + createdAt: new Date().toISOString(), + ...normalized, + ...(templateId ? { templateId } : {}), + }; + const entries = await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), entry]); + if (normalized.caloriesPer100g) { + await (0, calories_1.rememberCalories)(login, normalized.name, normalized.caloriesPer100g); + } + return buildDayRecord(normalized.date, entries); +} +/** + * Uloží najednou více záznamů (používá import). Záznamy se rozdělí dle data. + * Vrátí dny, kterých se zápis dotkl. + * + * @param login přihlašovací jméno uživatele + * @param entries záznamy k uložení + * @param replaceImportSource pokud je vyplněný, odeberou se z dotčených dnů nejdřív + * dřívější záznamy z tohoto importu; ručně zadaná jídla zůstanou + */ +async function addEntries(login, entries, replaceImportSource) { + const byDate = new Map(); + for (const entry of entries) { + const forDate = byDate.get(entry.date) ?? []; + forDate.push(entry); + byDate.set(entry.date, forDate); + } + for (const [date, forDate] of byDate) { + await storage.updateData(getDayKey(login, date), current => [ + ...(current ?? []).filter(entry => !replaceImportSource || entry.importSource !== replaceImportSource), + ...forDate, + ]); + } + return Array.from(byDate.keys()).sort(); +} +/** + * Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne. + * + * Změna data znamená přesun mezi dny — záznam se ze starého dne odebere + * a do nového přidá. Vrací se vždy přehled cílového (nového) dne. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor upravovaného záznamu + * @param input nová data jídla + */ +async function updateMeal(login, id, input) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + const normalized = normalizeInput(input); + const originalDate = await findEntryDate(login, id, normalized.date); + if (!originalDate) { + throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen"); + } + let original; + await storage.updateData(getDayKey(login, originalDate), current => { + original = (current ?? []).find(entry => entry.id === id); + return (current ?? []).filter(entry => entry.id !== id); + }); + if (!original) { + throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen"); + } + // Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno, + // proto se berou z normalizovaného vstupu a ne z původního záznamu. + const updated = { + ...original, + ...normalized, + price: normalized.price, + calories: normalized.calories, + source: normalized.source, + note: normalized.note, + weight: normalized.weight, + pricePer100g: normalized.pricePer100g, + caloriesPer100g: normalized.caloriesPer100g, + multiplier: normalized.multiplier, + updatedAt: new Date().toISOString(), + }; + const entries = await storage.updateData(getDayKey(login, normalized.date), current => [...(current ?? []), updated]); + if (normalized.caloriesPer100g) { + await (0, calories_1.rememberCalories)(login, normalized.name, normalized.caloriesPer100g); + } + return buildDayRecord(normalized.date, entries); +} +/** + * Smaže záznam o jídle a vrátí aktualizovaný přehled dne. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor mazaného záznamu + * @param date datum záznamu (YYYY-MM-DD) + */ +async function deleteMeal(login, id, date) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + let found = false; + const entries = await storage.updateData(getDayKey(login, date), current => { + found = (current ?? []).some(entry => entry.id === id); + return (current ?? []).filter(entry => entry.id !== id); + }); + if (!found) { + throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen"); + } + return buildDayRecord(date, entries); +} +/** + * Nastaví násobek porce jednoho jídla. Ostatní údaje záznamu zůstanou beze změny, + * takže jde násobek měnit přímo z přehledu dne bez otevření formuláře. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor záznamu + * @param date datum záznamu (YYYY-MM-DD) + * @param multiplier nový násobek; 1 násobek zruší + */ +async function setMealMultiplier(login, id, date, multiplier) { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor záznamu"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (multiplier == null) { + throw new utils_1.BadRequestError("Nebyl předán násobek"); + } + const normalized = (0, utils_1.normalizeMultiplier)(multiplier); + let found = false; + await storage.updateData(getDayKey(login, date), current => (current ?? []).map(entry => { + if (entry.id !== id) + return entry; + found = true; + const updated = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() }; + if (normalized == null) + delete updated.multiplier; + return updated; + })); + if (!found) { + throw new utils_1.NotFoundError("Záznam o jídle nebyl nalezen"); + } + return date; +} +/** + * Najde datum, pod kterým je záznam uložený. + * Nejdřív zkusí očekávaný den, teprve pak prohledá ostatní klíče uživatele — + * úprava záznamu totiž může měnit i datum. + */ +async function findEntryDate(login, id, expectedDate) { + const expected = await storage.getData(getDayKey(login, expectedDate)); + if (expected?.some(entry => entry.id === id)) { + return expectedDate; + } + const prefix = `meals:${login}:`; + for (const key of await storage.listKeys(prefix)) { + if (!key.startsWith(prefix)) + continue; + const entries = await storage.getData(key); + if (entries?.some(entry => entry.id === id)) { + return key.substring(prefix.length); + } + } + return undefined; +} +/** + * Vrátí všechna jídla uživatele v předaných dnech, seřazená dle dne a chodu. + * Import podle nich rozpoznává duplicity a v náhledu je ukazuje uživateli. + * + * @param login přihlašovací jméno uživatele + * @param dates dny, ve kterých se má hledat + */ +async function findMealsForDays(login, dates) { + const days = await Promise.all([...dates].sort().map(date => getDay(login, date))); + return days.flatMap(day => day.entries); +} diff --git a/server/dist/server/src/profile.js b/server/dist/server/src/profile.js new file mode 100644 index 0000000..1631a72 --- /dev/null +++ b/server/dist/server/src/profile.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.computeBasalCalories = exports.normalizeProfile = exports.isPohlavi = void 0; +const types_gen_1 = require("../../types/gen/types.gen"); +const utils_1 = require("./utils"); +/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */ +const LIMITS = { + birthYear: { min: 1900, max: new Date().getFullYear() }, + heightCm: { min: 50, max: 250 }, + weightGrams: { min: 20_000, max: 400_000 }, +}; +/** Vrátí true, pokud je hodnota podporovaným pohlavím. */ +const isPohlavi = (value) => value === types_gen_1.Pohlavi.MUZ || value === types_gen_1.Pohlavi.ZENA; +exports.isPohlavi = isPohlavi; +/** Ověří volitelné celé číslo v daném rozsahu. */ +const normalizeRanged = (value, name) => { + if (value == null) + return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new utils_1.BadRequestError(`Pole '${name}' musí být číslo`); + } + const rounded = Math.round(value); + const { min, max } = LIMITS[name]; + if (rounded < min || rounded > max) { + throw new utils_1.BadRequestError(`Pole '${name}' je mimo rozsah (${min}–${max})`); + } + return rounded; +}; +/** Ověří a znormalizuje tělesné údaje. Prázdné hodnoty se uloží jako nevyplněné. */ +const normalizeProfile = (input) => { + const value = (input ?? {}); + if (value.sex != null && !(0, exports.isPohlavi)(value.sex)) { + throw new utils_1.BadRequestError("Nebylo předáno platné pohlaví"); + } + return { + ...(value.sex != null ? { sex: value.sex } : {}), + ...(normalizeRanged(value.birthYear, 'birthYear') != null + ? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}), + ...(normalizeRanged(value.heightCm, 'heightCm') != null + ? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}), + ...(normalizeRanged(value.weightGrams, 'weightGrams') != null + ? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}), + }; +}; +exports.normalizeProfile = normalizeProfile; +/** + * Spočítá bazální metabolismus rovnicí Mifflin–St Jeor. + * + * ``` + * muž: 10 × kg + 6,25 × cm − 5 × věk + 5 + * žena: 10 × kg + 6,25 × cm − 5 × věk − 161 + * ``` + * + * Je to klidový výdej za **celých 24 hodin**, tedy včetně spánku. Záměrně se + * nenásobí koeficientem fyzické aktivity (PAL), jak to dělají kalkulačky TDEE — + * pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení + * by ho započítalo dvakrát. + * + * Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota. + * + * @param profile tělesné údaje uživatele + * @returns klidový výdej v kcal za den, nebo nic, když profil není úplný + */ +const computeBasalCalories = (profile) => { + if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) { + return undefined; + } + const age = new Date().getFullYear() - profile.birthYear; + const base = 10 * (profile.weightGrams / 1000) + + 6.25 * profile.heightCm + - 5 * age; + return Math.round(base + (profile.sex === types_gen_1.Pohlavi.MUZ ? 5 : -161)); +}; +exports.computeBasalCalories = computeBasalCalories; diff --git a/server/dist/server/src/routes/activityRoutes.js b/server/dist/server/src/routes/activityRoutes.js new file mode 100644 index 0000000..e49b267 --- /dev/null +++ b/server/dist/server/src/routes/activityRoutes.js @@ -0,0 +1,59 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const activities_1 = require("../activities"); +const dayOverview_1 = require("../dayOverview"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Přidá pohybovou aktivitu a vrátí aktualizovaný přehled dne. */ +router.post("/add", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const date = await (0, activities_1.addActivity)(login, req.body); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date)); + } + catch (e) { + next(e); + } +}); +/** Upraví existující aktivitu. */ +router.post("/update", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, ...input } = req.body ?? {}; + const date = await (0, activities_1.updateActivity)(login, id, input); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date)); + } + catch (e) { + next(e); + } +}); +/** Smaže aktivitu. */ +router.post("/delete", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date } = req.body ?? {}; + const day = await (0, activities_1.deleteActivity)(login, id, date); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +/** Nastaví násobek aktivity z přehledu dne. */ +router.post("/multiplier", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date, multiplier } = req.body ?? {}; + const day = await (0, activities_1.setActivityMultiplier)(login, id, date, multiplier); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/calorieRoutes.js b/server/dist/server/src/routes/calorieRoutes.js new file mode 100644 index 0000000..ad8b4f2 --- /dev/null +++ b/server/dist/server/src/routes/calorieRoutes.js @@ -0,0 +1,21 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const calories_1 = require("../calories"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Najde návrhy energetické hodnoty pro název jídla. */ +router.get("/search", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, calories_1.searchCalories)(login, req.query.q)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/dayRoutes.js b/server/dist/server/src/routes/dayRoutes.js new file mode 100644 index 0000000..669ebf0 --- /dev/null +++ b/server/dist/server/src/routes/dayRoutes.js @@ -0,0 +1,37 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const dayOverview_1 = require("../dayOverview"); +const sleep_1 = require("../sleep"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Vrátí kompletní přehled dne. Bez parametru vrací dnešek. */ +router.get("/", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const date = req.query.date == null + ? (0, utils_1.formatDate)((0, utils_1.getToday)()) + : (0, utils_1.requireIsoDate)(req.query.date, 'date'); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, date)); + } + catch (e) { + next(e); + } +}); +/** Uloží délku spánku pro zvolený den. */ +router.post("/sleep", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { date, sleepMinutes } = req.body ?? {}; + const day = await (0, sleep_1.saveSleep)(login, date, sleepMinutes); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/importRoutes.js b/server/dist/server/src/routes/importRoutes.js new file mode 100644 index 0000000..3dc220c --- /dev/null +++ b/server/dist/server/src/routes/importRoutes.js @@ -0,0 +1,38 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const luncherImport_1 = require("../luncherImport"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** + * Naimportuje měsíční přehled vyexportovaný ze stránky statistik Luncheru. + * + * Soubor přichází v těle požadavku jako Base64 — vyhneme se tím multipartu + * a XLSX (binární formát) projde stejnou cestou jako CSV a JSON. + */ +router.post("/luncher", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { fileName, content, defaultMealType, dryRun, mode, selectedKeys } = req.body ?? {}; + if (typeof fileName !== 'string' || !fileName.trim().length) { + throw new utils_1.BadRequestError("Nebyl předán název souboru"); + } + if (typeof content !== 'string' || !content.length) { + throw new utils_1.BadRequestError("Nebyl předán obsah souboru"); + } + const buffer = Buffer.from(content, 'base64'); + if (!buffer.length) { + throw new utils_1.BadRequestError("Obsah souboru je prázdný nebo není platný Base64"); + } + const result = await (0, luncherImport_1.importLuncherExport)(login, fileName.trim(), buffer, defaultMealType, dryRun === true, mode, selectedKeys); + res.status(200).json(result); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/mealRoutes.js b/server/dist/server/src/routes/mealRoutes.js new file mode 100644 index 0000000..eac8f05 --- /dev/null +++ b/server/dist/server/src/routes/mealRoutes.js @@ -0,0 +1,85 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const meals_1 = require("../meals"); +const dayOverview_1 = require("../dayOverview"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** + * Vrátí jídla přihlášeného uživatele za jeden den. Bez parametru vrací dnešek. + */ +router.get("/day", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const date = req.query.date == null + ? (0, utils_1.formatDate)((0, utils_1.getToday)()) + : (0, utils_1.requireIsoDate)(req.query.date, 'date'); + res.status(200).json(await (0, meals_1.getDay)(login, date)); + } + catch (e) { + next(e); + } +}); +/** + * Vrátí dny přihlášeného uživatele v rozsahu, které obsahují alespoň jeden záznam. + */ +router.get("/range", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const from = (0, utils_1.requireIsoDate)(req.query.from, 'from'); + const to = (0, utils_1.requireIsoDate)(req.query.to, 'to'); + res.status(200).json(await (0, meals_1.getRange)(login, from, to)); + } + catch (e) { + next(e); + } +}); +/** Přidá jídlo do zvoleného dne. */ +router.post("/add", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, meals_1.addMeal)(login, req.body)); + } + catch (e) { + next(e); + } +}); +/** Upraví existující záznam o jídle. */ +router.post("/update", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, ...input } = req.body ?? {}; + res.status(200).json(await (0, meals_1.updateMeal)(login, id, input)); + } + catch (e) { + next(e); + } +}); +/** Smaže záznam o jídle. */ +router.post("/delete", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date } = req.body ?? {}; + res.status(200).json(await (0, meals_1.deleteMeal)(login, id, date)); + } + catch (e) { + next(e); + } +}); +/** Nastaví násobek porce z přehledu dne a vrátí celý přehled, protože se mění i bilance. */ +router.post("/multiplier", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date, multiplier } = req.body ?? {}; + const day = await (0, meals_1.setMealMultiplier)(login, id, date, multiplier); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/mealTemplateRoutes.js b/server/dist/server/src/routes/mealTemplateRoutes.js new file mode 100644 index 0000000..987dca7 --- /dev/null +++ b/server/dist/server/src/routes/mealTemplateRoutes.js @@ -0,0 +1,65 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const mealTemplates_1 = require("../mealTemplates"); +const dayOverview_1 = require("../dayOverview"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Vrátí šablony jídel přihlášeného uživatele. */ +router.get("/", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, mealTemplates_1.getMealTemplates)(login)); + } + catch (e) { + next(e); + } +}); +/** Založí novou šablonu jídla, nebo upraví existující. */ +router.post("/save", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, mealTemplates_1.saveMealTemplate)(login, req.body)); + } + catch (e) { + next(e); + } +}); +/** Smaže šablonu jídla. */ +router.post("/delete", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, mealTemplates_1.deleteMealTemplate)(login, req.body?.id)); + } + catch (e) { + next(e); + } +}); +/** Založí jídlo ze šablony do zvoleného dne. */ +router.post("/apply", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date, mealType, multiplier } = req.body ?? {}; + const day = await (0, mealTemplates_1.applyMealTemplate)(login, id, date, mealType, multiplier); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +/** Vytvoří šablonu z existujícího jídla v přehledu dne. */ +router.post("/fromMeal", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date } = req.body ?? {}; + res.status(200).json(await (0, mealTemplates_1.createMealTemplateFromMeal)(login, date, id)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/settingsRoutes.js b/server/dist/server/src/routes/settingsRoutes.js new file mode 100644 index 0000000..f2fdba1 --- /dev/null +++ b/server/dist/server/src/routes/settingsRoutes.js @@ -0,0 +1,52 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const settings_1 = require("../settings"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Vrátí nastavení přihlášeného uživatele. */ +router.get("/", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, settings_1.getSettings)(login)); + } + catch (e) { + next(e); + } +}); +/** Uloží nebo smaže cenu za 100 g u jednoho zdroje. */ +router.post("/", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { source, pricePer100g } = req.body ?? {}; + res.status(200).json(await (0, settings_1.saveSourceRate)(login, source, pricePer100g)); + } + catch (e) { + next(e); + } +}); +/** Uloží klidový výdej (bazální metabolismus). */ +router.post("/basal", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, settings_1.saveBasalCalories)(login, req.body?.basalCalories)); + } + catch (e) { + next(e); + } +}); +/** Uloží tělesné údaje uživatele. */ +router.post("/profile", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, settings_1.saveProfile)(login, req.body)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/statsRoutes.js b/server/dist/server/src/routes/statsRoutes.js new file mode 100644 index 0000000..9fa1d12 --- /dev/null +++ b/server/dist/server/src/routes/statsRoutes.js @@ -0,0 +1,35 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const statsService_1 = require("../statsService"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Vrátí statistiky útraty a jídel přihlášeného uživatele za zvolené období. */ +router.get("/summary", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const from = (0, utils_1.requireIsoDate)(req.query.from, 'from'); + const to = (0, utils_1.requireIsoDate)(req.query.to, 'to'); + res.status(200).json(await (0, statsService_1.getFoodStats)(login, from, to)); + } + catch (e) { + next(e); + } +}); +/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */ +router.get("/activity", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const from = (0, utils_1.requireIsoDate)(req.query.from, 'from'); + const to = (0, utils_1.requireIsoDate)(req.query.to, 'to'); + res.status(200).json(await (0, statsService_1.getActivityStats)(login, from, to)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/routes/workoutRoutes.js b/server/dist/server/src/routes/workoutRoutes.js new file mode 100644 index 0000000..d96808a --- /dev/null +++ b/server/dist/server/src/routes/workoutRoutes.js @@ -0,0 +1,66 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const workouts_1 = require("../workouts"); +const dayOverview_1 = require("../dayOverview"); +const auth_1 = require("../auth"); +const utils_1 = require("../utils"); +const router = express_1.default.Router(); +/** Vrátí šablony tréninků přihlášeného uživatele. */ +router.get("/", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, workouts_1.getWorkoutTemplates)(login)); + } + catch (e) { + next(e); + } +}); +/** Založí novou šablonu tréninku, nebo upraví existující. */ +router.post("/save", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, name, items } = req.body ?? {}; + res.status(200).json(await (0, workouts_1.saveWorkoutTemplate)(login, id, name, items)); + } + catch (e) { + next(e); + } +}); +/** Smaže šablonu tréninku. */ +router.post("/delete", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + res.status(200).json(await (0, workouts_1.deleteWorkoutTemplate)(login, req.body?.id)); + } + catch (e) { + next(e); + } +}); +/** Založí do zvoleného dne všechny položky šablony. */ +router.post("/apply", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { id, date } = req.body ?? {}; + const day = await (0, workouts_1.applyWorkoutTemplate)(login, id, date); + res.status(200).json(await (0, dayOverview_1.getDayOverview)(login, day)); + } + catch (e) { + next(e); + } +}); +/** Vytvoří šablonu z aktivit zaznamenaných v přehledu dne. */ +router.post("/fromActivities", async (req, res, next) => { + try { + const login = (0, auth_1.getLogin)((0, utils_1.parseToken)(req)); + const { date, name, ids } = req.body ?? {}; + res.status(200).json(await (0, workouts_1.createWorkoutTemplateFromActivities)(login, date, name, ids)); + } + catch (e) { + next(e); + } +}); +exports.default = router; diff --git a/server/dist/server/src/settings.js b/server/dist/server/src/settings.js new file mode 100644 index 0000000..3ce617b --- /dev/null +++ b/server/dist/server/src/settings.js @@ -0,0 +1,135 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getSettings = getSettings; +exports.saveProfile = saveProfile; +exports.saveSourceRate = saveSourceRate; +exports.saveBasalCalories = saveBasalCalories; +exports.getSourceRate = getSourceRate; +const storage_1 = __importDefault(require("./storage")); +const utils_1 = require("./utils"); +const calories_1 = require("./calories"); +const profile_1 = require("./profile"); +const storage = (0, storage_1.default)(); +/** Horní mez sazby (10 000 Kč za 100 g) — chrání před překlepem o řád. */ +const MAX_PRICE_PER_100G = 1_000_000; +/** Horní mez klidového výdeje — nad 10 000 kcal/den už jde o překlep. */ +const MAX_BASAL_CALORIES = 10_000; +/** Klíč úložiště s nastavením jednoho uživatele. */ +function getSettingsKey(login) { + return `settings:${login}`; +} +/** Prázdné nastavení pro uživatele, který si zatím nic nenastavil. */ +function emptySettings() { + return { sourceRates: [] }; +} +/** + * Vrátí nastavení uživatele. + * + * @param login přihlašovací jméno uživatele + */ +async function getSettings(login) { + const stored = await storage.getData(getSettingsKey(login)) ?? emptySettings(); + return withDerived(stored); +} +/** + * Doplní nastavení o hodnoty, které se dopočítávají a neukládají. + * + * Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů — + * kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice. + */ +function withDerived(settings) { + const computed = (0, profile_1.computeBasalCalories)(settings.profile); + const effective = settings.basalCalories ?? computed; + return { + ...settings, + ...(computed != null ? { computedBasalCalories: computed } : {}), + ...(effective != null ? { effectiveBasalCalories: effective } : {}), + }; +} +/** + * Uloží tělesné údaje uživatele. + * + * @param login přihlašovací jméno uživatele + * @param profile tělesné údaje + */ +async function saveProfile(login, profile) { + const normalized = (0, profile_1.normalizeProfile)(profile); + const saved = await storage.updateData(getSettingsKey(login), current => ({ + ...(current ?? emptySettings()), + profile: normalized, + })); + return withDerived(saved); +} +/** + * Uloží cenu za 100 g u jednoho zdroje. Prázdná hodnota sazbu odstraní. + * + * Zdroje se porovnávají znormalizovaně, aby "TechTower" a "techtower" + * neskončily jako dvě různé sazby. + * + * @param login přihlašovací jméno uživatele + * @param source název zdroje (podniku) + * @param pricePer100g cena za 100 g v haléřích, nebo null pro odstranění + */ +async function saveSourceRate(login, source, pricePer100g) { + if (typeof source !== 'string' || !source.trim().length) { + throw new utils_1.BadRequestError("Nebyl předán název zdroje"); + } + if (pricePer100g != null && (typeof pricePer100g !== 'number' || !Number.isFinite(pricePer100g))) { + throw new utils_1.BadRequestError("Cena za 100 g musí být číslo"); + } + const rate = pricePer100g == null ? 0 : Math.round(pricePer100g); + if (rate < 0 || rate > MAX_PRICE_PER_100G) { + throw new utils_1.BadRequestError("Cena za 100 g je mimo rozsah"); + } + const name = source.trim(); + const key = (0, calories_1.normalizeName)(name); + const saved = await storage.updateData(getSettingsKey(login), current => { + const settings = current ?? emptySettings(); + const rates = settings.sourceRates.filter(item => (0, calories_1.normalizeName)(item.source) !== key); + // Nulová sazba znamená smazání — ukládat ji nemá smysl + return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates }; + }); + return withDerived(saved); +} +/** + * Uloží klidový výdej (bazální metabolismus) v kcal za den. + * Prázdná nebo nulová hodnota ho odstraní a bilance dne pak nepočítá deficit. + * + * @param login přihlašovací jméno uživatele + * @param basalCalories klidový výdej v kcal, nebo null pro odstranění + */ +async function saveBasalCalories(login, basalCalories) { + if (basalCalories != null && (typeof basalCalories !== 'number' || !Number.isFinite(basalCalories))) { + throw new utils_1.BadRequestError("Klidový výdej musí být číslo"); + } + const value = basalCalories == null ? 0 : Math.round(basalCalories); + if (value < 0 || value > MAX_BASAL_CALORIES) { + throw new utils_1.BadRequestError("Klidový výdej je mimo rozsah"); + } + const saved = await storage.updateData(getSettingsKey(login), current => { + const settings = current ?? emptySettings(); + // Nula znamená smazání — ukládat ji nemá smysl + if (value > 0) { + return { ...settings, basalCalories: value }; + } + const { basalCalories: _removed, ...rest } = settings; + return rest; + }); + return withDerived(saved); +} +/** + * Vrátí sazbu za 100 g nastavenou pro daný zdroj, pokud existuje. + * + * @param login přihlašovací jméno uživatele + * @param source název zdroje + */ +async function getSourceRate(login, source) { + if (!source?.trim().length) + return undefined; + const key = (0, calories_1.normalizeName)(source); + const settings = await getSettings(login); + return settings.sourceRates.find(rate => (0, calories_1.normalizeName)(rate.source) === key)?.pricePer100g; +} diff --git a/server/dist/server/src/sleep.js b/server/dist/server/src/sleep.js new file mode 100644 index 0000000..a5bc2f6 --- /dev/null +++ b/server/dist/server/src/sleep.js @@ -0,0 +1,49 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.saveSleep = exports.getSleep = void 0; +const storage_1 = __importDefault(require("./storage")); +const utils_1 = require("./utils"); +const storage = (0, storage_1.default)(); +/** Horní mez délky spánku — víc než 24 hodin za den je překlep. */ +const MAX_SLEEP_MINUTES = 24 * 60; +/** Klíč úložiště se spánkem jednoho dne jednoho uživatele. */ +const getSleepKey = (login, date) => `sleep:${login}:${date}`; +/** + * Vrátí délku spánku daného dne v minutách, pokud je zaznamenaná. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +const getSleep = async (login, date) => await storage.getData(getSleepKey(login, date)); +exports.getSleep = getSleep; +/** + * Uloží délku spánku pro zvolený den. Nulová hodnota záznam odstraní. + * + * Spánek se eviduje jako kontext dne, ne jako složka energetické bilance — + * klidový výdej z rovnice Mifflin–St Jeor už pokrývá celých 24 hodin včetně + * spánku, takže přičítat nebo odečítat za něj kalorie by bylo dvojí započtení. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + * @param sleepMinutes délka spánku v minutách, nebo null pro odstranění + * @returns datum, kterého se zápis týkal + */ +const saveSleep = async (login, date, sleepMinutes) => { + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (sleepMinutes != null && (typeof sleepMinutes !== 'number' || !Number.isFinite(sleepMinutes))) { + throw new utils_1.BadRequestError("Délka spánku musí být číslo"); + } + const value = sleepMinutes == null ? 0 : Math.round(sleepMinutes); + if (value < 0 || value > MAX_SLEEP_MINUTES) { + throw new utils_1.BadRequestError("Délka spánku je mimo rozsah"); + } + // Nula znamená smazání — ukládat ji nemá smysl + await storage.setData(getSleepKey(login, date), value > 0 ? value : undefined); + return date; +}; +exports.saveSleep = saveSleep; diff --git a/server/dist/server/src/statsService.js b/server/dist/server/src/statsService.js new file mode 100644 index 0000000..973b643 --- /dev/null +++ b/server/dist/server/src/statsService.js @@ -0,0 +1,265 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getFoodStats = getFoodStats; +exports.getActivityStats = getActivityStats; +const types_gen_1 = require("../../types/gen/types.gen"); +const meals_1 = require("./meals"); +const activities_1 = require("./activities"); +const workouts_1 = require("./workouts"); +const workoutPlans_1 = require("./workoutPlans"); +const utils_1 = require("./utils"); +/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */ +const UNKNOWN_SOURCE = 'Neuvedeno'; +/** Přičte záznam do kbelíku pod daným klíčem. */ +function accumulate(buckets, key, entry) { + const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 }; + bucket.price += entry.price ?? 0; + bucket.calories += (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0; + bucket.count++; + buckets.set(key, bucket); +} +/** Převede kbelíky na součty období seřazené vzestupně dle označení období. */ +function toPeriodTotals(buckets) { + return Array.from(buckets.entries()) + .map(([period, bucket]) => ({ period, ...bucket })) + .sort((a, b) => a.period.localeCompare(b.period)); +} +/** Převede kbelíky na součty skupin seřazené sestupně dle útraty. */ +function toGroupTotals(buckets) { + return Array.from(buckets.entries()) + .map(([key, bucket]) => ({ key, ...bucket })) + .sort((a, b) => b.price - a.price || b.count - a.count || a.key.localeCompare(b.key)); +} +/** + * Sestaví statistiky útraty a jídel uživatele za zvolené období. + * + * Dny bez záznamu se do `dayCount` ani do `byDay` nepromítají — průměr na den + * tak vychází z reálně vykázaných dní, ne z délky vybraného rozsahu. + * + * @param login přihlašovací jméno uživatele + * @param from první den období (včetně), YYYY-MM-DD + * @param to poslední den období (včetně), YYYY-MM-DD + */ +async function getFoodStats(login, from, to) { + const days = await (0, meals_1.getRange)(login, from, to); + const byDay = new Map(); + const byMonth = new Map(); + const byYear = new Map(); + const byMealType = new Map(); + const bySource = new Map(); + let totalPrice = 0; + let totalCalories = 0; + let entryCount = 0; + for (const day of days) { + for (const entry of day.entries) { + accumulate(byDay, entry.date, entry); + accumulate(byMonth, entry.date.substring(0, 7), entry); + accumulate(byYear, entry.date.substring(0, 4), entry); + accumulate(byMealType, entry.mealType, entry); + accumulate(bySource, entry.source ?? UNKNOWN_SOURCE, entry); + totalPrice += entry.price ?? 0; + totalCalories += (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0; + entryCount++; + } + } + return { + from, + to, + totalPrice, + totalCalories, + entryCount, + dayCount: days.length, + byDay: toPeriodTotals(byDay), + byMonth: toPeriodTotals(byMonth), + byYear: toPeriodTotals(byYear), + byMealType: toGroupTotals(byMealType), + bySource: toGroupTotals(bySource), + }; +} +/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */ +const DELETED_TEMPLATE = 'Smazaná šablona'; +/** Přičte výdej dne do období pod daným klíčem. */ +function accumulateActivity(buckets, period, template, planned, extra) { + const bucket = buckets.get(period) ?? { period, templateCalories: 0, plannedCalories: 0, extraCalories: 0 }; + bucket.templateCalories += template; + bucket.plannedCalories += planned; + bucket.extraCalories += extra; + buckets.set(period, bucket); +} +/** Seřadí součty období vzestupně dle označení období. */ +const sortPeriods = (buckets) => Array.from(buckets.values()).sort((a, b) => a.period.localeCompare(b.period)); +/** Stručný popis aktivity nebo cviku plánu; jedna série a násobek 1 se vynechávají. */ +const summarize = (item, calories) => ({ + name: item.name, + unit: item.unit, + quantity: item.quantity, + ...(item.sets != null && item.sets > 1 ? { sets: item.sets } : {}), + ...(item.multiplier != null && item.multiplier !== 1 ? { multiplier: item.multiplier } : {}), + calories, +}); +/** Vrátí true, pokud se aktivita provedla jinak, než plán cviku sliboval. */ +const differsFromPlan = (entry, item, calories) => entry.quantity !== item.quantity + || (entry.sets ?? 1) !== (item.sets ?? 1) + || (entry.multiplier ?? 1) !== 1 + || calories !== item.calories; +/** + * Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu + * a aktivity mimo šablony vrátí zvlášť. + * + * Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se + * berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde. + */ +function analyzeDay(entries, plans, templateNames, date) { + const byId = new Map(entries.map(entry => [entry.id, entry])); + const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId))); + const usages = new Map(); + const counts = new Map(); + const usageFor = (templateId, name, hasPlan) => { + const usage = usages.get(templateId) ?? { + templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [], + }; + usages.set(templateId, usage); + if (!counts.has(templateId)) + counts.set(templateId, { planned: 0, removed: 0 }); + return usage; + }; + for (const plan of plans) { + // Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se + const usage = usageFor(plan.templateId, plan.templateName, true); + const count = counts.get(plan.templateId); + for (const item of plan.items) { + usage.plannedCalories += item.calories; + count.planned++; + const entry = item.entryId ? byId.get(item.entryId) : undefined; + if (!entry) { + usage.deviations.push({ kind: types_gen_1.PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) }); + count.removed++; + continue; + } + const calories = (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0; + usage.actualCalories += calories; + if (differsFromPlan(entry, item, calories)) { + usage.deviations.push({ + kind: types_gen_1.PlanDeviationKind.UPRAVENO, + planned: summarize(item, item.calories), + actual: summarize(entry, calories), + }); + } + } + } + const extras = []; + for (const entry of entries) { + if (planned.has(entry.id)) + continue; + const calories = (0, utils_1.applyMultiplier)(entry.calories, entry.multiplier) ?? 0; + if (!entry.templateId) { + extras.push(summarize(entry, calories)); + continue; + } + const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false); + usage.plannedCalories += calories; + usage.actualCalories += calories; + counts.get(entry.templateId).planned++; + } + const templates = Array.from(usages.values()); + const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0); + return { + day: { + date, + totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories, + templates, + extraCalories, + extras, + }, + counts, + }; +} +/** + * Sestaví statistiky výdeje pohybem za zvolené období. + * + * Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při + * použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla + * mezitím změnit. + * + * @param login přihlašovací jméno uživatele + * @param from první den období (včetně), YYYY-MM-DD + * @param to poslední den období (včetně), YYYY-MM-DD + */ +async function getActivityStats(login, from, to) { + if (from > to) { + throw new utils_1.BadRequestError("Začátek rozsahu je až za jeho koncem"); + } + const dates = (0, utils_1.listDatesInRange)(from, to); + const [activityDays, plansByDay, templates] = await Promise.all([ + Promise.all(dates.map(date => (0, activities_1.getActivityDay)(login, date))), + Promise.all(dates.map(date => (0, workoutPlans_1.getWorkoutPlans)(login, date))), + (0, workouts_1.getWorkoutTemplates)(login), + ]); + const templateNames = new Map(templates.map(template => [template.id, template.name])); + const byDay = new Map(); + const byMonth = new Map(); + const byYear = new Map(); + const byTemplate = new Map(); + const extras = new Map(); + const days = []; + let templateCalories = 0; + let plannedCalories = 0; + let extraCalories = 0; + let entryCount = 0; + activityDays.forEach((activityDay, index) => { + if (!activityDay.entries.length) + return; + entryCount += activityDay.entries.length; + const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date); + days.push(day); + let dayTemplate = 0; + let dayPlanned = 0; + for (const usage of day.templates) { + const count = counts.get(usage.templateId); + const total = byTemplate.get(usage.templateId) ?? { + templateId: usage.templateId, name: usage.name, dayCount: 0, + plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0, + }; + // Dny jdou vzestupně, takže zůstane název z posledního použití + total.name = usage.name; + total.dayCount++; + total.plannedCalories += usage.plannedCalories; + total.actualCalories += usage.actualCalories; + total.plannedItemCount += count.planned; + total.doneItemCount += count.planned - count.removed; + byTemplate.set(usage.templateId, total); + dayTemplate += usage.actualCalories; + dayPlanned += usage.plannedCalories; + } + for (const item of day.extras) { + const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 }; + group.calories += item.calories; + group.count++; + extras.set(item.name, group); + } + accumulateActivity(byDay, day.date, dayTemplate, dayPlanned, day.extraCalories); + accumulateActivity(byMonth, day.date.substring(0, 7), dayTemplate, dayPlanned, day.extraCalories); + accumulateActivity(byYear, day.date.substring(0, 4), dayTemplate, dayPlanned, day.extraCalories); + templateCalories += dayTemplate; + plannedCalories += dayPlanned; + extraCalories += day.extraCalories; + }); + return { + from, + to, + totalCalories: templateCalories + extraCalories, + templateCalories, + plannedCalories, + extraCalories, + entryCount, + dayCount: days.length, + byDay: sortPeriods(byDay), + byMonth: sortPeriods(byMonth), + byYear: sortPeriods(byYear), + byTemplate: Array.from(byTemplate.values()) + .sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')), + extras: Array.from(extras.values()) + .sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')), + days, + }; +} diff --git a/server/dist/server/src/storage/StorageInterface.js b/server/dist/server/src/storage/StorageInterface.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/server/dist/server/src/storage/StorageInterface.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/server/dist/server/src/storage/index.js b/server/dist/server/src/storage/index.js new file mode 100644 index 0000000..3850abd --- /dev/null +++ b/server/dist/server/src/storage/index.js @@ -0,0 +1,36 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.storageReady = void 0; +exports.default = getStorage; +const dotenv_1 = __importDefault(require("dotenv")); +const path_1 = __importDefault(require("path")); +const json_1 = __importDefault(require("./json")); +const redis_1 = __importDefault(require("./redis")); +const memory_1 = __importDefault(require("./memory")); +const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; +dotenv_1.default.config({ path: path_1.default.resolve(__dirname, `../../.env.${ENVIRONMENT}`) }); +const JSON_KEY = 'json'; +const REDIS_KEY = 'redis'; +const MEMORY_KEY = 'memory'; +let storage; +if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) { + storage = new json_1.default(); +} +else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) { + storage = new redis_1.default(); +} +else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) { + storage = new memory_1.default(); +} +else { + throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'"); +} +exports.storageReady = storage.initialize + ? storage.initialize() + : Promise.resolve(); +function getStorage() { + return storage; +} diff --git a/server/dist/server/src/storage/json.js b/server/dist/server/src/storage/json.js new file mode 100644 index 0000000..9f857d7 --- /dev/null +++ b/server/dist/server/src/storage/json.js @@ -0,0 +1,76 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const simple_json_db_1 = __importDefault(require("simple-json-db")); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const dbPath = path.resolve(__dirname, '../../data/db.json'); +const dbDir = path.dirname(dbPath); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); +} +const db = new simple_json_db_1.default(dbPath); +/** + * Implementace úložiště používající JSON soubor. Určená pro vývoj. + */ +class JsonStorage { + hasData(key) { + return Promise.resolve(db.has(key)); + } + getData(key) { + return Promise.resolve(db.get(key)); + } + setData(key, data) { + db.set(key, data); + return Promise.resolve(); + } + listKeys(contains) { + const keys = Object.keys(db.JSON()); + return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); + } + updateData(key, mutator) { + const current = db.get(key); + const next = mutator(current); + db.set(key, next); + return Promise.resolve(next); + } + healthCheck() { + return Promise.resolve(true); + } +} +exports.default = JsonStorage; diff --git a/server/dist/server/src/storage/memory.js b/server/dist/server/src/storage/memory.js new file mode 100644 index 0000000..5744590 --- /dev/null +++ b/server/dist/server/src/storage/memory.js @@ -0,0 +1,37 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resetMemoryStorage = resetMemoryStorage; +const store = new Map(); +/** Vymaže všechna data z in-memory úložiště. Slouží k resetu mezi testy. */ +function resetMemoryStorage() { + store.clear(); +} +/** + * In-memory implementace úložiště. Používá se výhradně v testovacím prostředí. + */ +class MemoryStorage { + hasData(key) { + return Promise.resolve(store.has(key)); + } + getData(key) { + return Promise.resolve(store.get(key)); + } + setData(key, data) { + store.set(key, data); + return Promise.resolve(); + } + listKeys(contains) { + const keys = Array.from(store.keys()); + return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); + } + updateData(key, mutator) { + const current = store.get(key); + const next = mutator(current); + store.set(key, next); + return Promise.resolve(next); + } + healthCheck() { + return Promise.resolve(true); + } +} +exports.default = MemoryStorage; diff --git a/server/dist/server/src/storage/redis.js b/server/dist/server/src/storage/redis.js new file mode 100644 index 0000000..407d6b5 --- /dev/null +++ b/server/dist/server/src/storage/redis.js @@ -0,0 +1,77 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.shutdownRedisStorage = shutdownRedisStorage; +const redis_1 = require("redis"); +/** Počet pokusů o atomický zápis, než se WATCH/MULTI/EXEC smyčka vzdá. */ +const MAX_RETRIES = 10; +let client; +/** Ukončí spojení hlavního Redis klienta (volá se při graceful shutdownu). */ +async function shutdownRedisStorage() { + if (client?.isOpen) { + await client.quit(); + } +} +/** + * Implementace úložiště nad Redisem. Určená pro produkční provoz. + */ +class RedisStorage { + async initialize() { + const host = process.env.REDIS_HOST ?? 'localhost'; + const port = Number(process.env.REDIS_PORT ?? 6379); + client = (0, redis_1.createClient)({ socket: { host, port } }); + client.on('error', error => console.error('Chyba Redisu:', error)); + await client.connect(); + console.log(`Připojeno k Redisu na ${host}:${port}`); + } + getClient() { + if (!client) { + throw new Error('Redis klient není inicializovaný'); + } + return client; + } + async hasData(key) { + return (await this.getClient().exists(key)) === 1; + } + async getData(key) { + const value = await this.getClient().get(key); + return value == null ? undefined : JSON.parse(value); + } + async setData(key, data) { + await this.getClient().set(key, JSON.stringify(data)); + } + async listKeys(contains) { + const keys = []; + for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) { + keys.push(...(Array.isArray(key) ? key : [key])); + } + return keys; + } + async updateData(key, mutator) { + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + // Izolovaný klient, aby WATCH nekolidoval s ostatními požadavky + const attemptWrite = async (isolated) => { + await isolated.watch(key); + const value = await isolated.get(key); + const current = value == null ? undefined : JSON.parse(value); + const next = mutator(current); + const execResult = await isolated.multi().set(key, JSON.stringify(next)).exec(); + // null z EXEC = klíč se mezitím změnil, zkusíme znovu + return execResult === null ? undefined : next; + }; + const result = await this.getClient().executeIsolated(attemptWrite); + if (result !== undefined) { + return result; + } + } + throw new Error(`Nepodařilo se atomicky uložit klíč ${key} ani po ${MAX_RETRIES} pokusech`); + } + async healthCheck() { + try { + return (await this.getClient().ping()) === 'PONG'; + } + catch { + return false; + } + } +} +exports.default = RedisStorage; diff --git a/server/dist/server/src/utils.js b/server/dist/server/src/utils.js new file mode 100644 index 0000000..9152f79 --- /dev/null +++ b/server/dist/server/src/utils.js @@ -0,0 +1,116 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.applyMultiplier = exports.normalizeMultiplier = exports.NotFoundError = exports.BadRequestError = void 0; +exports.formatDate = formatDate; +exports.getToday = getToday; +exports.isValidIsoDate = isValidIsoDate; +exports.requireIsoDate = requireIsoDate; +exports.parseToken = parseToken; +exports.listDatesInRange = listDatesInRange; +/** Regulární výraz pro datum ve formátu YYYY-MM-DD. */ +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +/** Chyba způsobená neplatným vstupem uživatele — mapuje se na HTTP 400. */ +class BadRequestError extends Error { +} +exports.BadRequestError = BadRequestError; +/** Chyba hledaného, ale neexistujícího záznamu — mapuje se na HTTP 404. */ +class NotFoundError extends Error { +} +exports.NotFoundError = NotFoundError; +/** Vrátí datum v ISO formátu (YYYY-MM-DD) v lokální časové zóně. */ +function formatDate(date) { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()); + return `${year}-${month}-${day}`; +} +/** Vrátí dnešní datum s vynulovaným časem. */ +function getToday() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +} +/** + * Ověří, že řetězec je datum ve formátu YYYY-MM-DD a odpovídá reálnému dni. + * Zachytí i přetečení typu 2024-02-31, které by `new Date` tiše posunul na březen. + */ +function isValidIsoDate(value) { + if (typeof value !== 'string' || !ISO_DATE_PATTERN.test(value)) { + return false; + } + const [year, month, day] = value.split('-').map(Number); + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day; +} +/** + * Vrátí datum z query parametru, nebo vyhodí {@link BadRequestError}. + * + * @param value hodnota query parametru + * @param name název parametru pro chybovou hlášku + */ +function requireIsoDate(value, name) { + if (!isValidIsoDate(value)) { + throw new BadRequestError(`Parametr '${name}' není platné datum ve formátu YYYY-MM-DD`); + } + return value; +} +/** Vrátí JWT token z hlavičky Authorization, pokud tam je. */ +function parseToken(req) { + return req.headers?.authorization?.split(' ')[1]; +} +/** + * Vrátí všechna data v rozsahu (včetně obou krajů) jako ISO řetězce. + * + * @param from první den rozsahu (YYYY-MM-DD) + * @param to poslední den rozsahu (YYYY-MM-DD) + */ +function listDatesInRange(from, to) { + const dates = []; + const [fromYear, fromMonth, fromDay] = from.split('-').map(Number); + const [toYear, toMonth, toDay] = to.split('-').map(Number); + const cursor = new Date(fromYear, fromMonth - 1, fromDay); + const end = new Date(toYear, toMonth - 1, toDay); + while (cursor <= end) { + dates.push(formatDate(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + return dates; +} +/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */ +const MAX_MULTIPLIER = 100; +/** + * Ověří násobek porce nebo aktivity a zaokrouhlí ho na setiny. + * + * Násobek 1 je výchozí stav, proto se vrací jako undefined a neukládá se — + * stejně jako jedna série u aktivit. + * + * @param value vstupní hodnota + */ +const normalizeMultiplier = (value) => { + if (value == null) + return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new BadRequestError("Násobek musí být číslo"); + } + const rounded = Math.round(value * 100) / 100; + if (rounded <= 0) { + throw new BadRequestError("Násobek musí být kladné číslo"); + } + if (rounded > MAX_MULTIPLIER) { + throw new BadRequestError(`Násobek může být nejvýše ${MAX_MULTIPLIER}`); + } + return rounded === 1 ? undefined : rounded; +}; +exports.normalizeMultiplier = normalizeMultiplier; +/** + * Vrátí kalorie záznamu po započtení násobku. + * + * Uložené kalorie jsou vždy za jednu porci (jedno provedení), aby šlo násobek + * měnit tam a zpět bez ztráty přesnosti zaokrouhlováním. Klient má stejnou + * funkci v `Utils.tsx` — musí dávat stejné výsledky. + * + * @param calories kcal jedné porce + * @param multiplier násobek, nevyplněný znamená 1 + */ +const applyMultiplier = (calories, multiplier) => calories == null ? undefined : Math.round(calories * (multiplier ?? 1)); +exports.applyMultiplier = applyMultiplier; diff --git a/server/dist/server/src/workoutPlans.js b/server/dist/server/src/workoutPlans.js new file mode 100644 index 0000000..181ea35 --- /dev/null +++ b/server/dist/server/src/workoutPlans.js @@ -0,0 +1,48 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dropOrphanedPlans = exports.recordWorkoutPlan = exports.getWorkoutPlans = void 0; +const storage_1 = __importDefault(require("./storage")); +const storage = (0, storage_1.default)(); +/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */ +const getKey = (login, date) => `workoutPlans:${login}:${date}`; +/** + * Vrátí plány šablon použitých v daném dni. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +const getWorkoutPlans = async (login, date) => await storage.getData(getKey(login, date)) ?? []; +exports.getWorkoutPlans = getWorkoutPlans; +/** + * Zapamatuje si plán použité šablony. + * + * @param login přihlašovací jméno uživatele + * @param date den, do kterého se šablona použila + * @param plan plán šablony + */ +const recordWorkoutPlan = async (login, date, plan) => { + await storage.updateData(getKey(login, date), current => [...(current ?? []), plan]); +}; +exports.recordWorkoutPlan = recordWorkoutPlan; +/** + * Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita. + * + * Smazání všech cviků šablony se bere jako vrácení jejího použití, ne jako + * neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve + * statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá. + * + * @param login přihlašovací jméno uživatele + * @param date datum dne + * @param remaining aktivity, které ve dni zůstaly + */ +const dropOrphanedPlans = async (login, date, remaining) => { + const key = getKey(login, date); + if (!await storage.hasData(key)) + return; + const ids = new Set(remaining.map(entry => entry.id)); + await storage.updateData(key, current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId)))); +}; +exports.dropOrphanedPlans = dropOrphanedPlans; diff --git a/server/dist/server/src/workouts.js b/server/dist/server/src/workouts.js new file mode 100644 index 0000000..1a77c3c --- /dev/null +++ b/server/dist/server/src/workouts.js @@ -0,0 +1,300 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createWorkoutTemplateFromActivities = exports.applyWorkoutTemplate = exports.deleteWorkoutTemplate = exports.saveWorkoutTemplate = exports.getWorkoutTemplates = void 0; +const crypto_1 = require("crypto"); +const types_gen_1 = require("../../types/gen/types.gen"); +const storage_1 = __importDefault(require("./storage")); +const activities_1 = require("./activities"); +const utils_1 = require("./utils"); +const workoutPlans_1 = require("./workoutPlans"); +const storage = (0, storage_1.default)(); +/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */ +const MAX_ITEMS = 50; +/** Klíč úložiště se šablonami tréninků jednoho uživatele. */ +const getKey = (login) => `workouts:${login}`; +/** Vrátí true, pokud je položka odkazem na jinou šablonu. */ +const isTemplateRef = (item) => item.kind === types_gen_1.WorkoutItemKind.SABLONA; +/** + * Doplní položkám druh, pokud chybí. + * + * Šablony uložené dřív, než přibylo skládání z jiných šablon, pole `kind` + * nemají — všechny jejich položky jsou cviky. + */ +const withKind = (template) => ({ + ...template, + items: template.items.map(item => ({ ...item, kind: item.kind ?? types_gen_1.WorkoutItemKind.CVIK })), +}); +/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */ +const itemCalories = (item) => Math.round((0, activities_1.getTotalQuantity)(item.quantity ?? 0, item.sets) / 100 * (item.caloriesPer100Units ?? 0)); +/** + * Doplní šabloně dopočítávaná pole — odhad kalorií (včetně vnořených šablon), + * názvy vnořených šablon a informaci, kolik jiných šablon ji používá. + */ +const withDerived = (template, all) => { + const byId = new Map(all.map(item => [item.id, item])); + let calories = 0; + let hasNested = false; + const items = template.items.map(item => { + if (!isTemplateRef(item)) { + calories += itemCalories(item); + return item; + } + hasNested = true; + const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined; + // Vnořená šablona smí obsahovat jen cviky, takže stačí sečíst její položky + const nestedCalories = (nested?.items ?? []).reduce((sum, nestedItem) => sum + itemCalories(nestedItem), 0); + calories += nestedCalories; + return { + ...item, + ...(nested ? { templateRefName: nested.name } : {}), + templateRefCalories: nestedCalories, + }; + }); + return { + id: template.id, + name: template.name, + items, + estimatedCalories: calories, + hasNested, + usedByCount: all.filter(other => other.id !== template.id + && other.items.some(item => isTemplateRef(item) && item.templateRefId === template.id)).length, + }; +}; +/** Načte uložené šablony uživatele se doplněným druhem položek. */ +const loadTemplates = async (login) => (await storage.getData(getKey(login)) ?? []).map(withKind); +/** + * Vrátí šablony tréninků uživatele seřazené podle názvu. + * + * @param login přihlašovací jméno uživatele + */ +const getWorkoutTemplates = async (login) => { + const stored = await loadTemplates(login); + return stored + .map(template => withDerived(template, stored)) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); +}; +exports.getWorkoutTemplates = getWorkoutTemplates; +/** Ověří a znormalizuje jednu položku šablony. */ +const normalizeItem = (raw, index) => { + const value = (raw ?? {}); + const kind = value.kind === types_gen_1.WorkoutItemKind.SABLONA ? types_gen_1.WorkoutItemKind.SABLONA : types_gen_1.WorkoutItemKind.CVIK; + if (kind === types_gen_1.WorkoutItemKind.SABLONA) { + if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) { + throw new utils_1.BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`); + } + // Název a odhad kalorií vnořené šablony si server dopočítá sám + return { kind, templateRefId: value.templateRefId }; + } + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!name.length) { + throw new utils_1.BadRequestError(`Položka ${index + 1} nemá název`); + } + if (!(0, activities_1.isActivityUnit)(value.unit)) { + throw new utils_1.BadRequestError(`Položka '${name}' nemá platnou jednotku`); + } + if (typeof value.quantity !== 'number' || !Number.isFinite(value.quantity) || value.quantity < 0) { + throw new utils_1.BadRequestError(`Položka '${name}' nemá platné množství`); + } + const rate = value.caloriesPer100Units; + const sets = value.sets; + const extraWeight = value.extraWeightGrams; + return { + kind, + name, + unit: value.unit, + quantity: Math.round(value.quantity), + // Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl + ...(typeof sets === 'number' && Number.isFinite(sets) && sets > 1 + ? { sets: Math.round(sets) } + : {}), + ...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0 + ? { caloriesPer100Units: Math.round(rate) } + : {}), + ...(typeof extraWeight === 'number' && Number.isFinite(extraWeight) && extraWeight > 0 + ? { extraWeightGrams: Math.round(extraWeight) } + : {}), + }; +}; +/** + * Ověří, že se zanořením šablon nepřekročí jedna úroveň. + * + * Kontroluje se obojí směr, protože obě cesty vedou ke dvěma úrovním: + * šablona nesmí odkazovat na šablonu, která sama něco vnořuje, a zároveň + * nesmí sama začít vnořovat, pokud ji už někdo jiný používá. + */ +const checkNesting = (id, items, all) => { + const byId = new Map(all.map(template => [template.id, template])); + const nestedRefs = items.filter(isTemplateRef); + for (const item of nestedRefs) { + if (item.templateRefId === id) { + throw new utils_1.BadRequestError("Šablona nemůže odkazovat sama na sebe"); + } + const nested = byId.get(item.templateRefId); + if (!nested) { + throw new utils_1.NotFoundError("Vnořená šablona nebyla nalezena"); + } + if (nested.items.some(isTemplateRef)) { + throw new utils_1.BadRequestError(`Šablona '${nested.name}' už sama skládá jiné šablony, nelze ji vnořit — zanoření je povolené jen na jednu úroveň`); + } + } + if (nestedRefs.length) { + const usedBy = all.find(template => template.id !== id + && template.items.some(item => isTemplateRef(item) && item.templateRefId === id)); + if (usedBy) { + throw new utils_1.BadRequestError(`Tuto šablonu už skládá šablona '${usedBy.name}', takže do ní nelze vnořit další — zanoření je povolené jen na jednu úroveň`); + } + } +}; +/** + * Založí novou šablonu, nebo přepíše existující podle předaného id. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor upravované šablony, nebo nic pro založení nové + * @param name název šablony + * @param items položky šablony + */ +const saveWorkoutTemplate = async (login, id, name, items) => { + if (typeof name !== 'string' || !name.trim().length) { + throw new utils_1.BadRequestError("Nebyl předán název šablony"); + } + if (!Array.isArray(items) || !items.length) { + throw new utils_1.BadRequestError("Šablona musí mít alespoň jednu položku"); + } + if (items.length > MAX_ITEMS) { + throw new utils_1.BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`); + } + const normalized = { + id: typeof id === 'string' && id.length ? id : (0, crypto_1.randomUUID)(), + name: name.trim(), + items: items.map(normalizeItem), + }; + checkNesting(normalized.id, normalized.items, await loadTemplates(login)); + await storage.updateData(getKey(login), current => { + const others = (current ?? []).filter(template => template.id !== normalized.id); + return [...others, normalized]; + }); + return (0, exports.getWorkoutTemplates)(login); +}; +exports.saveWorkoutTemplate = saveWorkoutTemplate; +/** + * Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze — + * jinak by ve složené šabloně zůstal odkaz do prázdna. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor šablony + */ +const deleteWorkoutTemplate = async (login, id) => { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony"); + } + const all = await loadTemplates(login); + if (!all.some(template => template.id === id)) { + throw new utils_1.NotFoundError("Šablona nebyla nalezena"); + } + const usedBy = all.find(template => template.id !== id + && template.items.some(item => isTemplateRef(item) && item.templateRefId === id)); + if (usedBy) { + throw new utils_1.BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`); + } + await storage.updateData(getKey(login), current => (current ?? []).filter(template => template.id !== id)); + return (0, exports.getWorkoutTemplates)(login); +}; +exports.deleteWorkoutTemplate = deleteWorkoutTemplate; +/** + * Založí do zvoleného dne všechny cviky šablony, včetně těch z vnořených šablon. + * + * Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat, + * aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace, + * odkud přišly. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor šablony + * @param date den, do kterého se položky založí + */ +const applyWorkoutTemplate = async (login, id, date) => { + if (typeof id !== 'string' || !id.length) { + throw new utils_1.BadRequestError("Nebyl předán identifikátor šablony"); + } + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + const all = await loadTemplates(login); + const template = all.find(item => item.id === id); + if (!template) { + throw new utils_1.NotFoundError("Šablona nebyla nalezena"); + } + // Odkazy na vnořené šablony se rozbalí na jejich cviky; hlubší zanoření nemůže vzniknout + const byId = new Map(all.map(item => [item.id, item])); + const exercises = template.items.flatMap(item => isTemplateRef(item) ? (byId.get(item.templateRefId)?.items ?? []) : [item]); + const now = new Date().toISOString(); + const entries = exercises.map(item => ({ + id: (0, crypto_1.randomUUID)(), + date, + name: item.name, + unit: item.unit, + quantity: item.quantity, + ...(item.sets != null ? { sets: item.sets } : {}), + ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), + ...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}), + calories: itemCalories(item), + templateId: template.id, + createdAt: now, + })); + await (0, activities_1.addActivities)(login, date, entries); + await (0, workoutPlans_1.recordWorkoutPlan)(login, date, { + templateId: template.id, + templateName: template.name, + appliedAt: now, + items: entries.map(entry => ({ + entryId: entry.id, + name: entry.name, + unit: entry.unit, + quantity: entry.quantity, + ...(entry.sets != null ? { sets: entry.sets } : {}), + calories: entry.calories ?? 0, + })), + }); + return date; +}; +exports.applyWorkoutTemplate = applyWorkoutTemplate; +/** + * Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne. + * + * Slouží k opačnému směru než {@link applyWorkoutTemplate}: trénink odcvičený + * "na truc" jde takhle povýšit na šablonu a příště použít jedním kliknutím. + * Aktivity zůstanou v dni beze změny. + * + * @param login přihlašovací jméno uživatele + * @param date den, ze kterého se aktivity berou + * @param name název nové šablony + * @param ids identifikátory aktivit; bez nich se použijí všechny aktivity dne + */ +const createWorkoutTemplateFromActivities = async (login, date, name, ids) => { + if (!(0, utils_1.isValidIsoDate)(date)) { + throw new utils_1.BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (ids != null && !Array.isArray(ids)) { + throw new utils_1.BadRequestError("Seznam aktivit musí být pole identifikátorů"); + } + const selected = ids == null ? undefined : new Set(ids.map(String)); + const activities = (await (0, activities_1.getActivityDay)(login, date)).entries + .filter(entry => !selected || selected.has(entry.id)); + if (!activities.length) { + throw new utils_1.BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit"); + } + const items = activities.map(entry => ({ + kind: types_gen_1.WorkoutItemKind.CVIK, + name: entry.name, + unit: entry.unit, + quantity: entry.quantity, + ...(entry.sets != null ? { sets: entry.sets } : {}), + // Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá + ...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}), + ...(entry.extraWeightGrams != null ? { extraWeightGrams: entry.extraWeightGrams } : {}), + })); + return (0, exports.saveWorkoutTemplate)(login, undefined, name, items); +}; +exports.createWorkoutTemplateFromActivities = createWorkoutTemplateFromActivities; diff --git a/server/dist/types/gen/client.gen.js b/server/dist/types/gen/client.gen.js new file mode 100644 index 0000000..4def74b --- /dev/null +++ b/server/dist/types/gen/client.gen.js @@ -0,0 +1,6 @@ +"use strict"; +// This file is auto-generated by @hey-api/openapi-ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.client = void 0; +const client_fetch_1 = require("@hey-api/client-fetch"); +exports.client = (0, client_fetch_1.createClient)((0, client_fetch_1.createConfig)()); diff --git a/server/dist/types/gen/index.js b/server/dist/types/gen/index.js new file mode 100644 index 0000000..98cd3e9 --- /dev/null +++ b/server/dist/types/gen/index.js @@ -0,0 +1,19 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// This file is auto-generated by @hey-api/openapi-ts +__exportStar(require("./types.gen"), exports); +__exportStar(require("./sdk.gen"), exports); diff --git a/server/dist/types/gen/sdk.gen.js b/server/dist/types/gen/sdk.gen.js new file mode 100644 index 0000000..4a3db2c --- /dev/null +++ b/server/dist/types/gen/sdk.gen.js @@ -0,0 +1,585 @@ +"use strict"; +// This file is auto-generated by @hey-api/openapi-ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.importLuncher = exports.saveProfile = exports.saveBasalCalories = exports.saveSourceRate = exports.getSettings = exports.searchCalories = exports.createMealTemplateFromMeal = exports.applyMealTemplate = exports.deleteMealTemplate = exports.saveMealTemplate = exports.getMealTemplates = exports.createWorkoutTemplateFromActivities = exports.applyWorkoutTemplate = exports.deleteWorkoutTemplate = exports.saveWorkoutTemplate = exports.getWorkoutTemplates = exports.setActivityMultiplier = exports.deleteActivity = exports.updateActivity = exports.addActivity = exports.saveSleep = exports.getDayOverview = exports.getFoodStats = exports.setMealMultiplier = exports.deleteMeal = exports.updateMeal = exports.addMeal = exports.getRange = exports.getDay = exports.getConfig = exports.login = void 0; +const client_gen_1 = require("./client.gen"); +/** + * Přihlásí uživatele a vrátí JWT token. + */ +const login = (options) => { + return (options?.client ?? client_gen_1.client).post({ + url: '/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.login = login; +/** + * Vrátí veřejnou runtime konfiguraci klienta. + */ +const getConfig = (options) => { + return (options?.client ?? client_gen_1.client).get({ + url: '/config', + ...options + }); +}; +exports.getConfig = getConfig; +/** + * Vrátí jídla přihlášeného uživatele za jeden den. + */ +const getDay = (options) => { + return (options?.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/day', + ...options + }); +}; +exports.getDay = getDay; +/** + * Vrátí jídla přihlášeného uživatele za rozsah dnů. + */ +const getRange = (options) => { + return (options.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/range', + ...options + }); +}; +exports.getRange = getRange; +/** + * Přidá jídlo do zvoleného dne. + */ +const addMeal = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/add', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.addMeal = addMeal; +/** + * Upraví existující záznam o jídle. + */ +const updateMeal = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/update', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.updateMeal = updateMeal; +/** + * Smaže záznam o jídle. + */ +const deleteMeal = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.deleteMeal = deleteMeal; +/** + * Nastaví násobek porce jídla přímo z přehledu dne, bez úpravy ostatních údajů. + * + */ +const setMealMultiplier = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/meals/multiplier', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.setMealMultiplier = setMealMultiplier; +/** + * Vrátí statistiky útraty a jídel za zvolené období. + */ +const getFoodStats = (options) => { + return (options.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/stats/summary', + ...options + }); +}; +exports.getFoodStats = getFoodStats; +/** + * Vrátí kompletní přehled dne — jídlo, pohyb a energetickou bilanci. + */ +const getDayOverview = (options) => { + return (options?.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/day', + ...options + }); +}; +exports.getDayOverview = getDayOverview; +/** + * Uloží délku spánku pro zvolený den. + */ +const saveSleep = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/day/sleep', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveSleep = saveSleep; +/** + * Přidá pohybovou aktivitu do zvoleného dne. + */ +const addActivity = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/activities/add', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.addActivity = addActivity; +/** + * Upraví existující aktivitu. + */ +const updateActivity = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/activities/update', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.updateActivity = updateActivity; +/** + * Smaže aktivitu. + */ +const deleteActivity = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/activities/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.deleteActivity = deleteActivity; +/** + * Nastaví násobek aktivity přímo z přehledu dne, bez úpravy ostatních údajů. + * + */ +const setActivityMultiplier = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/activities/multiplier', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.setActivityMultiplier = setActivityMultiplier; +/** + * Vrátí šablony tréninků přihlášeného uživatele. + */ +const getWorkoutTemplates = (options) => { + return (options?.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/workouts', + ...options + }); +}; +exports.getWorkoutTemplates = getWorkoutTemplates; +/** + * Založí novou šablonu tréninku, nebo upraví existující. + */ +const saveWorkoutTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/workouts/save', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveWorkoutTemplate = saveWorkoutTemplate; +/** + * Smaže šablonu tréninku. + */ +const deleteWorkoutTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/workouts/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.deleteWorkoutTemplate = deleteWorkoutTemplate; +/** + * Založí do zvoleného dne všechny položky šablony tréninku. + */ +const applyWorkoutTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/workouts/apply', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.applyWorkoutTemplate = applyWorkoutTemplate; +/** + * Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne. + * Bez výčtu identifikátorů se použijí všechny aktivity daného dne. + * + */ +const createWorkoutTemplateFromActivities = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/workouts/fromActivities', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.createWorkoutTemplateFromActivities = createWorkoutTemplateFromActivities; +/** + * Vrátí šablony jídel přihlášeného uživatele. + */ +const getMealTemplates = (options) => { + return (options?.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/mealTemplates', + ...options + }); +}; +exports.getMealTemplates = getMealTemplates; +/** + * Založí novou šablonu jídla, nebo upraví existující. + */ +const saveMealTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/mealTemplates/save', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveMealTemplate = saveMealTemplate; +/** + * Smaže šablonu jídla. + */ +const deleteMealTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/mealTemplates/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.deleteMealTemplate = deleteMealTemplate; +/** + * Založí jídlo ze šablony do zvoleného dne. + */ +const applyMealTemplate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/mealTemplates/apply', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.applyMealTemplate = applyMealTemplate; +/** + * Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. + */ +const createMealTemplateFromMeal = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/mealTemplates/fromMeal', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.createMealTemplateFromMeal = createMealTemplateFromMeal; +/** + * Najde návrhy energetické hodnoty pro název jídla — z vlastní knihovny + * dřívějších zadání a od externího poskytovatele. + * + */ +const searchCalories = (options) => { + return (options.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/calories/search', + ...options + }); +}; +exports.searchCalories = searchCalories; +/** + * Vrátí nastavení přihlášeného uživatele. + */ +const getSettings = (options) => { + return (options?.client ?? client_gen_1.client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/settings', + ...options + }); +}; +exports.getSettings = getSettings; +/** + * Uloží nebo smaže cenu za 100 g u jednoho zdroje. + */ +const saveSourceRate = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/settings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveSourceRate = saveSourceRate; +/** + * Uloží klidový výdej (bazální metabolismus) přihlášeného uživatele. + */ +const saveBasalCalories = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/settings/basal', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveBasalCalories = saveBasalCalories; +/** + * Uloží tělesné údaje uživatele a přepočte z nich klidový výdej. + */ +const saveProfile = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/settings/profile', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.saveProfile = saveProfile; +/** + * Naimportuje měsíční přehled vyexportovaný z Luncheru (XLSX, CSV nebo JSON). + * Řádky se založí do dnů, ke kterým patří, ve výchozím stavu jako oběd. + * + */ +const importLuncher = (options) => { + return (options.client ?? client_gen_1.client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/import/luncher', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; +exports.importLuncher = importLuncher; diff --git a/server/dist/types/gen/types.gen.js b/server/dist/types/gen/types.gen.js new file mode 100644 index 0000000..1419a35 --- /dev/null +++ b/server/dist/types/gen/types.gen.js @@ -0,0 +1,66 @@ +"use strict"; +// This file is auto-generated by @hey-api/openapi-ts +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MealTemplateKind = exports.ImportMode = exports.WorkoutItemKind = exports.Pohlavi = exports.ActivityUnit = exports.MealType = void 0; +/** + * Typ jídla (chod v rámci dne). Importy z Luncheru se zakládají jako OBED, + * pokud se při importu neurčí jinak. + * + */ +exports.MealType = { + SNIDANE: 'SNIDANE', + DOPOLEDNI_SVACINA: 'DOPOLEDNI_SVACINA', + OBED: 'OBED', + ODPOLEDNI_SVACINA: 'ODPOLEDNI_SVACINA', + VECERE: 'VECERE', + JINE: 'JINE' +}; +/** + * Jednotka, ve které se aktivita měří + */ +exports.ActivityUnit = { + KROKY: 'KROKY', + MINUTY: 'MINUTY', + SEKUNDY: 'SEKUNDY', + KM: 'KM', + OPAKOVANI: 'OPAKOVANI' +}; +/** + * Pohlaví — vstupuje do výpočtu bazálního metabolismu + */ +exports.Pohlavi = { + MUZ: 'MUZ', + ZENA: 'ZENA' +}; +/** + * Druh položky šablony — buď přímo cvik, nebo odkaz na jinou šablonu. + * Zanoření je povolené jen na jednu úroveň. + * + */ +exports.WorkoutItemKind = { + CVIK: 'CVIK', + SABLONA: 'SABLONA' +}; +/** + * Jak naložit s dřívějším importem týchž dnů. + * PRIDAT — nové řádky se přidají, řádky už jednou naimportované se přeskočí. + * NAHRADIT — v dnech, do kterých se importuje, se dřívější importované + * záznamy odeberou a založí se znovu. Ručně zadaná jídla zůstávají vždy. + * + */ +exports.ImportMode = { + PRIDAT: 'PRIDAT', + NAHRADIT: 'NAHRADIT' +}; +/** + * Druh šablony jídla. + * HOTOVE — objednané nebo koupené jídlo; stačí si pamatovat výsledné + * hodnoty porce (kcal, cenu), aby se nemusely dohledávat znovu. + * RECEPT — vařené jídlo; eviduje se, z čeho a jak vzniklo, a hodnoty + * porce se dopočítají ze surovin. + * + */ +exports.MealTemplateKind = { + HOTOVE: 'HOTOVE', + RECEPT: 'RECEPT' +}; diff --git a/server/dist/types/index.js b/server/dist/types/index.js new file mode 100644 index 0000000..03b4a50 --- /dev/null +++ b/server/dist/types/index.js @@ -0,0 +1,17 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./gen"), exports); diff --git a/server/dist/types/openapi-ts.config.js b/server/dist/types/openapi-ts.config.js new file mode 100644 index 0000000..ea8a765 --- /dev/null +++ b/server/dist/types/openapi-ts.config.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const openapi_ts_1 = require("@hey-api/openapi-ts"); +exports.default = { + input: 'api.yml', + output: 'gen', + plugins: [ + ...openapi_ts_1.defaultPlugins, + '@hey-api/client-fetch', + { + enums: 'javascript', + name: '@hey-api/typescript', + }, + ], +}; diff --git a/server/package.json b/server/package.json index da6dfeb..6bbc972 100644 --- a/server/package.json +++ b/server/package.json @@ -5,8 +5,8 @@ "license": "MIT", "private": true, "scripts": { - "start": "ts-node src/index.ts", - "startReload": "nodemon --watch src src/index.ts", + "start": "cross-env NODE_ENV=development ts-node src/index.ts", + "startReload": "cross-env NODE_ENV=development nodemon --watch src src/index.ts", "build": "tsc -p .", "test": "jest" }, @@ -21,6 +21,7 @@ "@types/node": "^24.10.0", "@types/supertest": "^6.0.0", "babel-jest": "^30.2.0", + "cross-env": "^10.1.0", "jest": "^30.2.0", "nodemon": "^3.1.10", "supertest": "^7.0.0", diff --git a/server/src/bodyMetrics.ts b/server/src/bodyMetrics.ts new file mode 100644 index 0000000..22c7961 --- /dev/null +++ b/server/src/bodyMetrics.ts @@ -0,0 +1,154 @@ +import { BmiKategorie, BodyMetrics, Goals, PersonalProfile, Pohlavi, WeightEntry } from "../../types/gen/types.gen"; +import { BadRequestError } from "./utils"; +import { WEIGHT_LIMITS } from "./profile"; + +/** + * Energie odpovídající jednomu kilogramu tělesné hmotnosti. Běžně používaná + * aproximace (tuková tkáň ~ 7 700 kcal/kg); skutečná změna hmotnosti se od ní + * liší vodou a svaly, pro plánování tempa ale stačí. + */ +const KCAL_PER_KG = 7700; + +/** Meze cílů — chrání před překlepem, ne před nerozumným plánem. */ +const WEEKLY_CHANGE_LIMITS = { min: -1500, max: 1500 }; +const PROTEIN_PER_KG_LIMITS = { min: 0.5, max: 4 }; + +/** Hranice pásem BMI podle WHO. */ +const BMI_NORMAL_MIN = 18.5; +const BMI_OVERWEIGHT_MIN = 25; +const BMI_OBESITY_MIN = 30; + +const roundTenth = (value: number): number => Math.round(value * 10) / 10; + +/** + * Spočítá BMI — hmotnost v kg dělená druhou mocninou výšky v metrech. + * + * @param weightGrams hmotnost v gramech + * @param heightCm výška v centimetrech + */ +export const computeBmi = (weightGrams: number, heightCm: number): number => + roundTenth(weightGrams / 1000 / (heightCm / 100) ** 2); + +/** Zařadí BMI do pásma podle WHO. */ +export const getBmiCategory = (bmi: number): BmiKategorie => { + if (bmi < BMI_NORMAL_MIN) return BmiKategorie.PODVAHA; + if (bmi < BMI_OVERWEIGHT_MIN) return BmiKategorie.NORMA; + if (bmi < BMI_OBESITY_MIN) return BmiKategorie.NADVAHA; + return BmiKategorie.OBEZITA; +}; + +/** Hmotnost v gramech pro dané BMI a výšku, zaokrouhlená na desítky dekagramů. */ +const weightForBmi = (bmi: number, heightCm: number): number => + Math.round(bmi * (heightCm / 100) ** 2 * 10) * 100; + +/** + * Odhadne podíl tělesného tuku metodou US Navy (obvody v cm): + * + * ``` + * muž: 495 / (1,0324 − 0,19077 × log10(pas − krk) + 0,15456 × log10(výška)) − 450 + * žena: 495 / (1,29579 − 0,35004 × log10(pas + boky − krk) + 0,221 × log10(výška)) − 450 + * ``` + * + * Odchylka proti měření DEXA bývá kolem ±3–4 procentních bodů — hodí se hlavně + * ke sledování vývoje, ne jako přesná hodnota. + * + * @param profile tělesné údaje s obvody + * @returns procento tuku, nebo nic, když chybí údaje nebo jsou obvody nesmyslné + */ +export const computeBodyFat = (profile?: PersonalProfile): number | undefined => { + if (!profile?.sex || !profile.heightCm || !profile.waistCm || !profile.neckCm) return undefined; + const isMale = profile.sex === Pohlavi.MUZ; + if (!isMale && !profile.hipCm) return undefined; + const circumference = isMale + ? profile.waistCm - profile.neckCm + : profile.waistCm + profile.hipCm! - profile.neckCm; + if (circumference <= 0) return undefined; + const density = isMale + ? 1.0324 - 0.19077 * Math.log10(circumference) + 0.15456 * Math.log10(profile.heightCm) + : 1.29579 - 0.35004 * Math.log10(circumference) + 0.221 * Math.log10(profile.heightCm); + const result = roundTenth(495 / density - 450); + // Mimo tenhle rozsah jde o překlep v obvodech, ne o skutečný odhad + return result > 0 && result < 75 ? result : undefined; +}; + +/** + * Cílová denní bilance podle týdenního tempa změny hmotnosti — půl kila týdně + * dolů je zhruba −550 kcal denně. + */ +export const getTargetDailyBalance = (goals?: Goals): number | undefined => + goals?.weeklyChangeGrams != null + ? Math.round(goals.weeklyChangeGrams / 1000 * KCAL_PER_KG / 7) + : undefined; + +/** Cílový denní příjem bílkovin v gramech podle hmotnosti. */ +export const getProteinTarget = (goals?: Goals, weightGrams?: number): number | undefined => + goals?.proteinPerKg != null && weightGrams != null + ? Math.round(weightGrams / 1000 * goals.proteinPerKg) + : undefined; + +/** + * Za kolik týdnů se při zvoleném tempu dosáhne cílové hmotnosti. Jen když + * tempo vede k cíli — při nulovém tempu nebo opačném směru nic. + */ +const getWeeksToTarget = (goals?: Goals, weightGrams?: number): number | undefined => { + if (!goals?.weeklyChangeGrams || goals.targetWeightGrams == null || weightGrams == null) return undefined; + const weeks = (goals.targetWeightGrams - weightGrams) / goals.weeklyChangeGrams; + return weeks > 0 ? Math.ceil(weeks) : undefined; +}; + +/** + * Spočítá ukazatele z profilu, posledního vážení a cílů. Každý ukazatel se + * vynechá, když pro něj nejsou údaje. + * + * @param profile tělesné údaje + * @param goals cíle uživatele + * @param latestWeight poslední vážení; bez něj se použije hmotnost z profilu + */ +export const computeBodyMetrics = ( + profile?: PersonalProfile, + goals?: Goals, + latestWeight?: WeightEntry, +): BodyMetrics => { + const weightGrams = latestWeight?.weightGrams ?? profile?.weightGrams; + const heightCm = profile?.heightCm; + const bmi = weightGrams != null && heightCm ? computeBmi(weightGrams, heightCm) : undefined; + const result: BodyMetrics = { + weightGrams, + weightDate: latestWeight?.date, + bmi, + bmiCategory: bmi != null ? getBmiCategory(bmi) : undefined, + healthyWeightMinGrams: heightCm ? weightForBmi(BMI_NORMAL_MIN, heightCm) : undefined, + healthyWeightMaxGrams: heightCm ? weightForBmi(BMI_OVERWEIGHT_MIN, heightCm) : undefined, + bodyFatPercent: computeBodyFat(profile), + targetDailyBalance: getTargetDailyBalance(goals), + proteinTargetGrams: getProteinTarget(goals, weightGrams), + weeksToTarget: getWeeksToTarget(goals, weightGrams), + }; + return Object.fromEntries(Object.entries(result).filter(([, value]) => value != null)) as BodyMetrics; +}; + +/** Ověří volitelné číslo v rozsahu. */ +const normalizeRanged = ( + value: unknown, name: string, limits: { min: number, max: number }, round: (value: number) => number, +): number | undefined => { + if (value == null) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new BadRequestError(`Pole '${name}' musí být číslo`); + } + const rounded = round(value); + if (rounded < limits.min || rounded > limits.max) { + throw new BadRequestError(`Pole '${name}' je mimo rozsah (${limits.min}–${limits.max})`); + } + return rounded; +}; + +/** Ověří a znormalizuje cíle. Nevyplněné hodnoty se vynechají. */ +export const normalizeGoals = (input: unknown): Goals => { + const value = (input ?? {}) as Record; + const result: Goals = { + weeklyChangeGrams: normalizeRanged(value.weeklyChangeGrams, 'weeklyChangeGrams', WEEKLY_CHANGE_LIMITS, Math.round), + targetWeightGrams: normalizeRanged(value.targetWeightGrams, 'targetWeightGrams', WEIGHT_LIMITS, Math.round), + proteinPerKg: normalizeRanged(value.proteinPerKg, 'proteinPerKg', PROTEIN_PER_KG_LIMITS, roundTenth), + }; + return Object.fromEntries(Object.entries(result).filter(([, item]) => item != null)) as Goals; +}; diff --git a/server/src/dayOverview.ts b/server/src/dayOverview.ts index 67adccd..f09af25 100644 --- a/server/src/dayOverview.ts +++ b/server/src/dayOverview.ts @@ -3,6 +3,9 @@ import { getDay } from "./meals"; import { getActivityDay } from "./activities"; import { getSettings } from "./settings"; import { getSleep } from "./sleep"; +import { findWeightOn, getWeights } from "./weights"; +import { computeBasalCalories } from "./profile"; +import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics"; /** * Spočítá energetickou bilanci dne. @@ -18,8 +21,14 @@ import { getSleep } from "./sleep"; * @param intake přijaté kcal z jídla * @param activityBurn spálené kcal pohybem * @param basal klidový výdej v kcal za den, nebo nic + * @param targetBalance cílová bilance dne podle tempa změny hmotnosti, nebo nic */ -export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => { +export const buildEnergyBalance = ( + intake: number, + activityBurn: number, + basal?: number, + targetBalance?: number, +): EnergyBalance => { const resolvedBasal = basal ?? 0; const totalBurn = resolvedBasal + activityBurn; return { @@ -29,28 +38,43 @@ export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: totalBurn, balance: intake - totalBurn, hasBasal: !!basal, + ...(targetBalance != null ? { targetBalance } : {}), }; }; /** - * Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci. + * Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci. + * + * Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto + * dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím + * zpětně nepřepočítají. Bez vážení se použije hmotnost z profilu. * * @param login přihlašovací jméno uživatele * @param date datum ve formátu YYYY-MM-DD */ export const getDayOverview = async (login: string, date: string): Promise => { - const [meals, activities, settings, sleepMinutes] = await Promise.all([ + const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([ getDay(login, date), getActivityDay(login, date), getSettings(login), getSleep(login, date), + getWeights(login), ]); + const weightGrams = findWeightOn(weightList, date)?.weightGrams + ?? settings.profile?.weightGrams + ?? weightList[0]?.weightGrams; + // Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů + const basal = settings.basalCalories ?? computeBasalCalories(settings.profile, weightGrams); + const dayWeight = weightList.find(entry => entry.date === date)?.weightGrams; + const proteinTargetGrams = getProteinTarget(settings.goals, weightGrams); return { date, meals, activities, - // Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů - energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories), + energy: buildEnergyBalance( + meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)), ...(sleepMinutes ? { sleepMinutes } : {}), + ...(dayWeight != null ? { weightGrams: dayWeight } : {}), + ...(proteinTargetGrams != null ? { proteinTargetGrams } : {}), }; }; diff --git a/server/src/luncherImport.ts b/server/src/luncherImport.ts index 38691f9..101f151 100644 --- a/server/src/luncherImport.ts +++ b/server/src/luncherImport.ts @@ -1,7 +1,7 @@ import ExcelJS from 'exceljs'; import { randomUUID } from 'crypto'; import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen"; -import { addEntries, findMealsForDays, isMealType } from "./meals"; +import { addEntries, createNutritionFiller, findAllMeals, findMealsForDays, isMealType } from "./meals"; import { BadRequestError, isValidIsoDate } from "./utils"; /** Označení zdroje, kterým se importované záznamy značí. */ @@ -376,6 +376,10 @@ const isImportMode = (value: unknown): value is ImportMode => * dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se * v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech. * + * Luncher kalorie nezná, takže se importovaným jídlům doplní energie a živiny + * z historie — z posledního jídla stejného názvu, které je má (viz + * `createNutritionFiller`). + * * Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už * v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý * import pak dostane jen vybrané klíče. @@ -423,6 +427,7 @@ export async function importLuncherExport( const dates = Array.from(new Set(parsed.rows.map(row => row.date))); const existing = await findMealsForDays(login, dates); + const fillNutrition = createNutritionFiller(await findAllMeals(login)); // Při nahrazení se dřívější import založí znovu, duplicitou tedy není const existingKeys = new Set(replace ? [] @@ -439,7 +444,7 @@ export async function importLuncherExport( skipped++; continue; } - candidates.push(toMealEntry(row, mealType, importKey)); + candidates.push(fillNutrition(toMealEntry(row, mealType, importKey))); } const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String)); diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts index 0d8ae19..724274b 100644 --- a/server/src/mealTemplates.ts +++ b/server/src/mealTemplates.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'crypto'; -import { MealTemplate, MealTemplateKind, MealType, RecipeIngredient } from "../../types/gen/types.gen"; +import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen"; import getStorage from "./storage"; -import { addMeal, getDay, isMealType } from "./meals"; -import { normalizeName, rememberCalories } from "./calories"; +import { addMeal, isMealType } from "./meals"; +import { rememberCalories } from "./calories"; import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils"; const storage = getStorage(); @@ -14,29 +14,45 @@ const MAX_NOTE_LENGTH = 4000; /** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */ const MAX_INGREDIENTS = 50; -/** Klíč úložiště se šablonami jídel jednoho uživatele. */ +/** Klíč úložiště s recepty jednoho uživatele. */ const getKey = (login: string): string => `mealTemplates:${login}`; /** - * Doplní šabloně druh, pokud chybí. - * - * Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou - * ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic - * nepřepisuje, druh se doplní jen při čtení. + * Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly + * i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu) a recepty nesly + * `kind: 'RECEPT'`. */ -const withKind = (template: MealTemplate): MealTemplate => ({ - ...template, - kind: template.kind ?? MealTemplateKind.HOTOVE, -}); +type StoredMealTemplate = Omit & { + ingredients?: RecipeIngredient[]; + kind?: string; + pricePer100g?: number; +}; + +type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] }; /** - * Vrátí šablony jídel uživatele seřazené podle názvu. + * Šablony hotových jídel zanikly — opakovaně jedené jídlo se nabízí z historie. + * Poznají se podle toho, že nemají suroviny; recept bez surovin uložit nejde. + */ +const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length; + +/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */ +const toMealTemplate = (stored: StoredRecipe): MealTemplate => { + const { kind, pricePer100g, ...result } = stored; + return result; +}; + +/** Načte recepty z úložiště; šablony hotových jídel vynechá. */ +const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] => + (storedList ?? []).filter(isRecipe).map(toMealTemplate); + +/** + * Vrátí recepty uživatele seřazené podle názvu. * * @param login přihlašovací jméno uživatele */ export const getMealTemplates = async (login: string): Promise => - (await storage.getData(getKey(login)) ?? []) - .map(withKind) + loadRecipes(await storage.getData(getKey(login))) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); /** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */ @@ -161,117 +177,97 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => { }; /** - * Založí novou šablonu jídla, nebo přepíše existující podle předaného id. + * Založí nový recept, nebo přepíše existující podle předaného id. * - * U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty - * porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí — - * použití šablony pak funguje pro oba druhy stejně. + * Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží + * do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné. + * Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel. * * @param login přihlašovací jméno uživatele - * @param input data šablony + * @param input data receptu */ export const saveMealTemplate = async (login: string, input: unknown): Promise => { const value = (input ?? {}) as Record; const name = normalizeText(value.name, 'name'); if (!name) { - throw new BadRequestError("Nebyl předán název šablony"); + throw new BadRequestError("Nebyl předán název receptu"); } if (value.mealType != null && !isMealType(value.mealType)) { throw new BadRequestError("Nebyl předán platný typ jídla"); } - if (value.kind != null && value.kind !== MealTemplateKind.HOTOVE && value.kind !== MealTemplateKind.RECEPT) { - throw new BadRequestError("Nebyl předán platný druh šablony"); + if (!Array.isArray(value.ingredients) || !value.ingredients.length) { + throw new BadRequestError("Recept musí mít alespoň jednu surovinu"); } - const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE; + if (value.ingredients.length > MAX_INGREDIENTS) { + throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`); + } + const ingredients = value.ingredients.map(normalizeIngredient); + const servings = normalizeNumber(value.servings, 'servings'); + const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined; + const summary = deriveRecipe(ingredients, servings, cookedWeight); - const common = { + const template: MealTemplate = definedOnly({ id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(), - kind, name, mealType: value.mealType != null ? value.mealType as MealType : undefined, source: normalizeText(value.source, 'source'), note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH), - }; - - let template: MealTemplate; - if (kind === MealTemplateKind.RECEPT) { - if (!Array.isArray(value.ingredients) || !value.ingredients.length) { - throw new BadRequestError("Recept musí mít alespoň jednu surovinu"); + ingredients, + // Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl + servings: servings && servings > 1 ? servings : undefined, + cookedWeight, + weight: summary.weight, + caloriesPer100g: summary.caloriesPer100g, + calories: summary.calories, + price: summary.price, + }); + for (const ingredient of ingredients) { + if (ingredient.caloriesPer100g) { + await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g); } - if (value.ingredients.length > MAX_INGREDIENTS) { - throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`); - } - const ingredients = value.ingredients.map(normalizeIngredient); - const servings = normalizeNumber(value.servings, 'servings'); - const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined; - const summary = deriveRecipe(ingredients, servings, cookedWeight); - template = definedOnly({ - ...common, - ingredients, - // Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl - servings: servings && servings > 1 ? servings : undefined, - cookedWeight, - weight: summary.weight, - caloriesPer100g: summary.caloriesPer100g, - calories: summary.calories, - price: summary.price, - }); - for (const ingredient of ingredients) { - if (ingredient.caloriesPer100g) { - await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g); - } - } - } else { - template = definedOnly({ - ...common, - calories: normalizeNumber(value.calories, 'calories'), - caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'), - weight: normalizeNumber(value.weight, 'weight'), - price: normalizeNumber(value.price, 'price'), - pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'), - }); } - await storage.updateData(getKey(login), current => { - const others = (current ?? []).filter(item => item.id !== template.id); - return [...others, template]; - }); + await storage.updateData(getKey(login), current => [ + ...loadRecipes(current).filter(item => item.id !== template.id), + template, + ]); return getMealTemplates(login); }; /** - * Smaže šablonu jídla. + * Smaže recept. Jídla z něj založená zůstávají, jsou samostatná. * * @param login přihlašovací jméno uživatele - * @param id identifikátor šablony + * @param id identifikátor receptu */ export const deleteMealTemplate = async (login: string, id: unknown): Promise => { if (typeof id !== 'string' || !id.length) { - throw new BadRequestError("Nebyl předán identifikátor šablony"); + throw new BadRequestError("Nebyl předán identifikátor receptu"); } let found = false; - await storage.updateData(getKey(login), current => { - found = (current ?? []).some(item => item.id === id); - return (current ?? []).filter(item => item.id !== id); + await storage.updateData(getKey(login), current => { + const recipeList = loadRecipes(current); + found = recipeList.some(item => item.id === id); + return recipeList.filter(item => item.id !== id); }); if (!found) { - throw new NotFoundError("Šablona nebyla nalezena"); + throw new NotFoundError("Recept nebyl nalezen"); } return getMealTemplates(login); }; /** - * Založí do zvoleného dne jídlo ze šablony. + * Založí do zvoleného dne jednu porci receptu. * * Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by - * to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako - * u ručně zadaného jídla. + * to ovlivnilo recept. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako + * u ručně zadaného jídla. Postup do záznamu dne nepatří, zůstává u receptu. * * @param login přihlašovací jméno uživatele - * @param id identifikátor šablony + * @param id identifikátor receptu * @param date den, do kterého se jídlo založí - * @param mealType chod; bez něj se použije chod ze šablony, jinak oběd - * @param multiplier násobek porce, třeba 2 za dvě stejné sušenky + * @param mealType chod; bez něj se použije chod receptu, jinak oběd + * @param multiplier násobek porce, třeba 2 za dvě porce */ export const applyMealTemplate = async ( login: string, @@ -281,7 +277,7 @@ export const applyMealTemplate = async ( multiplier?: unknown, ): Promise => { if (typeof id !== 'string' || !id.length) { - throw new BadRequestError("Nebyl předán identifikátor šablony"); + throw new BadRequestError("Nebyl předán identifikátor receptu"); } if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); @@ -291,7 +287,7 @@ export const applyMealTemplate = async ( } const template = (await getMealTemplates(login)).find(item => item.id === id); if (!template) { - throw new NotFoundError("Šablona nebyla nalezena"); + throw new NotFoundError("Recept nebyl nalezen"); } await addMeal(login, { @@ -301,62 +297,9 @@ export const applyMealTemplate = async ( source: template.source, weight: template.weight, price: template.price, - pricePer100g: template.pricePer100g, calories: template.calories, caloriesPer100g: template.caloriesPer100g, - // Postup receptu do záznamu dne nepatří, zůstává u šablony - note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note, multiplier: normalizeMultiplier(multiplier), }, template.id); return date; }; - -/** - * Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. - * - * Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit - * jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných. - * Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by - * přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo. - * - * @param login přihlašovací jméno uživatele - * @param date den, ve kterém jídlo je - * @param id identifikátor jídla - */ -export const createMealTemplateFromMeal = async ( - login: string, - date: unknown, - id: unknown, -): Promise => { - if (typeof id !== 'string' || !id.length) { - throw new BadRequestError("Nebyl předán identifikátor jídla"); - } - if (!isValidIsoDate(date)) { - throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); - } - const meal = (await getDay(login, date)).entries.find(entry => entry.id === id); - if (!meal) { - throw new NotFoundError("Jídlo nebylo nalezeno"); - } - - const key = normalizeName(meal.name); - const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key); - if (existing?.kind === MealTemplateKind.RECEPT) { - throw new BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`); - } - - return saveMealTemplate(login, { - ...(existing ? { id: existing.id } : {}), - kind: MealTemplateKind.HOTOVE, - name: meal.name, - source: meal.source, - mealType: meal.mealType, - weight: meal.weight, - price: meal.price, - pricePer100g: meal.pricePer100g, - // Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže - calories: meal.caloriesPer100g ? undefined : meal.calories, - caloriesPer100g: meal.caloriesPer100g, - note: meal.note, - }); -}; diff --git a/server/src/meals.ts b/server/src/meals.ts index efdbb9a..f6f703e 100644 --- a/server/src/meals.ts +++ b/server/src/meals.ts @@ -1,10 +1,13 @@ import { randomUUID } from 'crypto'; -import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen"; +import { + DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients, +} from "../../types/gen/types.gen"; import getStorage from "./storage"; import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier, } from "./utils"; -import { rememberCalories } from "./calories"; +import { normalizeName, rememberCalories } from "./calories"; +import { getPortionNutrients, normalizeNutrients, sumNutrients } from "./nutrients"; const storage = getStorage(); @@ -46,11 +49,14 @@ function sortEntries(entries: MealEntry[]): MealEntry[] { /** Sestaví přehled dne včetně součtů z předaných záznamů. */ export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord { const sorted = sortEntries(entries); + const totalNutrients = sumNutrients(sorted.map(entry => + getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier))); return { date, entries: sorted, totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0), totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0), + ...(totalNutrients ? { totalNutrients } : {}), }; } @@ -125,6 +131,7 @@ export type NormalizedMealInput = { weight?: number; pricePer100g?: number; caloriesPer100g?: number; + nutrientsPer100g?: Nutrients; multiplier?: number; }; @@ -172,6 +179,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput { weight: normalizeNumber(value.weight, 'weight'), pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'), caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'), + nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'), multiplier: normalizeMultiplier(value.multiplier), }); } @@ -270,6 +278,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr weight: normalized.weight, pricePer100g: normalized.pricePer100g, caloriesPer100g: normalized.caloriesPer100g, + nutrientsPer100g: normalized.nutrientsPer100g, multiplier: normalized.multiplier, updatedAt: new Date().toISOString(), }; @@ -374,3 +383,203 @@ export async function findMealsForDays(login: string, dates: string[]): Promise< const days = await Promise.all([...dates].sort().map(date => getDay(login, date))); return days.flatMap(day => day.entries); } + +/** + * Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se + * neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g. + */ +function getManualWeight(source: MealEntry): number | undefined { + const weightFromPrice = source.price != null && source.pricePer100g + ? Math.round(source.price / source.pricePer100g * 100) + : undefined; + return source.weight !== weightFromPrice ? source.weight : undefined; +} + +/** + * Převede záznam jídla na návrh pro zakládání nového. + * + * Přenáší se jen to, co popisuje jídlo samotné. Chod, datum, násobek a poznámka + * patří ke konkrétnímu dni. Gramáž dopočtená z ceny se vynechá, aby se v novém + * záznamu dopočetla znovu z jeho ceny, a ruční kalorie jen tam, kde nejdou + * dopočítat z gramáže — jinak by se z dopočtené hodnoty stala ručně zadaná. + */ +function createMealSuggestion(source: MealEntry): MealSuggestion { + const caloriesDerived = source.weight != null && !!source.caloriesPer100g; + return { + name: source.name, + source: source.source, + price: source.price, + pricePer100g: source.pricePer100g, + weight: getManualWeight(source), + caloriesPer100g: source.caloriesPer100g, + calories: caloriesDerived ? undefined : source.calories, + nutrientsPer100g: source.nutrientsPer100g, + lastDate: source.date, + count: 1, + }; +} + +/** + * Vrátí jídla z celé historie uživatele, každý název jednou — slouží + * k našeptávání při zakládání jídla místo ručně ukládaných šablon. + * + * Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže + * "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou + * z posledního záznamu, protože cena i gramáž se časem mění. Když poslední + * záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně + * jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených. + * + * @param login přihlašovací jméno uživatele + */ +export async function getMealHistory(login: string): Promise { + const entryList = await findAllMeals(login); + const fillNutrition = createNutritionFiller(entryList); + const resultMap = new Map(); + for (const entry of entryList) { + const key = normalizeName(entry.name); + const existing = resultMap.get(key); + if (existing) { + existing.count++; + } else { + resultMap.set(key, createMealSuggestion(fillNutrition(entry))); + } + } + return [...resultMap.values()] + .sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate)); +} + +/** + * Načte všechna jídla uživatele napříč dny, od nejnovějších. + * + * @param login přihlašovací jméno uživatele + */ +export async function findAllMeals(login: string): Promise { + const prefix = `meals:${login}:`; + const keyList = (await storage.listKeys(prefix)).filter(key => key.startsWith(prefix)); + return (await Promise.all(keyList.map(key => storage.getData(key)))) + .flatMap(entries => entries ?? []) + .sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)); +} + +/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */ +const hasNutrition = (entry: MealEntry): boolean => + entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g; + +/** + * Připraví doplňování energie a živin podle historie — jídlo stejného názvu + * má stejné složení, i když se cena mění. + * + * Vzorem je nejnovější jídlo stejného názvu, které energii nebo živiny má; + * přednost má to ze stejného zdroje. Sazba za 100 g se přebírá jen ze stejného + * zdroje (jinde se účtuje jinak), gramáž se pak dopočte z ceny nového záznamu. + * Bez sazby se vezme gramáž vzoru — porce bývá stejná. Jídlo, které už energii + * nebo živiny má, zůstane, jak je. + * + * @param historyList jídla uživatele od nejnovějších (`findAllMeals`) + */ +export function createNutritionFiller(historyList: MealEntry[]): (entry: MealEntry) => MealEntry { + const sourceKey = (entry: MealEntry) => `${normalizeName(entry.name)}|${normalizeName(entry.source ?? '')}`; + const byNameMap = new Map(); + const byNameSourceMap = new Map(); + for (const entry of historyList.filter(hasNutrition)) { + if (!byNameMap.has(normalizeName(entry.name))) byNameMap.set(normalizeName(entry.name), entry); + if (!byNameSourceMap.has(sourceKey(entry))) byNameSourceMap.set(sourceKey(entry), entry); + } + + return (entry: MealEntry): MealEntry => { + if (hasNutrition(entry)) return entry; + const sameSourceRef = byNameSourceMap.get(sourceKey(entry)); + const ref = sameSourceRef ?? byNameMap.get(normalizeName(entry.name)); + if (!ref) return entry; + + const pricePer100g = sameSourceRef ? ref.pricePer100g : undefined; + const fromPrice = deriveAmounts({ + ...entry, pricePer100g, weight: getManualWeight(ref), caloriesPer100g: ref.caloriesPer100g, + }); + const weight = fromPrice.weight ?? ref.weight; + const calories = weight != null && ref.caloriesPer100g + ? Math.round(weight / 100 * ref.caloriesPer100g) + : ref.calories; + const result: MealEntry = { + ...entry, pricePer100g, weight, caloriesPer100g: ref.caloriesPer100g, calories, + nutrientsPer100g: ref.nutrientsPer100g, + }; + // Nevyplněné hodnoty se do úložiště nezapisují + return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as MealEntry; + }; +} + +/** + * Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj. + * + * Každý zdroj je jednou (porovnává se bez diakritiky a velikosti písmen), + * v zápisu z posledního použití a seřazené od nejčastěji použitých. + * + * @param login přihlašovací jméno uživatele + */ +export async function getMealSources(login: string): Promise { + const countMap = new Map(); + for (const entry of await findAllMeals(login)) { + if (!entry.source) continue; + const key = normalizeName(entry.source); + const existing = countMap.get(key); + if (existing) { + existing.count++; + } else { + countMap.set(key, { source: entry.source, count: 1 }); + } + } + return [...countMap.values()] + .sort((a, b) => b.count - a.count || a.source.localeCompare(b.source, 'cs')) + .map(item => item.source); +} + +/** + * Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je + * nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu. + * + * Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel + * ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která + * energii nebo živiny už mají, se nemění. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor jídla, ze kterého se hodnoty převezmou + * @param date datum toho jídla (YYYY-MM-DD) + * @param dryRun pokud true, jen spočítá dotčená jídla a nic nezmění + */ +export async function fillSameNameMeals( + login: string, + id: unknown, + date: unknown, + dryRun = false, +): Promise { + if (typeof id !== 'string' || !id.length) { + throw new BadRequestError("Nebyl předán identifikátor záznamu"); + } + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + const source = (await getDay(login, date)).entries.find(entry => entry.id === id); + if (!source) { + throw new NotFoundError("Záznam o jídle nebyl nalezen"); + } + if (!hasNutrition(source)) { + throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit"); + } + + const key = normalizeName(source.name); + const targetList = (await findAllMeals(login)) + .filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry)); + const dates = [...new Set(targetList.map(entry => entry.date))].sort(); + + if (!dryRun) { + const fill = createNutritionFiller([source]); + const targetIds = new Set(targetList.map(entry => entry.id)); + const updatedAt = new Date().toISOString(); + for (const targetDate of dates) { + await storage.updateData(getDayKey(login, targetDate), current => (current ?? []) + .map(entry => targetIds.has(entry.id) ? { ...fill(entry), updatedAt } : entry)); + } + } + return { count: targetList.length, dates }; +} diff --git a/server/src/nutrients.ts b/server/src/nutrients.ts new file mode 100644 index 0000000..9931289 --- /dev/null +++ b/server/src/nutrients.ts @@ -0,0 +1,83 @@ +import { Nutrients } from "../../types/gen/types.gen"; +import { BadRequestError } from "./utils"; + +/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */ +export const NUTRIENT_KEYS: (keyof Nutrients)[] = [ + 'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt', +]; + +/** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */ +const roundTenth = (value: number): number => Math.round(value * 10) / 10; + +/** + * Ověří a znormalizuje nutriční hodnoty na 100 g. + * Nevyplněné živiny vynechá; když není vyplněná žádná, vrátí undefined. + * + * @param value hodnoty ze vstupu + * @param name název pole pro chybovou hlášku + */ +export const normalizeNutrients = (value: unknown, name: string): Nutrients | undefined => { + if (value == null) return undefined; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new BadRequestError(`Pole '${name}' musí být objekt s nutričními hodnotami`); + } + const source = value as Record; + const result: Nutrients = {}; + for (const key of NUTRIENT_KEYS) { + const item = source[key]; + if (item == null) continue; + if (typeof item !== 'number' || !Number.isFinite(item)) { + throw new BadRequestError(`Pole '${name}.${key}' musí být číslo`); + } + if (item < 0) { + throw new BadRequestError(`Pole '${name}.${key}' nesmí být záporné`); + } + result[key] = roundTenth(item); + } + return Object.keys(result).length ? result : undefined; +}; + +/** + * Spočítá živiny snědené porce z hodnot na 100 g, gramáže a násobku. + * Bez gramáže je spočítat nejde — hodnoty na 100 g samy o porci nic neříkají. + * + * Klient má stejnou funkci v `Utils.tsx` pro náhled ve formuláři — musí dávat + * stejné výsledky. + * + * @param nutrientsPer100g nutriční hodnoty na 100 g + * @param weight gramáž jedné porce + * @param multiplier násobek porce, nevyplněný znamená 1 + */ +export const getPortionNutrients = ( + nutrientsPer100g: Nutrients | undefined, + weight: number | undefined, + multiplier?: number, +): Nutrients | undefined => { + if (!nutrientsPer100g || weight == null) return undefined; + const result: Nutrients = {}; + for (const key of NUTRIENT_KEYS) { + const per100g = nutrientsPer100g[key]; + if (per100g != null) { + result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1)); + } + } + return result; +}; + +/** + * Sečte živiny více porcí. Živina je ve výsledku, pokud ji má aspoň jedna porce; + * když nemá žádná porce nic, vrátí undefined. + */ +export const sumNutrients = (nutrientsList: (Nutrients | undefined)[]): Nutrients | undefined => { + const result: Nutrients = {}; + for (const nutrients of nutrientsList) { + if (!nutrients) continue; + for (const key of NUTRIENT_KEYS) { + const value = nutrients[key]; + if (value != null) { + result[key] = roundTenth((result[key] ?? 0) + value); + } + } + } + return Object.keys(result).length ? result : undefined; +}; diff --git a/server/src/profile.ts b/server/src/profile.ts index b4b483f..ca0d4ba 100644 --- a/server/src/profile.ts +++ b/server/src/profile.ts @@ -1,11 +1,17 @@ import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen"; import { BadRequestError } from "./utils"; +/** Meze hmotnosti v gramech — platí pro profil i vážení po dnech. */ +export const WEIGHT_LIMITS = { min: 20_000, max: 400_000 }; + /** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */ const LIMITS = { birthYear: { min: 1900, max: new Date().getFullYear() }, heightCm: { min: 50, max: 250 }, - weightGrams: { min: 20_000, max: 400_000 }, + weightGrams: WEIGHT_LIMITS, + waistCm: { min: 40, max: 250 }, + neckCm: { min: 20, max: 80 }, + hipCm: { min: 50, max: 250 }, }; /** Vrátí true, pokud je hodnota podporovaným pohlavím. */ @@ -32,15 +38,12 @@ export const normalizeProfile = (input: unknown): PersonalProfile => { if (value.sex != null && !isPohlavi(value.sex)) { throw new BadRequestError("Nebylo předáno platné pohlaví"); } - return { - ...(value.sex != null ? { sex: value.sex as Pohlavi } : {}), - ...(normalizeRanged(value.birthYear, 'birthYear') != null - ? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}), - ...(normalizeRanged(value.heightCm, 'heightCm') != null - ? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}), - ...(normalizeRanged(value.weightGrams, 'weightGrams') != null - ? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}), - }; + const result: PersonalProfile = value.sex != null ? { sex: value.sex as Pohlavi } : {}; + for (const name of Object.keys(LIMITS) as (keyof typeof LIMITS)[]) { + const normalized = normalizeRanged(value[name], name); + if (normalized != null) result[name] = normalized; + } + return result; }; /** @@ -59,14 +62,18 @@ export const normalizeProfile = (input: unknown): PersonalProfile => { * Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota. * * @param profile tělesné údaje uživatele - * @returns klidový výdej v kcal za den, nebo nic, když profil není úplný + * @param weightGrams hmotnost — z vážení platného k danému dni, bez něj z profilu + * @returns klidový výdej v kcal za den, nebo nic, když údaje nejsou úplné */ -export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => { - if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) { +export const computeBasalCalories = ( + profile?: PersonalProfile, + weightGrams = profile?.weightGrams, +): number | undefined => { + if (!profile?.sex || !profile.birthYear || !profile.heightCm || !weightGrams) { return undefined; } const age = new Date().getFullYear() - profile.birthYear; - const base = 10 * (profile.weightGrams / 1000) + const base = 10 * (weightGrams / 1000) + 6.25 * profile.heightCm - 5 * age; return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161)); diff --git a/server/src/routes/dayRoutes.ts b/server/src/routes/dayRoutes.ts index 4f16d53..21f9e3e 100644 --- a/server/src/routes/dayRoutes.ts +++ b/server/src/routes/dayRoutes.ts @@ -1,6 +1,7 @@ import express, { Request, Response, NextFunction } from "express"; import { getDayOverview } from "../dayOverview"; import { saveSleep } from "../sleep"; +import { getWeights, saveWeight } from "../weights"; import { getLogin } from "../auth"; import { formatDate, getToday, parseToken, requireIsoDate } from "../utils"; @@ -27,4 +28,22 @@ router.post("/sleep", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); +/** Zapíše vážení ke zvolenému dni. */ +router.post("/weight", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { date, weightGrams } = req.body ?? {}; + const day = await saveWeight(login, date, weightGrams); + res.status(200).json(await getDayOverview(login, day)); + } catch (e) { next(e); } +}); + +/** Vrátí všechna vážení — pro graf vývoje hmotnosti. */ +router.get("/weights", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await getWeights(login)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/routes/mealRoutes.ts b/server/src/routes/mealRoutes.ts index 3b95bf1..3293308 100644 --- a/server/src/routes/mealRoutes.ts +++ b/server/src/routes/mealRoutes.ts @@ -1,5 +1,8 @@ import express, { Request, Response, NextFunction } from "express"; -import { addMeal, deleteMeal, getDay, getRange, setMealMultiplier, updateMeal } from "../meals"; +import { + addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier, + updateMeal, +} from "../meals"; import { getDayOverview } from "../dayOverview"; import { getLogin } from "../auth"; import { formatDate, getToday, parseToken, requireIsoDate } from "../utils"; @@ -31,6 +34,31 @@ router.get("/range", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); +/** Vrátí jídla z historie, každý název jednou — pro našeptávání při zakládání jídla. */ +router.get("/history", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await getMealHistory(login)); + } catch (e) { next(e); } +}); + +/** Vrátí zdroje jídel, které uživatel už použil — nabídka pro pole Zdroj. */ +router.get("/sources", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await getMealSources(login)); + } catch (e) { next(e); } +}); + +/** Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají. */ +router.post("/fillSameName", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { id, date, dryRun } = req.body ?? {}; + res.status(200).json(await fillSameNameMeals(login, id, date, dryRun === true)); + } catch (e) { next(e); } +}); + /** Přidá jídlo do zvoleného dne. */ router.post("/add", async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/server/src/routes/mealTemplateRoutes.ts b/server/src/routes/mealTemplateRoutes.ts index 5089aa6..0425392 100644 --- a/server/src/routes/mealTemplateRoutes.ts +++ b/server/src/routes/mealTemplateRoutes.ts @@ -1,14 +1,12 @@ import express, { Request, Response, NextFunction } from "express"; -import { - applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate, -} from "../mealTemplates"; +import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates"; import { getDayOverview } from "../dayOverview"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; const router = express.Router(); -/** Vrátí šablony jídel přihlášeného uživatele. */ +/** Vrátí recepty přihlášeného uživatele. */ router.get("/", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); @@ -16,7 +14,7 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => { } catch (e) { next(e); } }); -/** Založí novou šablonu jídla, nebo upraví existující. */ +/** Založí nový recept, nebo upraví existující. */ router.post("/save", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); @@ -24,7 +22,7 @@ router.post("/save", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); -/** Smaže šablonu jídla. */ +/** Smaže recept. */ router.post("/delete", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); @@ -32,7 +30,7 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) = } catch (e) { next(e); } }); -/** Založí jídlo ze šablony do zvoleného dne. */ +/** Založí porci receptu jako jídlo do zvoleného dne. */ router.post("/apply", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); @@ -42,13 +40,4 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); -/** Vytvoří šablonu z existujícího jídla v přehledu dne. */ -router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => { - try { - const login = getLogin(parseToken(req)); - const { id, date } = req.body ?? {}; - res.status(200).json(await createMealTemplateFromMeal(login, date, id)); - } catch (e) { next(e); } -}); - export default router; diff --git a/server/src/routes/settingsRoutes.ts b/server/src/routes/settingsRoutes.ts index d4137ce..46e5099 100644 --- a/server/src/routes/settingsRoutes.ts +++ b/server/src/routes/settingsRoutes.ts @@ -1,5 +1,5 @@ import express, { Request, Response, NextFunction } from "express"; -import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings"; +import { getSettings, saveBasalCalories, saveGoals, saveProfile, saveSourceRate } from "../settings"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; @@ -38,4 +38,12 @@ router.post("/profile", async (req: Request, res: Response, next: NextFunction) } catch (e) { next(e); } }); +/** Uloží cíle uživatele. */ +router.post("/goals", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await saveGoals(login, req.body)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/settings.ts b/server/src/settings.ts index f54f534..9ebd6f0 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -3,6 +3,8 @@ import getStorage from "./storage"; import { BadRequestError } from "./utils"; import { normalizeName } from "./calories"; import { computeBasalCalories, normalizeProfile } from "./profile"; +import { computeBodyMetrics, normalizeGoals } from "./bodyMetrics"; +import { getWeights } from "./weights"; const storage = getStorage(); @@ -29,22 +31,27 @@ function emptySettings(): UserSettings { */ export async function getSettings(login: string): Promise { const stored = await storage.getData(getSettingsKey(login)) ?? emptySettings(); - return withDerived(stored); + return withDerived(login, stored); } /** - * Doplní nastavení o hodnoty, které se dopočítávají a neukládají. + * Doplní nastavení o hodnoty, které se dopočítávají a neukládají — klidový + * výdej a tělesné ukazatele. Počítají se z posledního vážení, bez něj + * z hmotnosti v profilu. * * Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů — * kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice. */ -function withDerived(settings: UserSettings): UserSettings { - const computed = computeBasalCalories(settings.profile); - const effective = settings.basalCalories ?? computed; +async function withDerived(login: string, settings: UserSettings): Promise { + const { computedBasalCalories: _basal, effectiveBasalCalories: _effective, bodyMetrics: _metrics, ...stored } = settings; + const latestWeight = (await getWeights(login)).pop(); + const computed = computeBasalCalories(stored.profile, latestWeight?.weightGrams ?? stored.profile?.weightGrams); + const effective = stored.basalCalories ?? computed; return { - ...settings, + ...stored, ...(computed != null ? { computedBasalCalories: computed } : {}), ...(effective != null ? { effectiveBasalCalories: effective } : {}), + bodyMetrics: computeBodyMetrics(stored.profile, stored.goals, latestWeight), }; } @@ -60,7 +67,7 @@ export async function saveProfile(login: string, profile: unknown): Promise 0 ? [...rates, { source: name, pricePer100g: rate }] : rates }; }); - return withDerived(saved); + return withDerived(login, saved); } /** @@ -120,7 +127,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown): const { basalCalories: _removed, ...rest } = settings; return rest; }); - return withDerived(saved); + return withDerived(login, saved); } /** @@ -135,3 +142,18 @@ export async function getSourceRate(login: string, source?: string): Promise normalizeName(rate.source) === key)?.pricePer100g; } + +/** + * Uloží cíle uživatele. Nevyplněné hodnoty se odstraní, prázdné cíle celé. + * + * @param login přihlašovací jméno uživatele + * @param goals cíle + */ +export async function saveGoals(login: string, goals: unknown): Promise { + const normalized = normalizeGoals(goals); + const saved = await storage.updateData(getSettingsKey(login), current => { + const { goals: _removed, ...rest } = current ?? emptySettings(); + return Object.keys(normalized).length ? { ...rest, goals: normalized } : rest; + }); + return withDerived(login, saved); +} diff --git a/server/src/tests/activityStats.test.ts b/server/src/tests/activityStats.test.ts index 62be49d..a2f112d 100644 --- a/server/src/tests/activityStats.test.ts +++ b/server/src/tests/activityStats.test.ts @@ -190,10 +190,14 @@ describe('denní detail výdeje', () => { }); }); -describe('vazba jídla na šablonu', () => { +describe('vazba jídla na recept', () => { - it('jídlo ze šablony si pamatuje, ze které vzniklo', async () => { - const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA }); + it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => { + const [template] = await saveMealTemplate(LOGIN, { + name: 'Štrůdl', + mealType: MealType.PULNOCNI_SVACINA, + ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }], + }); await applyMealTemplate(LOGIN, template.id, DATE); const [meal] = (await getDay(LOGIN, DATE)).entries; diff --git a/server/src/tests/bodyMetrics.test.ts b/server/src/tests/bodyMetrics.test.ts new file mode 100644 index 0000000..7785fdf --- /dev/null +++ b/server/src/tests/bodyMetrics.test.ts @@ -0,0 +1,157 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { computeBmi, computeBodyFat, computeBodyMetrics, getBmiCategory, normalizeGoals } from "../bodyMetrics"; +import { getSettings, saveBasalCalories, saveGoals, saveProfile } from "../settings"; +import { getWeights, saveWeight } from "../weights"; +import { getDayOverview } from "../dayOverview"; +import { addMeal } from "../meals"; +import { resetMemoryStorage } from "../storage/memory"; +import { BmiKategorie, MealType, Pohlavi } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; + +const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185 }; + +beforeEach(() => resetMemoryStorage()); + +describe('BMI a zdravá hmotnost', () => { + + it('spočítá BMI na desetiny', () => { + // 90 / 1,85² = 26,296… + expect(computeBmi(90_000, 185)).toBe(26.3); + }); + + it('zařadí BMI do pásem WHO včetně hranic', () => { + expect(getBmiCategory(18.4)).toBe(BmiKategorie.PODVAHA); + expect(getBmiCategory(18.5)).toBe(BmiKategorie.NORMA); + expect(getBmiCategory(24.9)).toBe(BmiKategorie.NORMA); + expect(getBmiCategory(25)).toBe(BmiKategorie.NADVAHA); + expect(getBmiCategory(30)).toBe(BmiKategorie.OBEZITA); + }); + + it('rozmezí zdravé hmotnosti odpovídá BMI 18,5–25 pro výšku', () => { + const metrics = computeBodyMetrics(muz); + + // 18,5 × 1,85² = 63,3 kg, 25 × 1,85² = 85,6 kg + expect(metrics.healthyWeightMinGrams).toBe(63_300); + expect(metrics.healthyWeightMaxGrams).toBe(85_600); + expect(metrics.bmi).toBeUndefined(); + }); +}); + +describe('podíl tuku (US Navy)', () => { + + it('u muže počítá z pasu a krku', () => { + expect(computeBodyFat({ ...muz, waistCm: 90, neckCm: 40 })).toBe(17.6); + }); + + it('u ženy potřebuje i boky', () => { + const zena = { sex: Pohlavi.ZENA, heightCm: 168, waistCm: 72, neckCm: 33 }; + + expect(computeBodyFat(zena)).toBeUndefined(); + // 495 / (1,29579 − 0,35004 × log10(137) + 0,221 × log10(168)) − 450 + expect(computeBodyFat({ ...zena, hipCm: 98 })).toBe(26.1); + }); + + it('nesmyslné obvody nedají žádný odhad', () => { + expect(computeBodyFat({ ...muz, waistCm: 40, neckCm: 45 })).toBeUndefined(); + expect(computeBodyFat({ ...muz, waistCm: 90 })).toBeUndefined(); + }); +}); + +describe('cíle', () => { + + it('půl kila týdně dolů je denní bilance −550 kcal', () => { + expect(computeBodyMetrics(muz, { weeklyChangeGrams: -500 }).targetDailyBalance).toBe(-550); + }); + + it('bílkoviny počítá z hmotnosti a cílový termín jen ve směru tempa', () => { + const metrics = computeBodyMetrics( + muz, { weeklyChangeGrams: -500, targetWeightGrams: 85_000, proteinPerKg: 1.6 }, + { date: '2026-10-01', weightGrams: 90_000 }); + + expect(metrics.proteinTargetGrams).toBe(144); + expect(metrics.weeksToTarget).toBe(10); + expect(computeBodyMetrics(muz, { weeklyChangeGrams: 250, targetWeightGrams: 85_000 }, + { date: '2026-10-01', weightGrams: 90_000 }).weeksToTarget).toBeUndefined(); + }); + + it('cíle se ukládají znormalizované a prázdné se odstraní', async () => { + const settings = await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 1.64 }); + expect(settings.goals).toEqual({ weeklyChangeGrams: -500, proteinPerKg: 1.6 }); + + expect((await saveGoals(LOGIN, {})).goals).toBeUndefined(); + }); + + it('odmítne cíl mimo rozsah', () => { + expect(() => normalizeGoals({ weeklyChangeGrams: -5000 })).toThrow(/mimo rozsah/); + expect(() => normalizeGoals({ proteinPerKg: 10 })).toThrow(/mimo rozsah/); + }); +}); + +describe('vážení po dnech', () => { + + it('ukládá, přepisuje a maže vážení dne', async () => { + await saveWeight(LOGIN, '2026-10-02', 89_500); + await saveWeight(LOGIN, '2026-10-01', 90_000); + await saveWeight(LOGIN, '2026-10-02', 89_400); + + expect(await getWeights(LOGIN)).toEqual([ + { date: '2026-10-01', weightGrams: 90_000 }, + { date: '2026-10-02', weightGrams: 89_400 }, + ]); + + await saveWeight(LOGIN, '2026-10-02', null); + expect(await getWeights(LOGIN)).toHaveLength(1); + }); + + it('odmítne hmotnost mimo rozsah', async () => { + await expect(saveWeight(LOGIN, '2026-10-01', 900)).rejects.toThrow(/mimo rozsah/); + }); + + it('ukazatele v nastavení počítají s posledním vážením místo hmotnosti z profilu', async () => { + await saveProfile(LOGIN, { ...muz, weightGrams: 95_000 }); + await saveWeight(LOGIN, '2026-10-01', 90_000); + + const fromWeighing = await getSettings(LOGIN); + await saveWeight(LOGIN, '2026-10-01', null); + const fromProfile = await getSettings(LOGIN); + + expect(fromWeighing.bodyMetrics).toMatchObject({ weightGrams: 90_000, weightDate: '2026-10-01', bmi: 26.3 }); + expect(fromProfile.bodyMetrics?.weightDate).toBeUndefined(); + // 10 kcal na kilogram rozdílu + expect(fromProfile.computedBasalCalories! - fromWeighing.computedBasalCalories!).toBe(50); + }); + + it('bilance dne bere hmotnost platnou k tomu dni, ne poslední', async () => { + await saveProfile(LOGIN, muz); + await saveWeight(LOGIN, '2026-09-01', 95_000); + await saveWeight(LOGIN, '2026-10-01', 90_000); + + const september = await getDayOverview(LOGIN, '2026-09-15'); + const october = await getDayOverview(LOGIN, '2026-10-05'); + + expect(september.energy.basal - october.energy.basal).toBe(50); + expect(september.weightGrams).toBeUndefined(); + expect((await getDayOverview(LOGIN, '2026-10-01')).weightGrams).toBe(90_000); + }); + + it('ruční klidový výdej má přednost i před vážením', async () => { + await saveProfile(LOGIN, muz); + await saveWeight(LOGIN, '2026-10-01', 90_000); + await saveBasalCalories(LOGIN, 1500); + + expect((await getDayOverview(LOGIN, '2026-10-01')).energy.basal).toBe(1500); + }); + + it('přehled dne nese cílovou bilanci a cíl bílkovin', async () => { + await saveWeight(LOGIN, '2026-10-01', 90_000); + await saveGoals(LOGIN, { weeklyChangeGrams: -500, proteinPerKg: 2 }); + await addMeal(LOGIN, { date: '2026-10-01', mealType: MealType.OBED, name: 'Guláš', calories: 700 }); + + const overview = await getDayOverview(LOGIN, '2026-10-01'); + + expect(overview.energy.targetBalance).toBe(-550); + expect(overview.proteinTargetGrams).toBe(180); + }); +}); diff --git a/server/src/tests/luncherImport.test.ts b/server/src/tests/luncherImport.test.ts index 4b276b3..122cbfb 100644 --- a/server/src/tests/luncherImport.test.ts +++ b/server/src/tests/luncherImport.test.ts @@ -1,6 +1,6 @@ import ExcelJS from 'exceljs'; import { importLuncherExport } from "../luncherImport"; -import { getDay } from "../meals"; +import { addMeal, getDay } from "../meals"; import { resetMemoryStorage } from "../storage/memory"; import { MealType } from "../../../types/gen/types.gen"; @@ -278,3 +278,82 @@ describe('import přehledu z Luncheru', () => { .rejects.toThrow(/nepodařilo najít očekávané sloupce/); }); }); + +describe('doplnění energie a živin z historie při importu', () => { + + const svickova = { + date: '2025-02-10', mealType: MealType.OBED, name: 'Svíčková na smetaně', source: 'Sladovnická', + weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8, fat: 9.5 }, + }; + + /** Naimportovaný záznam daného dne. */ + const importedOn = async (date: string) => { + await importLuncherExport(LOGIN, 'export.json', buildLuncherJson()); + return (await getDay(LOGIN, date)).entries.find(entry => entry.importSource === 'luncher')!; + }; + + it('převezme energii, živiny i gramáž jídla stejného názvu', async () => { + await addMeal(LOGIN, svickova); + + const entry = await importedOn('2025-03-03'); + + expect(entry).toMatchObject({ + weight: 400, caloriesPer100g: 150, calories: 600, nutrientsPer100g: { protein: 8, fat: 9.5 }, + }); + // Cena zůstane z importu + expect(entry.price).toBe(15900); + }); + + it('u stejného zdroje převezme sazbu za 100 g a gramáž dopočte z nové ceny', async () => { + await addMeal(LOGIN, { + date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Bolt Food', + price: 20000, pricePer100g: 5000, caloriesPer100g: 250, + }); + + const entry = await importedOn('2025-03-05'); + + // 249 Kč za 50 Kč/100 g = 498 g, × 250 kcal/100 g + expect(entry).toMatchObject({ pricePer100g: 5000, weight: 498, calories: 1245 }); + }); + + it('z jiného zdroje sazbu nepřebírá a použije gramáž porce', async () => { + await addMeal(LOGIN, { + date: '2025-02-10', mealType: MealType.OBED, name: 'Kuřecí burger', source: 'Jinde', + price: 20000, pricePer100g: 5000, caloriesPer100g: 250, + }); + + const entry = await importedOn('2025-03-05'); + + expect(entry.pricePer100g).toBeUndefined(); + expect(entry).toMatchObject({ weight: 400, calories: 1000 }); + }); + + it('má přednost jídlo ze stejného zdroje, i když je starší', async () => { + await addMeal(LOGIN, svickova); + await addMeal(LOGIN, { ...svickova, date: '2025-02-20', source: 'Jinde', caloriesPer100g: 200 }); + + expect((await importedOn('2025-03-03')).caloriesPer100g).toBe(150); + }); + + it('jídlo stejného názvu bez kalorií vzorem není, vezme se starší s kaloriemi', async () => { + await addMeal(LOGIN, svickova); + await addMeal(LOGIN, { date: '2025-02-20', mealType: MealType.OBED, name: 'Svíčková na smetaně', price: 15900 }); + + expect((await importedOn('2025-03-03')).calories).toBe(600); + }); + + it('bez historie zůstane jídlo bez kalorií', async () => { + const entry = await importedOn('2025-03-03'); + + expect(entry.calories).toBeUndefined(); + expect(entry).not.toHaveProperty('weight'); + }); + + it('doplněné hodnoty ukáže už náhled', async () => { + await addMeal(LOGIN, svickova); + + const result = await importLuncherExport(LOGIN, 'export.json', buildLuncherJson(), undefined, true); + + expect(result.entries.find(entry => entry.date === '2025-03-03')?.calories).toBe(600); + }); +}); diff --git a/server/src/tests/mealHistory.test.ts b/server/src/tests/mealHistory.test.ts new file mode 100644 index 0000000..1b329d9 --- /dev/null +++ b/server/src/tests/mealHistory.test.ts @@ -0,0 +1,233 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { addMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, updateMeal } from "../meals"; +import { getPortionNutrients, sumNutrients } from "../nutrients"; +import { resetMemoryStorage } from "../storage/memory"; +import { MealType } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +const DATE = '2025-03-03'; + +beforeEach(() => resetMemoryStorage()); + +describe('návrhy jídel z historie', () => { + + const gyros = { + date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros', + source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172, + }; + + it('každý název nabídne jednou, i když se jedl vícekrát', async () => { + await addMeal(LOGIN, gyros); + await addMeal(LOGIN, { ...gyros, date: '2025-03-04' }); + await addMeal(LOGIN, { ...gyros, date: '2025-03-05', name: 'kureci GYROS' }); + await addMeal(LOGIN, { ...gyros, name: 'Polévka' }); + + const history = await getMealHistory(LOGIN); + + expect(history.map(item => item.name)).toEqual(['kureci GYROS', 'Polévka']); + expect(history[0].count).toBe(3); + }); + + it('hodnoty bere z posledního záznamu', async () => { + await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 }); + await addMeal(LOGIN, gyros); + + const [item] = await getMealHistory(LOGIN); + + expect(item.price).toBe(22000); + expect(item.lastDate).toBe('2025-03-10'); + }); + + it('nepřenáší chod, násobek ani poznámku', async () => { + await addMeal(LOGIN, { ...gyros, mealType: MealType.VECERE, multiplier: 2, note: 's kolegy' }); + + const [item] = await getMealHistory(LOGIN); + + expect(item).not.toHaveProperty('mealType'); + expect(item).not.toHaveProperty('multiplier'); + expect(item).not.toHaveProperty('note'); + }); + + it('gramáž dopočtenou z ceny vynechá, ručně zadanou ponechá', async () => { + await addMeal(LOGIN, gyros); + await addMeal(LOGIN, { ...gyros, name: 'Salát', weight: 250 }); + + const history = await getMealHistory(LOGIN); + + expect(history.find(item => item.name === 'Kuřecí gyros')?.weight).toBeUndefined(); + expect(history.find(item => item.name === 'Salát')?.weight).toBe(250); + }); + + it('kalorie dopočtené z gramáže vynechá, ručně zadané ponechá', async () => { + await addMeal(LOGIN, gyros); + await addMeal(LOGIN, { date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80 }); + + const history = await getMealHistory(LOGIN); + + expect(history.find(item => item.name === 'Kuřecí gyros')).toMatchObject({ caloriesPer100g: 172 }); + expect(history.find(item => item.name === 'Kuřecí gyros')?.calories).toBeUndefined(); + expect(history.find(item => item.name === 'Jablko')?.calories).toBe(80); + }); + + it('řadí od nejčastějších, při shodě od naposledy jedených', async () => { + await addMeal(LOGIN, { ...gyros, name: 'Starší' }); + await addMeal(LOGIN, { ...gyros, name: 'Novější', date: '2025-03-04' }); + await addMeal(LOGIN, { ...gyros, name: 'Častější' }); + await addMeal(LOGIN, { ...gyros, name: 'Častější', date: '2025-03-02' }); + + const history = await getMealHistory(LOGIN); + + expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']); + }); + + it('historie je oddělená mezi uživateli', async () => { + await addMeal(LOGIN, gyros); + + expect(await getMealHistory('nekdo-jiny')).toEqual([]); + }); +}); + +describe('nutriční hodnoty', () => { + + const tycinka = { + date: DATE, mealType: MealType.DOPOLEDNI_SVACINA, name: 'Proteinová tyčinka', weight: 50, + nutrientsPer100g: { protein: 30, carbohydrates: 40.04, sugars: 5, fat: 12.5, salt: 0.44 }, + }; + + it('uloží hodnoty na 100 g zaokrouhlené na desetiny', async () => { + const day = await addMeal(LOGIN, tycinka); + + expect(day.entries[0].nutrientsPer100g).toEqual({ + protein: 30, carbohydrates: 40, sugars: 5, fat: 12.5, salt: 0.4, + }); + }); + + it('den sečte živiny porcí včetně násobku', async () => { + await addMeal(LOGIN, tycinka); + await addMeal(LOGIN, { ...tycinka, multiplier: 2 }); + + const day = await getDay(LOGIN, DATE); + + // 50 g = polovina hodnot na 100 g; jednou ×1 a jednou ×2 + expect(day.totalNutrients).toEqual({ + protein: 45, carbohydrates: 60, sugars: 7.5, fat: 18.8, salt: 0.6, + }); + }); + + it('jídlo bez gramáže nebo bez živin do součtu nevstupuje', async () => { + await addMeal(LOGIN, { ...tycinka, weight: undefined }); + await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Guláš', weight: 400, calories: 600 }); + + expect((await getDay(LOGIN, DATE)).totalNutrients).toBeUndefined(); + }); + + it('úprava umí živiny smazat', async () => { + const day = await addMeal(LOGIN, tycinka); + const { nutrientsPer100g, ...bezZivin } = tycinka; + + const updated = await updateMeal(LOGIN, day.entries[0].id, bezZivin); + + expect(nutrientsPer100g).toBeDefined(); + expect(updated.entries[0].nutrientsPer100g).toBeUndefined(); + expect(updated.totalNutrients).toBeUndefined(); + }); + + it('odmítne zápornou nebo nečíselnou hodnotu', async () => { + await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: -1 } })).rejects.toThrow(/záporné/); + await expect(addMeal(LOGIN, { ...tycinka, nutrientsPer100g: { fat: 'hodně' } })).rejects.toThrow(/číslo/); + }); + + it('prázdné hodnoty se neukládají', async () => { + const day = await addMeal(LOGIN, { ...tycinka, nutrientsPer100g: {} }); + + expect(day.entries[0].nutrientsPer100g).toBeUndefined(); + }); + + it('přenesou se do návrhu z historie', async () => { + await addMeal(LOGIN, tycinka); + + const [item] = await getMealHistory(LOGIN); + + expect(item.nutrientsPer100g?.protein).toBe(30); + }); + + it('porce a součet počítají jen vyplněné živiny', () => { + expect(getPortionNutrients({ protein: 10 }, 250, 0.5)).toEqual({ protein: 12.5 }); + expect(getPortionNutrients({ protein: 10 }, undefined)).toBeUndefined(); + expect(sumNutrients([{ protein: 1.1 }, undefined, { protein: 2.2, fat: 1 }])).toEqual({ protein: 3.3, fat: 1 }); + expect(sumNutrients([undefined])).toBeUndefined(); + }); +}); + +describe('zdroje jídel', () => { + + it('vrátí každý zdroj jednou, od nejčastějšího, v posledním zápisu', async () => { + await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'A', source: 'techtower' }); + await addMeal(LOGIN, { date: '2025-03-04', mealType: MealType.OBED, name: 'B', source: 'TechTower' }); + await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'C', source: 'Chefie' }); + await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'D' }); + + expect(await getMealSources(LOGIN)).toEqual(['TechTower', 'Chefie']); + }); +}); + +describe('doplnění do jídel stejného názvu', () => { + + const salat = { mealType: MealType.OBED, name: 'Těstovinový salát', source: 'Chefie', price: 12900 }; + + /** Založí jídla bez kalorií a jedno s nimi; vrátí to s kaloriemi. */ + const seed = async () => { + await addMeal(LOGIN, { ...salat, date: '2025-03-01' }); + await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' }); + await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' }); + await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 }); + const day = await addMeal(LOGIN, { + ...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 }, + }); + return day.entries[0]; + }; + + it('náhled spočítá jídla bez kalorií a nic nezmění', async () => { + const source = await seed(); + + const result = await fillSameNameMeals(LOGIN, source.id, DATE, true); + + expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] }); + expect((await getDay(LOGIN, '2025-03-01')).totalCalories).toBe(0); + }); + + it('doplní energii, živiny a gramáž, jídla s kaloriemi nechá', async () => { + const source = await seed(); + + await fillSameNameMeals(LOGIN, source.id, DATE); + + const [first] = (await getDay(LOGIN, '2025-03-01')).entries; + expect(first).toMatchObject({ weight: 350, calories: 560, nutrientsPer100g: { protein: 6 }, price: 12900 }); + const second = (await getDay(LOGIN, '2025-03-02')).entries; + expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560); + expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined(); + expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300); + }); + + it('odmítne jídlo bez kalorií i živin', async () => { + const day = await addMeal(LOGIN, { ...salat, date: DATE }); + + await expect(fillSameNameMeals(LOGIN, day.entries[0].id, DATE)).rejects.toThrow(/není co doplnit/); + }); +}); + +describe('návrh z historie s posledním záznamem bez kalorií', () => { + + it('převezme kalorie z dřívějšího záznamu, cenu z posledního', async () => { + await addMeal(LOGIN, { + date: '2025-03-01', mealType: MealType.OBED, name: 'Salát', source: 'Chefie', + price: 12900, weight: 350, caloriesPer100g: 160, + }); + await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Salát', source: 'Chefie', price: 13900 }); + + const [item] = await getMealHistory(LOGIN); + + expect(item).toMatchObject({ price: 13900, weight: 350, caloriesPer100g: 160, lastDate: DATE }); + }); +}); diff --git a/server/src/tests/setsAndMealTemplates.test.ts b/server/src/tests/setsAndMealTemplates.test.ts index 71e1b70..76a0035 100644 --- a/server/src/tests/setsAndMealTemplates.test.ts +++ b/server/src/tests/setsAndMealTemplates.test.ts @@ -5,6 +5,7 @@ import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from " import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates"; import { getDay } from "../meals"; import { resetMemoryStorage } from "../storage/memory"; +import getStorage from "../storage"; import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen"; const LOGIN = 'tester'; @@ -115,74 +116,67 @@ describe('série u cviků', () => { }); }); -describe('šablony jídel', () => { - const salat = { - name: 'Těstovinový salát', - source: 'Chefie', - mealType: MealType.OBED, - weight: 350, - caloriesPer100g: 160, - price: 12900, +describe('recepty', () => { + + const rizoto = { + name: 'Kuřecí rizoto', + source: 'doma', + mealType: MealType.VECERE, + servings: 2, + cookedWeight: 1000, + ingredients: [ + { name: 'Rýže', weight: 200, caloriesPer100g: 350, price: 3000 }, + { name: 'Kuřecí prsa', weight: 300, caloriesPer100g: 110, price: 9000 }, + ], + note: 'Rýži orestovat, podlít vývarem.', }; - it('uloží šablonu a vrátí ji v seznamu', async () => { - const templates = await saveMealTemplate(LOGIN, salat); + it('uloží recept a dopočte hodnoty jedné porce', async () => { + const [template] = await saveMealTemplate(LOGIN, rizoto); + + // 700 + 330 kcal na 1000 g hotového jídla, rozděleno na dvě porce + expect(template).toMatchObject({ + name: 'Kuřecí rizoto', + weight: 500, + calories: 515, + caloriesPer100g: 103, + price: 6000, + }); + }); + + it('úprava podle id recept přepíše, nezaloží druhý', async () => { + const [created] = await saveMealTemplate(LOGIN, rizoto); + + const templates = await saveMealTemplate(LOGIN, { ...rizoto, id: created.id, name: 'Rizoto velké' }); expect(templates).toHaveLength(1); - expect(templates[0].name).toBe('Těstovinový salát'); - expect(templates[0].source).toBe('Chefie'); + expect(templates[0].name).toBe('Rizoto velké'); }); - it('úprava podle id šablonu přepíše, nezaloží druhou', async () => { - const [created] = await saveMealTemplate(LOGIN, salat); - - const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' }); - - expect(templates).toHaveLength(1); - expect(templates[0].name).toBe('Salát velký'); - }); - - it('použití založí jídlo do dne a dopočte kalorie', async () => { - const [template] = await saveMealTemplate(LOGIN, salat); + it('použití založí porci do dne, bez postupu', async () => { + const [template] = await saveMealTemplate(LOGIN, rizoto); await applyMealTemplate(LOGIN, template.id, DATE); - const day = await getDay(LOGIN, DATE); - expect(day.entries).toHaveLength(1); - expect(day.entries[0].name).toBe('Těstovinový salát'); - expect(day.entries[0].source).toBe('Chefie'); - // 350 g × 160 kcal/100 g - expect(day.entries[0].calories).toBe(560); - expect(day.totalPrice).toBe(12900); + const [entry] = (await getDay(LOGIN, DATE)).entries; + expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 }); + expect(entry.note).toBeUndefined(); }); - it('zvolený chod přebije ten ze šablony', async () => { - const [template] = await saveMealTemplate(LOGIN, salat); + it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => { + const [template] = await saveMealTemplate(LOGIN, rizoto); + const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined }); - await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE); + await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE); + await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04'); - expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE); + expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE); + expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED); }); - it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => { - const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 }); - - await applyMealTemplate(LOGIN, template.id, DATE); - - expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED); - }); - - it('šablona jen s absolutními kaloriemi funguje taky', async () => { - const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 }); - - await applyMealTemplate(LOGIN, template.id, DATE); - - expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80); - }); - - it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => { - const [template] = await saveMealTemplate(LOGIN, salat); + it('založené jídlo je samostatné — smazání receptu ho nesmaže', async () => { + const [template] = await saveMealTemplate(LOGIN, rizoto); await applyMealTemplate(LOGIN, template.id, DATE); await deleteMealTemplate(LOGIN, template.id); @@ -191,25 +185,66 @@ describe('šablony jídel', () => { expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1); }); - it('unese poznámku s postupem přípravy', async () => { - const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.'; - const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept }); - - expect(template.note).toBe(recept); - - await applyMealTemplate(LOGIN, template.id, DATE); - expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept); + it('odmítne recept bez názvu, bez surovin a neexistující id', async () => { + await expect(saveMealTemplate(LOGIN, { ...rizoto, name: ' ' })).rejects.toThrow(/název receptu/); + await expect(saveMealTemplate(LOGIN, { ...rizoto, ingredients: [] })).rejects.toThrow(/surovinu/); + await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/); + await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/); }); - it('odmítne šablonu bez názvu a neexistující id', async () => { - await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/); - await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/); - await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/); - }); - - it('šablony jídel jsou oddělené mezi uživateli', async () => { - await saveMealTemplate(LOGIN, salat); + it('recepty jsou oddělené mezi uživateli', async () => { + await saveMealTemplate(LOGIN, rizoto); expect(await getMealTemplates('nekdo-jiny')).toEqual([]); }); }); + +describe('zaniklé šablony hotových jídel', () => { + + const KEY = `mealTemplates:${LOGIN}`; + + /** Úložiště tak, jak vypadalo před zrušením hotových jídel. */ + const seedLegacy = async () => { + await getStorage().setData(KEY, [ + { id: 'stara', name: 'Sušenka', calories: 80 }, + { id: 'hotove', kind: 'HOTOVE', name: 'Salát', weight: 350, caloriesPer100g: 160, pricePer100g: 3700 }, + { + id: 'recept', kind: 'RECEPT', name: 'Guláš', weight: 400, calories: 600, + ingredients: [{ name: 'Hovězí', weight: 400, calories: 600 }], + }, + ]); + }; + + it('se nevracejí, recepty zůstanou bez starého druhu', async () => { + await seedLegacy(); + + const templates = await getMealTemplates(LOGIN); + + expect(templates.map(item => item.id)).toEqual(['recept']); + expect(templates[0]).not.toHaveProperty('kind'); + }); + + it('zápis je z úložiště odklidí', async () => { + await seedLegacy(); + + await saveMealTemplate(LOGIN, { name: 'Polévka', ingredients: [{ name: 'Mrkev', weight: 100, calories: 40 }] }); + + const stored = await getStorage().getData<{ id: string, name: string, kind?: string }[]>(KEY) ?? []; + expect(stored.map(item => item.name).sort()).toEqual(['Guláš', 'Polévka']); + expect(stored.some(item => item.kind)).toBe(false); + }); + + it('jídla z nich založená ve dnech zůstanou', async () => { + await seedLegacy(); + await getStorage().setData(`meals:${LOGIN}:${DATE}`, [{ + id: 'zeSablony', date: DATE, mealType: MealType.OBED, name: 'Salát', + calories: 560, templateId: 'hotove', createdAt: '2025-03-03T10:00:00.000Z', + }]); + + await deleteMealTemplate(LOGIN, 'recept'); + + const day = await getDay(LOGIN, DATE); + expect(day.entries).toHaveLength(1); + expect(day.totalCalories).toBe(560); + }); +}); diff --git a/server/src/tests/toTemplate.test.ts b/server/src/tests/toTemplate.test.ts index 085863a..3c4692b 100644 --- a/server/src/tests/toTemplate.test.ts +++ b/server/src/tests/toTemplate.test.ts @@ -1,78 +1,15 @@ process.env.CALORIE_PROVIDER = 'none'; import { addActivity, getActivityDay } from "../activities"; -import { addMeal, getDay } from "../meals"; -import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates"; import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts"; import { resetMemoryStorage } from "../storage/memory"; -import { ActivityUnit, MealType } from "../../../types/gen/types.gen"; +import { ActivityUnit } from "../../../types/gen/types.gen"; const LOGIN = 'tester'; const DATE = '2025-03-03'; beforeEach(() => resetMemoryStorage()); -describe('překlopení jídla do šablony', () => { - - const gyros = { - date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros', - source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172, - }; - - it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => { - const day = await addMeal(LOGIN, gyros); - - const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); - - expect(templates).toHaveLength(1); - expect(templates[0]).toMatchObject({ - name: 'Kuřecí gyros', - source: 'TechTower', - mealType: MealType.OBED, - weight: 400, - caloriesPer100g: 172, - price: 17600, - pricePer100g: 4400, - }); - }); - - it('jídlo v dni zůstane beze změny', async () => { - const day = await addMeal(LOGIN, gyros); - - await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); - - expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1); - }); - - it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => { - const day = await addMeal(LOGIN, gyros); - await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); - - // Totéž jídlo jindy a s jinou gramáží - const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 }); - const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id); - - expect(templates).toHaveLength(1); - expect(templates[0].weight).toBe(500); - }); - - it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => { - const day = await addMeal(LOGIN, { - date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80, - }); - - const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); - - expect(templates[0].calories).toBe(80); - expect(templates[0].caloriesPer100g).toBeUndefined(); - }); - - it('hlásí chybu u neexistujícího jídla', async () => { - await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje')) - .rejects.toThrow(/nebylo nalezeno/); - }); -}); - describe('překlopení aktivit do šablony tréninku', () => { const kliky = { @@ -163,6 +100,5 @@ describe('překlopení aktivit do šablony tréninku', () => { await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok'); expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]); - expect(await getMealTemplates('nekdo-jiny')).toEqual([]); }); }); diff --git a/server/src/weights.ts b/server/src/weights.ts new file mode 100644 index 0000000..60cfeba --- /dev/null +++ b/server/src/weights.ts @@ -0,0 +1,60 @@ +import { WeightEntry } from "../../types/gen/types.gen"; +import getStorage from "./storage"; +import { BadRequestError, isValidIsoDate } from "./utils"; +import { WEIGHT_LIMITS } from "./profile"; + +const storage = getStorage(); + +/** + * Klíč úložiště s váženími jednoho uživatele. Hodnotou je mapa den → gramy; + * vážení je málo a pro graf i dohledání hmotnosti k datu je potřeba celá řada. + */ +const getKey = (login: string): string => `weights:${login}`; + +type WeightLog = Record; + +/** + * Vrátí všechna vážení uživatele od nejstaršího. + * + * @param login přihlašovací jméno uživatele + */ +export const getWeights = async (login: string): Promise => + Object.entries(await storage.getData(getKey(login)) ?? {}) + .map(([date, weightGrams]) => ({ date, weightGrams })) + .sort((a, b) => a.date.localeCompare(b.date)); + +/** + * Najde vážení platné k danému dni — poslední zapsané v ten den nebo dřív. + * + * @param weightList vážení od nejstaršího + * @param date den ve formátu YYYY-MM-DD + */ +export const findWeightOn = (weightList: WeightEntry[], date: string): WeightEntry | undefined => + weightList.filter(entry => entry.date <= date).pop(); + +/** + * Zapíše vážení ke zvolenému dni. Nevyplněná nebo nulová hodnota ho smaže. + * + * @param login přihlašovací jméno uživatele + * @param date den ve formátu YYYY-MM-DD + * @param weightGrams hmotnost v gramech + * @returns datum, kterého se zápis týkal + */ +export const saveWeight = async (login: string, date: unknown, weightGrams: unknown): Promise => { + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (weightGrams != null && (typeof weightGrams !== 'number' || !Number.isFinite(weightGrams))) { + throw new BadRequestError("Hmotnost musí být číslo"); + } + const value = weightGrams == null ? 0 : Math.round(weightGrams); + if (value !== 0 && (value < WEIGHT_LIMITS.min || value > WEIGHT_LIMITS.max)) { + throw new BadRequestError(`Hmotnost je mimo rozsah (${WEIGHT_LIMITS.min / 1000}–${WEIGHT_LIMITS.max / 1000} kg)`); + } + await storage.updateData(getKey(login), current => { + const { [date]: _removed, ...rest } = current ?? {}; + // Nula znamená smazání — ukládat ji nemá smysl + return value > 0 ? { ...rest, [date]: value } : rest; + }); + return date; +}; diff --git a/server/yarn.lock b/server/yarn.lock index 9563fa0..cc0a5d8 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -961,6 +961,11 @@ dependencies: tslib "^2.4.0" +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@fast-csv/format@4.3.5": version "4.3.5" resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3" @@ -2380,6 +2385,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-env@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" diff --git a/types/api.yml b/types/api.yml index 99b7354..cbc38c9 100644 --- a/types/api.yml +++ b/types/api.yml @@ -24,6 +24,12 @@ paths: $ref: "./paths/meals/delete.yml" /meals/multiplier: $ref: "./paths/meals/multiplier.yml" + /meals/history: + $ref: "./paths/meals/history.yml" + /meals/sources: + $ref: "./paths/meals/sources.yml" + /meals/fillSameName: + $ref: "./paths/meals/fillSameName.yml" # Statistiky (/api/stats) /stats/summary: @@ -36,6 +42,10 @@ paths: $ref: "./paths/day/overview.yml" /day/sleep: $ref: "./paths/day/sleep.yml" + /day/weight: + $ref: "./paths/day/weight.yml" + /day/weights: + $ref: "./paths/day/weights.yml" # Pohyb (/api/activities) /activities/add: @@ -59,7 +69,7 @@ paths: /workouts/fromActivities: $ref: "./paths/workouts/fromActivities.yml" - # Šablony jídel (/api/mealTemplates) + # Recepty (/api/mealTemplates) /mealTemplates: $ref: "./paths/mealTemplates/list.yml" /mealTemplates/save: @@ -68,8 +78,6 @@ paths: $ref: "./paths/mealTemplates/delete.yml" /mealTemplates/apply: $ref: "./paths/mealTemplates/apply.yml" - /mealTemplates/fromMeal: - $ref: "./paths/mealTemplates/fromMeal.yml" # Kalorie (/api/calories) /calories/search: @@ -82,6 +90,8 @@ paths: $ref: "./paths/settings/basal.yml" /settings/profile: $ref: "./paths/settings/profile.yml" + /settings/goals: + $ref: "./paths/settings/goals.yml" # Importy (/api/import) /import/luncher: diff --git a/types/paths/day/weight.yml b/types/paths/day/weight.yml new file mode 100644 index 0000000..959d486 --- /dev/null +++ b/types/paths/day/weight.yml @@ -0,0 +1,28 @@ +post: + operationId: saveWeight + summary: Zapíše vážení k zvolenému dni. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [date] + properties: + date: + type: string + format: date + weightGrams: + description: Hmotnost v gramech. Bez hodnoty (nebo 0) se vážení dne smaže. + type: integer + responses: + "200": + description: Aktualizovaný přehled dne + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/DayOverview" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/day/weights.yml b/types/paths/day/weights.yml new file mode 100644 index 0000000..140da12 --- /dev/null +++ b/types/paths/day/weights.yml @@ -0,0 +1,14 @@ +get: + operationId: getWeights + summary: Vrátí všechna vážení přihlášeného uživatele seřazená podle data. + responses: + "200": + description: Vážení od nejstaršího + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/WeightEntry" + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/mealTemplates/apply.yml b/types/paths/mealTemplates/apply.yml index 3919cfe..f9f70c8 100644 --- a/types/paths/mealTemplates/apply.yml +++ b/types/paths/mealTemplates/apply.yml @@ -1,6 +1,6 @@ post: operationId: applyMealTemplate - summary: Založí jídlo ze šablony do zvoleného dne. + summary: Založí jednu porci receptu jako jídlo do zvoleného dne. requestBody: required: true content: @@ -10,18 +10,18 @@ post: required: [id, date] properties: id: - description: Identifikátor šablony + description: Identifikátor receptu type: string date: description: Den, do kterého se jídlo založí type: string format: date mealType: - description: Chod. Bez něj se použije chod ze šablony, jinak oběd. + description: Chod. Bez něj se použije chod receptu, jinak oběd. allOf: - $ref: "../../schemas/_index.yml#/MealType" multiplier: - description: Násobek porce, třeba 2 za dvě stejné sušenky. Bez něj 1. + description: Násobek porce, třeba 2 za dvě porce. Bez něj 1. type: number responses: "200": @@ -35,4 +35,4 @@ post: "401": description: Neautentizovaný uživatel "404": - description: Šablona neexistuje + description: Recept neexistuje diff --git a/types/paths/mealTemplates/delete.yml b/types/paths/mealTemplates/delete.yml index 1ba8d44..e08a300 100644 --- a/types/paths/mealTemplates/delete.yml +++ b/types/paths/mealTemplates/delete.yml @@ -1,6 +1,6 @@ post: operationId: deleteMealTemplate - summary: Smaže šablonu jídla. + summary: Smaže recept. requestBody: required: true content: @@ -13,7 +13,7 @@ post: type: string responses: "200": - description: Aktualizovaný seznam šablon + description: Aktualizovaný seznam receptů content: application/json: schema: diff --git a/types/paths/mealTemplates/fromMeal.yml b/types/paths/mealTemplates/fromMeal.yml deleted file mode 100644 index d9f17c9..0000000 --- a/types/paths/mealTemplates/fromMeal.yml +++ /dev/null @@ -1,33 +0,0 @@ -post: - operationId: createMealTemplateFromMeal - summary: Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [id, date] - properties: - id: - description: Identifikátor jídla, ze kterého se šablona vytvoří - type: string - date: - description: Den, ve kterém jídlo je - type: string - format: date - responses: - "200": - description: Aktualizovaný seznam šablon - content: - application/json: - schema: - type: array - items: - $ref: "../../schemas/_index.yml#/MealTemplate" - "400": - description: Neplatná data - "401": - description: Neautentizovaný uživatel - "404": - description: Jídlo neexistuje diff --git a/types/paths/mealTemplates/list.yml b/types/paths/mealTemplates/list.yml index b1c5631..74d4caf 100644 --- a/types/paths/mealTemplates/list.yml +++ b/types/paths/mealTemplates/list.yml @@ -1,9 +1,9 @@ get: operationId: getMealTemplates - summary: Vrátí šablony jídel přihlášeného uživatele. + summary: Vrátí recepty přihlášeného uživatele. responses: "200": - description: Šablony seřazené podle názvu + description: Recepty seřazené podle názvu content: application/json: schema: diff --git a/types/paths/mealTemplates/save.yml b/types/paths/mealTemplates/save.yml index 5a19259..73e7fd4 100644 --- a/types/paths/mealTemplates/save.yml +++ b/types/paths/mealTemplates/save.yml @@ -1,6 +1,6 @@ post: operationId: saveMealTemplate - summary: Založí novou šablonu jídla, nebo upraví existující. + summary: Založí nový recept, nebo upraví existující. requestBody: required: true content: @@ -9,7 +9,7 @@ post: $ref: "../../schemas/_index.yml#/MealTemplateInput" responses: "200": - description: Aktualizovaný seznam šablon + description: Aktualizovaný seznam receptů content: application/json: schema: diff --git a/types/paths/meals/fillSameName.yml b/types/paths/meals/fillSameName.yml new file mode 100644 index 0000000..6a02158 --- /dev/null +++ b/types/paths/meals/fillSameName.yml @@ -0,0 +1,39 @@ +post: + operationId: fillSameNameMeals + summary: | + Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je nemají. + description: | + Typicky po doplnění kalorií jednomu naimportovanému jídlu — ostatní jídla + téhož jména mají stejné složení. Jídla, která energii nebo živiny už mají, + se nemění. S `dryRun` jen spočítá, kterých jídel by se to týkalo. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, date] + properties: + id: + description: Identifikátor jídla, ze kterého se hodnoty převezmou + type: string + date: + description: Datum jídla (YYYY-MM-DD) + type: string + format: date + dryRun: + description: Jen spočítat, nic neměnit + type: boolean + responses: + "200": + description: Jídla, kterých se doplnění týká + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/FillSameNameResult" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel + "404": + description: Jídlo neexistuje diff --git a/types/paths/meals/history.yml b/types/paths/meals/history.yml new file mode 100644 index 0000000..3158bd4 --- /dev/null +++ b/types/paths/meals/history.yml @@ -0,0 +1,17 @@ +get: + operationId: getMealHistory + summary: Vrátí jídla z historie přihlášeného uživatele, každý název jednou. + description: | + Slouží k našeptávání při zakládání jídla. Seřazeno od nejčastěji jedených, + při shodě od naposledy jedených. + responses: + "200": + description: Jídla z historie + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/MealSuggestion" + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/meals/sources.yml b/types/paths/meals/sources.yml new file mode 100644 index 0000000..9561abf --- /dev/null +++ b/types/paths/meals/sources.yml @@ -0,0 +1,17 @@ +get: + operationId: getMealSources + summary: Vrátí zdroje jídel (podniky, obchody), které uživatel už použil. + description: | + Slouží jako nabídka při zadávání zdroje. Každý zdroj je jednou, seřazené + od nejčastěji použitých; zápis je z posledního použití. + responses: + "200": + description: Použité zdroje + content: + application/json: + schema: + type: array + items: + type: string + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/settings/goals.yml b/types/paths/settings/goals.yml new file mode 100644 index 0000000..f402888 --- /dev/null +++ b/types/paths/settings/goals.yml @@ -0,0 +1,21 @@ +post: + operationId: saveGoals + summary: Uloží cíle uživatele — tempo změny hmotnosti, cílovou hmotnost a bílkoviny. + description: Nevyplněné hodnoty se odstraní. + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/Goals" + responses: + "200": + description: Aktualizované nastavení + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/UserSettings" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 560df20..f78de3c 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -57,6 +57,8 @@ MealEntry: caloriesPer100g: description: Energetická hodnota na 100 g v kcal. Slouží k dopočtu kalorií z gramáže. type: integer + nutrientsPer100g: + $ref: "#/Nutrients" note: description: Poznámka uživatele k jídlu type: string @@ -130,6 +132,8 @@ MealInput: description: Energetická hodnota na 100 g v kcal type: integer nullable: true + nutrientsPer100g: + $ref: "#/Nutrients" note: description: Poznámka uživatele k jídlu type: string @@ -138,6 +142,100 @@ MealInput: description: Násobek porce; nevyplněno znamená 1 type: number +Nutrients: + description: | + Nutriční hodnoty v gramech, ve stejném členění jako na etiketě potravin. + U jídla jsou vztažené na 100 g; hodnoty porce se dopočtou z gramáže, + takže jídlo bez gramáže do součtů živin nevstupuje. Na rozdíl od ostatních + hodnot nejsou celočíselné — etikety uvádějí desetiny gramu a u soli + nebo vlákniny by zaokrouhlení na celé gramy smazalo celou hodnotu. + Ukládají se zaokrouhlené na jedno desetinné místo. + type: object + additionalProperties: false + properties: + protein: + description: Bílkoviny + type: number + carbohydrates: + description: Sacharidy + type: number + sugars: + description: Z toho cukry + type: number + fat: + description: Tuky + type: number + saturatedFat: + description: Z toho nasycené mastné kyseliny + type: number + fiber: + description: Vláknina + type: number + salt: + description: Sůl + type: number + +FillSameNameResult: + description: Jídla stejného názvu, do kterých se doplní (nebo doplnily) energie a živiny + type: object + additionalProperties: false + required: + - count + - dates + properties: + count: + description: Počet jídel + type: integer + dates: + description: Dny, ve kterých ta jídla jsou (YYYY-MM-DD), seřazené + type: array + items: + type: string + format: date + +MealSuggestion: + description: | + Jídlo z historie uživatele nabízené při zakládání nového. Každý název je + v návrzích jen jednou; hodnoty jsou z posledního záznamu toho jména. + Chod, datum, násobek ani poznámka se nepřenášejí — patří ke konkrétnímu dni. + type: object + additionalProperties: false + required: + - name + - lastDate + - count + properties: + name: + description: Název jídla tak, jak byl zapsán naposledy + type: string + source: + type: string + price: + description: Cena v haléřích + type: integer + pricePer100g: + description: Cena za 100 g v haléřích + type: integer + weight: + description: | + Gramáž zadaná ručně. Chybí, pokud se gramáž dopočetla z ceny — + v novém záznamu se pak dopočte znovu. + type: integer + caloriesPer100g: + type: integer + calories: + description: Kalorie porce, jen pokud nejdou dopočítat z gramáže a energie na 100 g + type: integer + nutrientsPer100g: + $ref: "#/Nutrients" + lastDate: + description: Den, kdy uživatel jídlo jedl naposledy + type: string + format: date + count: + description: Kolikrát se jídlo v historii vyskytuje + type: integer + DayRecord: description: Přehled jídel jednoho dne včetně součtů type: object @@ -163,6 +261,12 @@ DayRecord: totalCalories: description: Součet kalorií jídel dne v kcal (jídla bez kalorií se nezapočítávají) type: integer + totalNutrients: + description: | + Součet živin jídel dne po započtení násobku. Započítávají se jen jídla + s gramáží a nutričními hodnotami; chybí, pokud takové jídlo v dni není. + allOf: + - $ref: "#/Nutrients" PeriodTotal: description: Součty za jedno období (den, měsíc nebo rok) @@ -506,6 +610,102 @@ UserSettings: Klidový výdej, který se reálně použije v bilanci dne — ručně zadaný, jinak spočítaný z profilu. Doplňuje server. type: integer + goals: + $ref: "#/Goals" + bodyMetrics: + $ref: "#/BodyMetrics" + +Goals: + description: Cíle uživatele — tempo změny hmotnosti, cílová hmotnost a příjem bílkovin + type: object + additionalProperties: false + properties: + weeklyChangeGrams: + description: | + Plánovaná změna hmotnosti za týden v gramech, záporná při hubnutí + (−500 = půl kila týdně dolů). Určuje cílovou denní bilanci. + type: integer + targetWeightGrams: + description: Cílová hmotnost v gramech + type: integer + proteinPerKg: + description: | + Cílový příjem bílkovin v gramech na kilogram hmotnosti, s přesností + na desetiny (1,6). Desetinné číslo, protože doporučení se tak udávají. + type: number + +BmiKategorie: + description: | + Pásmo BMI podle WHO — PODVAHA pod 18,5, NORMA do 25, NADVAHA do 30, + OBEZITA od 30. + type: string + enum: + - PODVAHA + - NORMA + - NADVAHA + - OBEZITA + +BodyMetrics: + description: | + Ukazatele spočítané z tělesných údajů, posledního vážení a cílů. + Doplňuje server, nic z toho se neukládá. Každý chybí, když pro něj + nejsou údaje. + type: object + additionalProperties: false + properties: + weightGrams: + description: | + Aktuální hmotnost — z posledního vážení, bez vážení hmotnost zadaná + v profilu + type: integer + weightDate: + description: Den posledního vážení; chybí, pokud jde o hmotnost z profilu + type: string + format: date + bmi: + description: Body mass index, kg / m², zaokrouhlený na desetiny + type: number + bmiCategory: + $ref: "#/BmiKategorie" + healthyWeightMinGrams: + description: Hmotnost odpovídající BMI 18,5 při zadané výšce + type: integer + healthyWeightMaxGrams: + description: Hmotnost odpovídající BMI 25 při zadané výšce + type: integer + bodyFatPercent: + description: | + Odhad podílu tuku v procentech metodou US Navy z výšky a obvodů + (muž pas a krk, žena navíc boky), zaokrouhlený na desetiny + type: number + targetDailyBalance: + description: | + Cílová denní bilance v kcal podle týdenního tempa + (1 kg tělesné hmotnosti ≈ 7 700 kcal) + type: integer + proteinTargetGrams: + description: Cílový denní příjem bílkovin v gramech podle aktuální hmotnosti + type: integer + weeksToTarget: + description: | + Za kolik týdnů se při zvoleném tempu dosáhne cílové hmotnosti. + Chybí, pokud tempo nevede k cíli (nulové nebo opačným směrem). + type: integer + +WeightEntry: + description: Jedno vážení + type: object + additionalProperties: false + required: + - date + - weightGrams + properties: + date: + type: string + format: date + weightGrams: + description: Hmotnost v gramech + type: integer ActivityUnit: description: Jednotka, ve které se aktivita měří @@ -756,6 +956,11 @@ EnergyBalance: False, pokud uživatel nemá nastavený klidový výdej. Bilance pak porovnává jen jídlo proti pohybu a není to skutečný deficit. type: boolean + targetBalance: + description: | + Cílová bilance dne podle týdenního tempa změny hmotnosti. Chybí, + pokud si uživatel tempo nenastavil. + type: integer DayOverview: description: Kompletní přehled dne — jídlo, pohyb a jejich bilance @@ -782,6 +987,14 @@ DayOverview: bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin včetně spánku a žádná standardní rovnice délku spánku nezohledňuje. type: integer + weightGrams: + description: Vážení zapsané k tomuto dni v gramech + type: integer + proteinTargetGrams: + description: | + Cílový příjem bílkovin dne v gramech — podle hmotnosti platné k tomuto + dni. Chybí bez nastaveného cíle. + type: integer Pohlavi: description: Pohlaví — vstupuje do výpočtu bazálního metabolismu @@ -793,7 +1006,7 @@ Pohlavi: PersonalProfile: description: | Tělesné údaje uživatele. Slouží k výpočtu bazálního metabolismu - (klidového výdeje) rovnicí Mifflin–St Jeor. + (klidového výdeje) rovnicí Mifflin–St Jeor, BMI a odhadu podílu tuku. type: object additionalProperties: false properties: @@ -806,7 +1019,18 @@ PersonalProfile: description: Výška v centimetrech type: integer weightGrams: - description: Hmotnost v gramech. Celé číslo, aby se počítalo bez desetinných chyb. + description: | + Hmotnost v gramech zadaná dřív, než přibylo vážení po dnech. Použije se, + jen dokud uživatel žádné vážení nezapsal. + type: integer + waistCm: + description: Obvod pasu v cm — u mužů v úrovni pupku, u žen v nejužším místě + type: integer + neckCm: + description: Obvod krku v cm, pod ohryzkem + type: integer + hipCm: + description: Obvod boků v cm v nejširším místě; do odhadu tuku vstupuje jen u žen type: integer WorkoutItemKind: @@ -820,52 +1044,46 @@ WorkoutItemKind: MealTemplate: description: | - Předpřipravené jídlo. Použitím se založí do zvoleného dne, takže opakovaně - jedené jídlo není potřeba zadávat znovu. Na rozdíl od šablon tréninků se - šablony jídel neskládají z jiných — jsou to ploché záznamy. + Recept — vařené jídlo se surovinami a postupem. Hodnoty jedné porce + (gramáž, energie, cena) dopočte server ze surovin při uložení a použitím + receptu se z nich založí běžný záznam jídla. Opakovaně jedená hotová jídla + šablony nemají, nabízejí se z historie (MealSuggestion). type: object additionalProperties: false required: - id - - kind - name + - ingredients properties: id: type: string - kind: - $ref: "#/MealTemplateKind" name: description: Název jídla type: string source: - description: Zdroj jídla (podnik, obchod, "doma", ...) + description: Zdroj jídla, typicky "doma" type: string mealType: description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd. allOf: - $ref: "#/MealType" calories: - description: | - Energetická hodnota celé porce v kcal. Použije se, když jídlo nemá - vyplněnou gramáž a energii na 100 g. + description: Energetická hodnota jedné porce v kcal type: integer caloriesPer100g: - description: Energetická hodnota na 100 g v kcal + description: Energetická hodnota hotového jídla na 100 g v kcal type: integer weight: - description: Obvyklá gramáž porce v gramech + description: Gramáž jedné porce v gramech type: integer price: - description: Obvyklá cena v haléřích - type: integer - pricePer100g: - description: Cena za 100 g v haléřích + description: Cena jedné porce v haléřích type: integer note: - description: Poznámka, u receptu postup přípravy + description: Postup přípravy type: string ingredients: - description: Suroviny receptu. U hotového jídla chybí. + description: Suroviny receptu type: array items: $ref: "#/RecipeIngredient" @@ -880,46 +1098,27 @@ MealTemplate: MealTemplateInput: description: | - Data pro založení nebo úpravu šablony jídla. Oproti {@link MealTemplate} je - `id` volitelné — bez něj se šablona založí jako nová. + Data pro založení nebo úpravu receptu. Oproti {@link MealTemplate} je + `id` volitelné — bez něj se recept založí jako nový — a hodnoty porce + chybí, protože je server dopočte ze surovin. type: object additionalProperties: false required: - name + - ingredients properties: id: - description: Identifikátor upravované šablony. Bez něj se založí nová. + description: Identifikátor upravovaného receptu. Bez něj se založí nový. type: string - kind: - description: Druh šablony. Bez něj se založí hotové jídlo. - allOf: - - $ref: "#/MealTemplateKind" name: type: string source: type: string - nullable: true mealType: allOf: - $ref: "#/MealType" - calories: - type: integer - nullable: true - caloriesPer100g: - type: integer - nullable: true - weight: - type: integer - nullable: true - price: - type: integer - nullable: true - pricePer100g: - type: integer - nullable: true note: type: string - nullable: true ingredients: type: array items: @@ -947,18 +1146,6 @@ ImportMode: - PRIDAT - NAHRADIT -MealTemplateKind: - description: | - Druh šablony jídla. - HOTOVE — objednané nebo koupené jídlo; stačí si pamatovat výsledné - hodnoty porce (kcal, cenu), aby se nemusely dohledávat znovu. - RECEPT — vařené jídlo; eviduje se, z čeho a jak vzniklo, a hodnoty - porce se dopočítají ze surovin. - type: string - enum: - - HOTOVE - - RECEPT - RecipeIngredient: description: Jedna surovina receptu type: object