feat: předělání historie a profilu
This commit is contained in:
1 parent
9e72f431a1
commit
8fa7f601f2
97 files changed
+7432
-964
No files matched your search
@@ -59,8 +59,10 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
- **Entry:** `index.ts` — Express app, auth middleware, error middleware
|
- **Entry:** `index.ts` — Express app, auth middleware, error middleware
|
||||||
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
||||||
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
||||||
- **Domain:** `meals.ts` (jídlo), `activities.ts` (pohyb), `workouts.ts` (šablony
|
- **Domain:** `meals.ts` (jídlo a historie jídel), `nutrients.ts` (živiny),
|
||||||
tréninků), `sleep.ts` (spánek), `profile.ts` (tělesné údaje a výpočet BMR),
|
`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),
|
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
||||||
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
|
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
|
||||||
- **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy
|
- **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/)
|
### Client (client/src/)
|
||||||
- **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
|
- **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),
|
- **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`)
|
`StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`)
|
||||||
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||||
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
|
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ů,
|
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||||
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
||||||
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
- **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í.
|
nezapočítávají.
|
||||||
- Gramáž (`weight`, g) a jednotkové hodnoty (`pricePer100g` v haléřích za 100 g,
|
- Gramáž (`weight`, g) a jednotkové hodnoty (`pricePer100g` v haléřích za 100 g,
|
||||||
`caloriesPer100g` v kcal) umožňují dopočet — viz níže.
|
`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
|
## 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.
|
`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,
|
**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`)
|
než server uloží. Totéž platí pro `applyMultiplier` (`server/src/utils.ts`),
|
||||||
a `deriveRecipe` (`server/src/mealTemplates.ts`).
|
`deriveRecipe` (`server/src/mealTemplates.ts`) a `getPortionNutrients`
|
||||||
|
(`server/src/nutrients.ts`).
|
||||||
|
|
||||||
## Násobek položky dne
|
## 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.
|
výchozí stav a neukládá se.
|
||||||
|
|
||||||
Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` —
|
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
|
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. Záznamy ve dni
|
||||||
umí `createMealTemplateFromMeal`, které navíc **přepíše šablonu stejného názvu**;
|
zůstanou beze změny. U jídla nic takového není, opakovaně jedená jídla se nabízejí
|
||||||
opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik
|
z historie (viz Historie jídel a recepty).
|
||||||
stejných. Záznamy ve dni zůstanou v obou případech beze změny.
|
|
||||||
|
|
||||||
**Dodatečná zátěž** (`extraWeightGrams` u aktivity i cviku šablony, v gramech)
|
**Dodatečná zátěž** (`extraWeightGrams` u aktivity i cviku šablony, v gramech)
|
||||||
je jen informace o tréninku, kalorie **nemění** — žádný jednoduchý vzorec
|
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,
|
**to není skutečný deficit**, proto se to přes `hasBasal: false` propisuje do UI,
|
||||||
aby to číslo nikoho nemátlo.
|
aby to číslo nikoho nemátlo.
|
||||||
|
|
||||||
Klidový výdej se bere z `settings.effectiveBasalCalories`: ručně zadaný
|
Klidový výdej: ručně zadaný `basalCalories` má přednost před hodnotou spočítanou
|
||||||
`basalCalories` má přednost před hodnotou spočítanou z tělesných údajů
|
z tělesných údajů (`computeBasalCalories` v `profile.ts`, rovnice Mifflin–St Jeor).
|
||||||
(`computeBasalCalories` v `profile.ts`, rovnice Mifflin–St Jeor). Výpočet
|
`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
|
**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
|
TDEE — pohyb se eviduje zvlášť a přičítá se, takže vynásobení by ho započítalo
|
||||||
dvakrát.
|
dvakrát.
|
||||||
|
|
||||||
|
**Vážení** (`weights.ts`, klíč `weights:<login>` — mapa den → gramy) se zapisuje
|
||||||
|
v přehledu dne nebo na stránce Profil. Hmotnost v profilu (`profile.weightGrams`)
|
||||||
|
je pozůstatek z doby před vážením po dnech a použije se, jen dokud uživatel
|
||||||
|
žádné vážení nemá; klient ji při uložení profilu posílá zpět beze změny.
|
||||||
|
|
||||||
|
**Ukazatele** (`bodyMetrics.ts`, `UserSettings.bodyMetrics`) počítá server, nic
|
||||||
|
z nich se neukládá: BMI s pásmem podle WHO, zdravé rozmezí hmotnosti (BMI
|
||||||
|
18,5–25), podíl tuku metodou US Navy z obvodů (muž pas a krk, žena navíc boky)
|
||||||
|
a z cílů (`Goals`) cílová denní bilance a bílkoviny. Tempo počítá s **7 700 kcal
|
||||||
|
na kilogram** — −0,5 kg týdně je −550 kcal denně. Cílová bilance
|
||||||
|
(`EnergyBalance.targetBalance`) i cíl bílkovin (`DayOverview.proteinTargetGrams`)
|
||||||
|
jsou jen informace pro uživatele, do bilance nevstupují. Popisky tempa ve výběru
|
||||||
|
na stránce Profil počítají kcal/den se stejnou konstantou.
|
||||||
|
|
||||||
**Spánek** (`sleep.ts`, klíč `sleep:<login>:<datum>`) se eviduje jako kontext dne
|
**Spánek** (`sleep.ts`, klíč `sleep:<login>:<datum>`) se eviduje jako kontext dne
|
||||||
a do bilance **nevstupuje**. Klidový výdej je hodnota za celých 24 hodin včetně
|
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
|
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),
|
`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.
|
takže `DayPage` si vystačí s jedním voláním.
|
||||||
|
|
||||||
## Šablony jídel
|
## Historie jídel a recepty
|
||||||
|
|
||||||
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je opakovaně
|
Hotová jídla (objednaná, koupená) **nemají šablony**. Při zakládání jídla se
|
||||||
jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných**. Použitím
|
k rozepsanému názvu nabízí historie: `getMealHistory` v `meals.ts`
|
||||||
vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu i kalorie dopočítá
|
(`GET /api/meals/history`) projde všechna jídla uživatele a každý název vrátí
|
||||||
stejná logika jako u ručně zadaného jídla. Při zakládání jídla v přehledu dne se
|
jednou (`MealSuggestion`). Názvy se porovnávají znormalizované přes
|
||||||
šablony nabízejí k rozepsanému názvu (`TemplateSuggestions`).
|
`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:
|
Návrhy se nabízejí i při **úpravě** jídla, ale až po změně názvu, a doplní jen
|
||||||
buď `calories`, nebo `caloriesPer100g` + `weight`; obojí projde `deriveAmounts`.
|
prázdná pole (`mergeSuggestion` v `MealModal`) — slouží k přejmenování
|
||||||
- **RECEPT** — vařené jídlo se surovinami (`ingredients`), počtem porcí
|
naimportovaného jídla, jehož cena a zdroj jsou skutečné. Po uložení jídla
|
||||||
(`servings`), hmotností hotového jídla (`cookedWeight`) a postupem v `note`.
|
s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do
|
||||||
`deriveRecipe` z nich při uložení spočítá hodnoty **jedné porce** a uloží je
|
ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`,
|
||||||
do stejných plochých polí (`weight`, `caloriesPer100g`, `calories`, `price`),
|
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
|
||||||
takže použití šablony je pro oba druhy stejné. Energie na 100 g se bere
|
|
||||||
z hmotnosti hotového jídla, ne ze součtu surovin — vařením se voda odpaří
|
|
||||||
nebo nasákne. Postup se do záznamu dne nekopíruje.
|
|
||||||
|
|
||||||
Šablony uložené dřív, než druhy přibyly, nemají `kind`; `withKind` jim ho při
|
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
||||||
načtení doplní na `HOTOVE`, v úložišti se nic nepřepisuje.
|
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
||||||
`createMealTemplateFromMeal` recept stejného názvu **nepřepíše** (přišel by
|
psát jde i nový.
|
||||||
o suroviny a postup) a odmítne to chybou.
|
|
||||||
|
|
||||||
Jídlo založené ze šablony nese `templateId`. Nastavuje ho jen server
|
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je **recept** —
|
||||||
|
vařené jídlo se surovinami (`ingredients`), počtem porcí (`servings`), hmotností
|
||||||
|
hotového jídla (`cookedWeight`) a postupem v `note`. `deriveRecipe` z nich při
|
||||||
|
uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`weight`,
|
||||||
|
`caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes
|
||||||
|
`addMeal`. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu
|
||||||
|
surovin — vařením se voda odpaří nebo nasákne. Postup se do záznamu dne nekopíruje.
|
||||||
|
Recepty se nabízejí v našeptávání i ve výběru z historie (`ApplyMealModal`)
|
||||||
|
a jídlo stejného jména z historie se vedle receptu nenabízí podruhé.
|
||||||
|
|
||||||
|
Dřív pod stejným klíčem ležely i šablony hotových jídel (`kind: HOTOVE`, nebo
|
||||||
|
bez `kind`). Ty zanikly: `loadRecipes` je při čtení vynechá (poznají se podle
|
||||||
|
toho, že nemají suroviny) a první zápis receptu je z úložiště odklidí. **Jídla
|
||||||
|
z nich založená ve dnech zůstávají**, dál nesou `templateId` zaniklé šablony.
|
||||||
|
|
||||||
|
Jídlo založené z receptu nese `templateId`. Nastavuje ho jen server
|
||||||
(`addMeal` s třetím parametrem) — `MealInput` ho nemá, takže ho klient
|
(`addMeal` s třetím parametrem) — `MealInput` ho nemá, takže ho klient
|
||||||
nepodstrčí a úprava jídla ho zachová.
|
nepodstrčí a úprava jídla ho zachová.
|
||||||
|
|
||||||
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate`
|
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné a hodnoty porce
|
||||||
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
chybí — dopočítá je server.
|
||||||
|
|
||||||
## Import z Luncheru
|
## 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`
|
- `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")
|
("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.
|
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,
|
- 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.
|
jídla a částky — opakovaný import stejného měsíce nic nezduplikuje.
|
||||||
- Režim (`ImportMode`): **PRIDAT** (výchozí) přeskočí už naimportované řádky;
|
- Režim (`ImportMode`): **PRIDAT** (výchozí) přeskočí už naimportované řádky;
|
||||||
|
|||||||
+37
-8
@@ -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
|
// Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole
|
||||||
.derived-value {
|
.derived-value {
|
||||||
display: flex;
|
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
|
// Návrhy z historie a receptů pod názvem jídla ve formuláři
|
||||||
.template-suggestions {
|
.meal-suggestions {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border: 1px solid var(--ft-border-light);
|
border: 1px solid var(--ft-border-light);
|
||||||
border-radius: var(--ft-radius);
|
border-radius: var(--ft-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.template-suggestions-title {
|
.meal-suggestions-title {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -873,7 +902,7 @@ body {
|
|||||||
background: var(--ft-bg-hover);
|
background: var(--ft-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-suggestion {
|
.meal-suggestion {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -892,16 +921,16 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-suggestion-icon {
|
.meal-suggestion-icon {
|
||||||
color: var(--ft-primary);
|
color: var(--ft-primary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-suggestion-name {
|
.meal-suggestion-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-suggestion-detail {
|
.meal-suggestion-detail {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--ft-text-muted);
|
color: var(--ft-text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
+58
-10
@@ -1,4 +1,5 @@
|
|||||||
import { MealTemplate } from "../../types";
|
import { MealSuggestion, MealTemplate, Nutrients } from "../../types";
|
||||||
|
import { NUTRIENTS_IN_ORDER } from "./enums";
|
||||||
|
|
||||||
const TOKEN_KEY = "token";
|
const TOKEN_KEY = "token";
|
||||||
|
|
||||||
@@ -341,17 +342,64 @@ export const deriveRecipe = (
|
|||||||
export const normalizeSearch = (value: string): string =>
|
export const normalizeSearch = (value: string): string =>
|
||||||
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||||
|
|
||||||
/**
|
/** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */
|
||||||
* Popíše šablonu jídla jednou řádkou — zdroj, gramáž porce, kalorie a cena,
|
export const describeMealTemplate = (template: MealTemplate): string => [
|
||||||
* co je vyplněné. U receptu jsou to hodnoty jedné porce, které dopočítal server.
|
|
||||||
*/
|
|
||||||
export const describeMealTemplate = (template: MealTemplate): string => {
|
|
||||||
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
|
||||||
const calories = derived.calories ?? template.calories;
|
|
||||||
return [
|
|
||||||
template.source,
|
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 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 describeMealSuggestion = (suggestion: MealSuggestion): string => {
|
||||||
|
const derived = deriveAmounts(suggestion.price, suggestion.pricePer100g, suggestion.weight, suggestion.caloriesPer100g);
|
||||||
|
const calories = derived.calories ?? suggestion.calories;
|
||||||
|
return [
|
||||||
|
suggestion.source,
|
||||||
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
||||||
calories != null ? formatCalories(calories) : undefined,
|
calories != null ? formatCalories(calories) : undefined,
|
||||||
template.price != null ? formatPrice(template.price) : undefined,
|
suggestion.price != null ? formatPrice(suggestion.price) : undefined,
|
||||||
].filter(item => !!item).join(' · ');
|
].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('.', ',');
|
||||||
@@ -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<Props>) => {
|
||||||
|
const metrics = settings.bodyMetrics ?? {};
|
||||||
|
const formatDecimal = (value: number) => value.toLocaleString('cs-CZ', { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="summary-grid">
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faWeightScale} /> Hmotnost</div>
|
||||||
|
<div className="summary-value">{formatWeightKg(metrics.weightGrams)}</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{metrics.weightDate && `zváženo ${getHumanDate(metrics.weightDate)}`}
|
||||||
|
{!metrics.weightDate && (metrics.weightGrams != null ? 'z profilu, zapište vážení' : 'zapište vážení')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`summary-tile bmi-tile bmi-${metrics.bmiCategory?.toLowerCase() ?? 'none'}`}>
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faRulerCombined} /> BMI</div>
|
||||||
|
<div className="summary-value">{metrics.bmi != null ? formatDecimal(metrics.bmi) : '—'}</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{metrics.bmiCategory && <>{BMI_KATEGORIE_NAMES[metrics.bmiCategory]}<br /></>}
|
||||||
|
{metrics.healthyWeightMinGrams != null && metrics.healthyWeightMaxGrams != null
|
||||||
|
? `zdravá hmotnost ${formatDecimal(metrics.healthyWeightMinGrams / 1000)}–${formatWeightKg(metrics.healthyWeightMaxGrams)}`
|
||||||
|
: 'vyplňte výšku'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faPercent} /> Podíl tuku</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{metrics.bodyFatPercent != null ? `${formatDecimal(metrics.bodyFatPercent)} %` : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{metrics.bodyFatPercent != null ? 'odhad US Navy, ±3–4 %' : 'vyplňte obvody níže'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Klidový výdej</div>
|
||||||
|
<div className="summary-value">{formatCalories(settings.effectiveBasalCalories)}</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{settings.basalCalories != null && 'ručně zadaný'}
|
||||||
|
{settings.basalCalories == null && (settings.computedBasalCalories != null
|
||||||
|
? 'Mifflin–St Jeor, odhad ±10 %'
|
||||||
|
: 'vyplňte údaje a vážení')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile accent">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faBullseye} /> Cíl</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{metrics.targetDailyBalance != null ? formatBalance(metrics.targetDailyBalance) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{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 && <><br />bílkoviny {metrics.proteinTargetGrams} g denně</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BodyMetricsTiles;
|
||||||
@@ -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<Props>) => {
|
||||||
|
const [preview, setPreview] = useState<FillSameNameResult | undefined>();
|
||||||
|
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 (
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
className="py-2 small d-flex align-items-center gap-2"
|
||||||
|
dismissible
|
||||||
|
onClose={() => onDismiss()}
|
||||||
|
>
|
||||||
|
<span className="me-auto">
|
||||||
|
{preview.count === 1
|
||||||
|
? `Další jídlo „${entry.name}“ nemá kalorie.`
|
||||||
|
: `Další jídla „${entry.name}“ (${preview.count}) nemají kalorie.`}
|
||||||
|
{' '}Doplnit stejné hodnoty?
|
||||||
|
</span>
|
||||||
|
<Button size="sm" onClick={() => fill()} disabled={busy}>Doplnit</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillSameNameOffer;
|
||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { useAuth } from "../context/auth";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
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: DEN_URL, label: 'Můj den' },
|
||||||
{ url: SABLONY_URL, label: 'Šablony' },
|
{ url: SABLONY_URL, label: 'Šablony' },
|
||||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||||
|
{ url: PROFIL_URL, label: 'Profil' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -53,12 +54,6 @@ export default function Header() {
|
|||||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
|
||||||
<NavDropdown.Item onClick={() => navigate(SABLONY_URL)}>Šablony tréninků</NavDropdown.Item>
|
|
||||||
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
|
||||||
<NavDropdown.Item onClick={() => navigate(PROFIL_URL)}>
|
|
||||||
<FontAwesomeIcon icon={faUser} /> Osobní údaje
|
|
||||||
</NavDropdown.Item>
|
|
||||||
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faGear} /> Nastavení
|
<FontAwesomeIcon icon={faGear} /> Nastavení
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Form } from "react-bootstrap";
|
import { Form } from "react-bootstrap";
|
||||||
import { ImportMode, ImportResult, MealEntry } from "../../../types";
|
import { ImportMode, ImportResult, MealEntry } from "../../../types";
|
||||||
import { MEAL_TYPE_ICONS } from "../enums";
|
import { MEAL_TYPE_ICONS } from "../enums";
|
||||||
import { formatPrice, getDayOfWeek, getHumanDate } from "../Utils";
|
import { formatCalories, formatPrice, getDayOfWeek, getHumanDate } from "../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
preview: ImportResult,
|
preview: ImportResult,
|
||||||
@@ -17,7 +17,8 @@ type Props = {
|
|||||||
* Náhled importu rozdělený po dnech.
|
* 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 —
|
* 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<Props>) => {
|
const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }: Readonly<Props>) => {
|
||||||
const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort();
|
const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort();
|
||||||
@@ -67,6 +68,14 @@ const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }:
|
|||||||
<span className="import-preview-name">
|
<span className="import-preview-name">
|
||||||
{entry.name}
|
{entry.name}
|
||||||
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
||||||
|
{entry.calories != null && (
|
||||||
|
<span
|
||||||
|
className="import-preview-note"
|
||||||
|
title="Doplněno z posledního jídla stejného názvu"
|
||||||
|
>
|
||||||
|
{' · '}{formatCalories(entry.calories)} z historie
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MealEntry } from "../../../types";
|
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 RowActions from "./RowActions";
|
||||||
import MultiplierControl from "./MultiplierControl";
|
import MultiplierControl from "./MultiplierControl";
|
||||||
|
|
||||||
@@ -7,13 +8,14 @@ type Props = {
|
|||||||
entry: MealEntry,
|
entry: MealEntry,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
onDelete: () => void,
|
onDelete: () => void,
|
||||||
onSaveTemplate: () => void,
|
|
||||||
onMultiplierChange: (multiplier: number) => void,
|
onMultiplierChange: (multiplier: number) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Jeden řádek seznamu jídel v přehledu dne. */
|
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||||
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly<Props>) => {
|
const MealRow = ({ entry, onEdit, onDelete, onMultiplierChange }: Readonly<Props>) => {
|
||||||
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
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 (
|
return (
|
||||||
<div className="meal-row">
|
<div className="meal-row">
|
||||||
<div className="meal-main">
|
<div className="meal-main">
|
||||||
@@ -30,6 +32,17 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }
|
|||||||
{formatCalories(calories)}
|
{formatCalories(calories)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{nutrients && nutrientKeys.some(key => NUTRIENT_SHORT_NAMES[key]) && (
|
||||||
|
<span
|
||||||
|
className="tag"
|
||||||
|
title={nutrientKeys.map(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(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +53,7 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }
|
|||||||
label="jídla"
|
label="jídla"
|
||||||
/>
|
/>
|
||||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<Props>) => {
|
||||||
|
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 (
|
||||||
|
<div className="meal-suggestions">
|
||||||
|
<div className="meal-suggestions-title">Už jste to měli?</div>
|
||||||
|
{recipeList.map(recipe => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="meal-suggestion"
|
||||||
|
key={recipe.id}
|
||||||
|
onClick={() => onPickRecipe(recipe)}
|
||||||
|
title="Vyplnit hodnoty jedné porce receptu"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} className="meal-suggestion-icon" />
|
||||||
|
<span className="meal-suggestion-name">{recipe.name}</span>
|
||||||
|
<span className="meal-suggestion-detail">{describeMealTemplate(recipe)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{historyList.map(item => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="meal-suggestion"
|
||||||
|
key={item.name}
|
||||||
|
onClick={() => onPickHistory(item)}
|
||||||
|
title="Vyplnit hodnoty z posledního záznamu"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} className="meal-suggestion-icon" />
|
||||||
|
<span className="meal-suggestion-name">{item.name}</span>
|
||||||
|
<span className="meal-suggestion-detail">{describeMealSuggestion(item)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealSuggestions;
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
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 { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
import {
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||||
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories,
|
|
||||||
} from "../Utils";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
template: MealTemplate,
|
template: MealTemplate,
|
||||||
@@ -14,18 +12,11 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Karta šablony jídla na stránce Šablony.
|
* Karta receptu na stránce Šablony — suroviny, hodnoty celého hrnce i jedné
|
||||||
*
|
* porce a postup.
|
||||||
* 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í.
|
|
||||||
*/
|
*/
|
||||||
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
||||||
const isRecipe = template.kind === MealTemplateKind.RECEPT;
|
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-panel template-card">
|
<div className="card-panel template-card">
|
||||||
@@ -38,18 +29,18 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{template.source && <span className="tag">{template.source}</span>}
|
{template.source && <span className="tag">{template.source}</span>}
|
||||||
{derived.weight != null && (
|
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||||
<span className="tag">{isRecipe ? 'porce ' : ''}{formatWeight(derived.weight)}</span>
|
{(template.servings ?? 1) > 1 && (
|
||||||
)}
|
|
||||||
{isRecipe && (template.servings ?? 1) > 1 && (
|
|
||||||
<span className="tag">{template.servings} porcí</span>
|
<span className="tag">{template.servings} porcí</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{calories != null && <span className="template-calories">{formatCalories(calories)}</span>}
|
{template.calories != null && (
|
||||||
|
<span className="template-calories">{formatCalories(template.calories)}</span>
|
||||||
|
)}
|
||||||
{template.price != null && (
|
{template.price != null && (
|
||||||
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
||||||
)}
|
)}
|
||||||
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit šablonu">
|
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit recept">
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -57,13 +48,13 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
onClick={() => onDelete()}
|
onClick={() => onDelete()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title="Smazat"
|
title="Smazat"
|
||||||
aria-label="Smazat šablonu"
|
aria-label="Smazat recept"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRecipe && !!template.ingredients?.length && (
|
{!!template.ingredients.length && (
|
||||||
<ul className="template-items">
|
<ul className="template-items">
|
||||||
{template.ingredients.map((ingredient, index) => (
|
{template.ingredients.map((ingredient, index) => (
|
||||||
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
||||||
@@ -80,14 +71,12 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipe && (
|
|
||||||
<div className="recipe-summary">
|
<div className="recipe-summary">
|
||||||
Celkem {formatCalories(recipe.totalCalories)}
|
Celkem {formatCalories(recipe.totalCalories)}
|
||||||
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||||
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||||
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{template.note && <p className="meal-template-note">{template.note}</p>}
|
{template.note && <p className="meal-template-note">{template.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,47 +1,33 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, ButtonGroup, Form } from "react-bootstrap";
|
import { Alert, Button, Form } from "react-bootstrap";
|
||||||
import { MealTemplate, MealTemplateKind, MealType, saveMealTemplate } from "../../../types";
|
import { MealTemplate, MealType, saveMealTemplate } from "../../../types";
|
||||||
import {
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||||
} from "../enums";
|
import SourceInput from "./SourceInput";
|
||||||
import {
|
|
||||||
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
|
||||||
parseIntInput, parsePriceInput,
|
|
||||||
} from "../Utils";
|
|
||||||
import CalorieLookup from "./CalorieLookup";
|
|
||||||
import RecipeIngredientsEditor, {
|
import RecipeIngredientsEditor, {
|
||||||
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||||
} from "./RecipeIngredientsEditor";
|
} from "./RecipeIngredientsEditor";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Upravovaná šablona. Pokud chybí, zakládá se nová. */
|
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
|
||||||
template?: MealTemplate,
|
template?: MealTemplate,
|
||||||
onSaved: (templates: MealTemplate[]) => void,
|
onSaved: (templates: MealTemplate[]) => void,
|
||||||
onCancel: () => 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) => ({
|
const createForm = (template?: MealTemplate) => ({
|
||||||
kind: template?.kind ?? MealTemplateKind.HOTOVE,
|
|
||||||
name: template?.name ?? '',
|
name: template?.name ?? '',
|
||||||
source: template?.source ?? '',
|
source: template?.source ?? '',
|
||||||
mealType: (template?.mealType ?? '') as MealType | '',
|
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 ?? '',
|
note: template?.note ?? '',
|
||||||
servings: template?.servings != null ? String(template.servings) : '',
|
servings: template?.servings != null ? String(template.servings) : '',
|
||||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulář šablony jídla.
|
* 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.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||||
const [form, setForm] = useState(() => createForm(template));
|
const [form, setForm] = useState(() => createForm(template));
|
||||||
@@ -53,96 +39,57 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||||
setForm(current => ({ ...current, ...changes }));
|
setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
const isRecipe = form.kind === MealTemplateKind.RECEPT;
|
|
||||||
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
||||||
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
||||||
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
|
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
|
||||||
|
|
||||||
const preview = deriveAmounts(
|
const canSave = form.name.trim().length > 0 && filledIngredients.length > 0 && !busy;
|
||||||
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 save = async () => {
|
const save = async () => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
const common = {
|
// Hodnoty porce si server dopočte ze surovin sám
|
||||||
|
const body = {
|
||||||
...(template ? { id: template.id } : {}),
|
...(template ? { id: template.id } : {}),
|
||||||
kind: form.kind,
|
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||||
};
|
|
||||||
// Hodnoty porce receptu si server dopočte ze surovin sám
|
|
||||||
const body = isRecipe
|
|
||||||
? {
|
|
||||||
...common,
|
|
||||||
ingredients: filledIngredients,
|
ingredients: filledIngredients,
|
||||||
servings: parseIntInput(form.servings),
|
servings: parseIntInput(form.servings),
|
||||||
cookedWeight: parseIntInput(form.cookedWeight),
|
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 });
|
const response = await saveMealTemplate({ body });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
onSaved(response.data);
|
onSaved(response.data);
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<div className="card-panel template-form">
|
<div className="card-panel template-form">
|
||||||
<h2 className="chart-title">{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
<h2 className="chart-title">{template ? 'Úprava receptu' : 'Nový recept'}</h2>
|
||||||
|
|
||||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||||
|
|
||||||
<ButtonGroup className="mb-2">
|
|
||||||
{MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => (
|
|
||||||
<Button
|
|
||||||
key={kind}
|
|
||||||
variant={form.kind === kind ? 'primary' : 'outline-secondary'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => update({ kind })}
|
|
||||||
>
|
|
||||||
{MEAL_TEMPLATE_KIND_NAMES[kind]}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</ButtonGroup>
|
|
||||||
<p className="text-body-secondary small">
|
|
||||||
{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.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="row g-3 mb-3">
|
<div className="row g-3 mb-3">
|
||||||
<div className="col-sm-5">
|
<div className="col-sm-5">
|
||||||
<Form.Label>Název</Form.Label>
|
<Form.Label>Název</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
value={form.name}
|
value={form.name}
|
||||||
placeholder={isRecipe ? 'Např. Kuřecí rizoto' : 'Např. Sušenka BeBe'}
|
placeholder="Např. Kuřecí rizoto"
|
||||||
onChange={event => update({ name: event.target.value })}
|
onChange={event => update({ name: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4">
|
<div className="col-sm-4">
|
||||||
<Form.Label>Zdroj</Form.Label>
|
<Form.Label>Zdroj</Form.Label>
|
||||||
<Form.Control
|
<SourceInput
|
||||||
value={form.source}
|
value={form.source}
|
||||||
placeholder={isRecipe ? 'doma' : 'Chefie, Albert…'}
|
placeholder="doma"
|
||||||
onChange={event => update({ source: event.target.value })}
|
onChange={source => update({ source })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
@@ -161,8 +108,6 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRecipe ? (
|
|
||||||
<>
|
|
||||||
<fieldset className="meal-fieldset mb-3">
|
<fieldset className="meal-fieldset mb-3">
|
||||||
<legend>Suroviny</legend>
|
<legend>Suroviny</legend>
|
||||||
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
||||||
@@ -207,113 +152,27 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<fieldset className="meal-fieldset mb-3">
|
|
||||||
<legend>Kalorie, gramáž a cena porce</legend>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Gramáž</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.weight}
|
|
||||||
placeholder="350"
|
|
||||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">g</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Energie na 100 g</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.caloriesPer100g}
|
|
||||||
placeholder="160"
|
|
||||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">kcal</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Cena</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="decimal"
|
|
||||||
value={form.price}
|
|
||||||
placeholder="129,00"
|
|
||||||
onChange={event => update({ price: event.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">Kč</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Cena za 100 g</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="decimal"
|
|
||||||
value={form.pricePer100g}
|
|
||||||
placeholder="volitelné"
|
|
||||||
onChange={event => update({ pricePer100g: event.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">Kč</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row g-3 mt-0">
|
|
||||||
{preview.calories == null && (
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Kalorie porce</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.calories}
|
|
||||||
placeholder="volitelné"
|
|
||||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">kcal</span>
|
|
||||||
</div>
|
|
||||||
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="col-sm-6 d-flex align-items-end">
|
|
||||||
<CalorieLookup
|
|
||||||
name={form.name}
|
|
||||||
onPick={value => update({ caloriesPer100g: String(value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{isRecipe ? 'Postup' : 'Poznámka'}</Form.Label>
|
<Form.Label>Postup</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={isRecipe ? 6 : 2}
|
rows={6}
|
||||||
value={form.note}
|
value={form.note}
|
||||||
placeholder={isRecipe ? 'Jak se jídlo připravuje…' : undefined}
|
placeholder="Jak se jídlo připravuje…"
|
||||||
onChange={event => update({ note: event.target.value })}
|
onChange={event => update({ note: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<div className="workout-form-footer">
|
<div className="workout-form-footer">
|
||||||
{!isRecipe && previewCalories != null && (
|
{recipe.calories != null && (
|
||||||
<span className="workout-draft-calories">
|
|
||||||
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
|
||||||
{formatCalories(previewCalories)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isRecipe && recipe.calories != null && (
|
|
||||||
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||||
)}
|
)}
|
||||||
{template && (
|
{template && (
|
||||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||||
{template ? 'Uložit změny' : 'Vytvořit šablonu'}
|
{template ? 'Uložit změny' : 'Vytvořit recept'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Props>) => (
|
||||||
|
<div className="nutrient-summary card-panel" title="Jen jídla s gramáží a nutričními hodnotami">
|
||||||
|
{NUTRIENTS_IN_ORDER.filter(key => nutrients[key] != null).map(key => (
|
||||||
|
<div className="nutrient-summary-item" key={key}>
|
||||||
|
<span className="nutrient-summary-label">{NUTRIENT_NAMES[key]}</span>
|
||||||
|
<span className="nutrient-summary-value">
|
||||||
|
{formatNutrient(nutrients[key])}
|
||||||
|
{key === 'protein' && proteinTarget != null && (
|
||||||
|
<span className="nutrient-summary-target"> / {proteinTarget} g</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NutrientSummary;
|
||||||
@@ -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<keyof Nutrients, string>;
|
||||||
|
|
||||||
|
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<keyof Nutrients>(['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<Props>) => {
|
||||||
|
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 (
|
||||||
|
<Button variant="link" className="nutrients-toggle mb-3" onClick={() => setExpanded(true)}>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} /> Nutriční hodnoty
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="meal-fieldset mb-3">
|
||||||
|
<legend>
|
||||||
|
{!filled ? (
|
||||||
|
<button type="button" className="nutrients-legend-toggle" onClick={() => setExpanded(false)}>
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} /> Nutriční hodnoty na 100 g
|
||||||
|
</button>
|
||||||
|
) : 'Nutriční hodnoty na 100 g'}
|
||||||
|
</legend>
|
||||||
|
<div className="row g-2">
|
||||||
|
{NUTRIENTS_IN_ORDER.map(key => (
|
||||||
|
<div className="col-6 col-sm-3" key={key}>
|
||||||
|
<Form.Label className={SUB_NUTRIENTS.has(key) ? 'nutrient-sub-label' : undefined}>
|
||||||
|
{NUTRIENT_NAMES[key]}
|
||||||
|
</Form.Label>
|
||||||
|
<div className="input-group input-group-sm">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={values[key]}
|
||||||
|
isInvalid={values[key].trim().length > 0 && parseNutrientInput(values[key]) == null}
|
||||||
|
onChange={event => onChange({ ...values, [key]: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g</span>
|
||||||
|
</div>
|
||||||
|
{portion?.[key] != null && <Form.Text>porce {formatNutrient(portion[key])}</Form.Text>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filled && weight == null && (
|
||||||
|
<Form.Text>Bez gramáže se hodnoty porce nespočítají a do součtu dne nevstoupí.</Form.Text>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NutrientsFieldset;
|
||||||
@@ -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<Props>) => {
|
||||||
|
const listId = useId();
|
||||||
|
const [sources, setSources] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<Form.Control
|
||||||
|
list={listId}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{sources.map(source => <option key={source} value={source} />)}
|
||||||
|
</datalist>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SourceInput;
|
||||||
@@ -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<Props>) => {
|
|
||||||
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 (
|
|
||||||
<div className="template-suggestions">
|
|
||||||
<div className="template-suggestions-title">Už jste to měli?</div>
|
|
||||||
{matchList.map(template => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="template-suggestion"
|
|
||||||
key={template.id}
|
|
||||||
onClick={() => onPick(template)}
|
|
||||||
title="Vyplnit hodnoty ze šablony"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={template.kind === MealTemplateKind.RECEPT ? faBookOpen : faBookmark}
|
|
||||||
className="template-suggestion-icon"
|
|
||||||
/>
|
|
||||||
<span className="template-suggestion-name">{template.name}</span>
|
|
||||||
<span className="template-suggestion-detail">{describeMealTemplate(template)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TemplateSuggestions;
|
|
||||||
@@ -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<Props>) => {
|
||||||
|
const [weightList, setWeightList] = useState<WeightEntry[] | undefined>();
|
||||||
|
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 (
|
||||||
|
<div className="card-panel chart-panel">
|
||||||
|
<h2 className="chart-title">Vývoj hmotnosti</h2>
|
||||||
|
|
||||||
|
<div className="weight-form">
|
||||||
|
<Form.Control
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={event => event.target.value && setDate(event.target.value)}
|
||||||
|
aria-label="Den vážení"
|
||||||
|
/>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={weightInput}
|
||||||
|
placeholder="90,0"
|
||||||
|
aria-label="Hmotnost v kilogramech"
|
||||||
|
onChange={event => setWeightInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kg</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => save(date, weightGrams)} disabled={busy || weightGrams == null}>
|
||||||
|
Zapsat vážení
|
||||||
|
</Button>
|
||||||
|
<div className="weight-changes">
|
||||||
|
<span>týden <strong>{formatChange(getChange(weightList, 7))}</strong></span>
|
||||||
|
<span>měsíc <strong>{formatChange(getChange(weightList, 30))}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weightList.length > 1 && (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||||
|
<YAxis
|
||||||
|
domain={['dataMin - 2', 'dataMax + 2']}
|
||||||
|
tickFormatter={value => Math.round(value).toString()}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
stroke="var(--ft-text-muted)"
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
{minKg != null && maxKg != null && (
|
||||||
|
<ReferenceArea
|
||||||
|
y1={minKg}
|
||||||
|
y2={maxKg}
|
||||||
|
ifOverflow="hidden"
|
||||||
|
fill="var(--ft-primary)"
|
||||||
|
fillOpacity={0.08}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{targetKg != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={targetKg}
|
||||||
|
stroke="var(--ft-warning)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
ifOverflow="extendDomain"
|
||||||
|
label={{ value: 'cíl', position: 'insideTopRight', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
formatter={value => [`${Number(value).toLocaleString('cs-CZ')} kg`, 'Hmotnost']}
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="kg" stroke="var(--ft-primary)" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
{weightList.length <= 1 && (
|
||||||
|
<p className="text-body-secondary small mb-0">
|
||||||
|
Graf se ukáže od druhého vážení. Vážit se jde i v přehledu dne vedle spánku.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!weightList.length && (
|
||||||
|
<ul className="weight-list">
|
||||||
|
{weightList.slice(-RECENT_COUNT).reverse().map(entry => (
|
||||||
|
<li key={entry.date}>
|
||||||
|
<span>{getHumanDate(entry.date)}</span>
|
||||||
|
<strong>{formatWeightKg(entry.weightGrams)}</strong>
|
||||||
|
<button
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => save(entry.date)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Smazat vážení"
|
||||||
|
aria-label={`Smazat vážení ${getHumanDate(entry.date)}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WeightHistoryPanel;
|
||||||
@@ -2,12 +2,12 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Form, Modal, Spinner } from "react-bootstrap";
|
import { Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
|
||||||
import {
|
import {
|
||||||
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
||||||
} from "../../enums";
|
} from "../../../../types";
|
||||||
import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
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";
|
import { SABLONY_URL } from "../../routes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,20 +15,24 @@ type Props = {
|
|||||||
/** Den, do kterého se jídlo založí. */
|
/** Den, do kterého se jídlo založí. */
|
||||||
date: string,
|
date: string,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
/** Zavolá se po založení jídla s aktualizovaným přehledem dne. */
|
/** Zavolá se po založení jídla; přehled dne je potřeba načíst znovu. */
|
||||||
onApplied: (day: DayOverview) => void,
|
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
|
* Jídlo z historie se založí s hodnotami z posledního záznamu toho jména, recept
|
||||||
* vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat,
|
* jako jedna porce. Chod lze zvolit, protože totéž jídlo může být jednou oběd
|
||||||
* protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se
|
* a podruhé večeře, a násobkem se zapíší třeba dvě stejné sušenky naráz.
|
||||||
* 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<Props>) => {
|
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||||
|
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||||
const [mealType, setMealType] = useState<MealType | ''>('');
|
const [mealType, setMealType] = useState<MealType | ''>('');
|
||||||
const [multiplierInput, setMultiplierInput] = useState('');
|
const [multiplierInput, setMultiplierInput] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -42,8 +46,9 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
setMealType('');
|
setMealType('');
|
||||||
setMultiplierInput('');
|
setMultiplierInput('');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
getMealTemplates().then(response => {
|
Promise.all([getMealTemplates(), getMealHistory()]).then(([recipeResponse, historyResponse]) => {
|
||||||
setTemplates(response.data ?? []);
|
setRecipes(recipeResponse.data ?? []);
|
||||||
|
setHistory(historyResponse.data ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@@ -51,30 +56,50 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
const multiplier = parseMultiplierInput(multiplierInput);
|
const multiplier = parseMultiplierInput(multiplierInput);
|
||||||
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
|
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
|
||||||
|
|
||||||
const apply = async (id: string) => {
|
const applyRecipe = async (recipe: MealTemplate) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const response = await applyMealTemplate({
|
const response = await applyMealTemplate({
|
||||||
body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) },
|
body: {
|
||||||
|
id: recipe.id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (response.data) {
|
if (response.data) onApplied();
|
||||||
onApplied(response.data);
|
};
|
||||||
}
|
|
||||||
|
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 key = normalizeSearch(search);
|
||||||
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
|
const matches = (name: string) => !key.length || normalizeSearch(name).includes(key);
|
||||||
.map(kind => ({
|
const recipeList = recipes.filter(recipe => matches(recipe.name));
|
||||||
kind,
|
// Jídlo stejného jména jako recept vzniklo nejspíš z něj, nabízí se jen recept
|
||||||
templates: templates.filter(template => template.kind === kind
|
const recipeNames = new Set(recipes.map(recipe => normalizeSearch(recipe.name)));
|
||||||
&& (!key.length || normalizeSearch(template.name).includes(key))),
|
const historyMatchList = history.filter(item => matches(item.name) && !recipeNames.has(normalizeSearch(item.name)));
|
||||||
}))
|
const historyList = historyMatchList.slice(0, HISTORY_LIMIT);
|
||||||
.filter(group => group.templates.length > 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>Použít šablonu jídla</Modal.Title>
|
<Modal.Title>Vybrat z historie</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -83,18 +108,18 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !templates.length && (
|
{!loading && !recipes.length && !history.length && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
<div className="empty-icon"><FontAwesomeIcon icon={faClockRotateLeft} /></div>
|
||||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
<div className="empty-title">Zatím nemáte co vybírat</div>
|
||||||
<p className="empty-description">
|
<p className="empty-description">
|
||||||
Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem
|
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
|
||||||
„Uložit jako šablonu".
|
recept na stránce Šablony.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !!templates.length && (
|
{!loading && (!!recipes.length || !!history.length) && (
|
||||||
<>
|
<>
|
||||||
<div className="row g-3 mb-3">
|
<div className="row g-3 mb-3">
|
||||||
<div className="col-sm-5">
|
<div className="col-sm-5">
|
||||||
@@ -110,9 +135,10 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<Form.Label>Založit jako</Form.Label>
|
<Form.Label>Založit jako</Form.Label>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={mealType}
|
value={mealType}
|
||||||
|
title="Bez volby se jídlo z historie založí jako oběd a recept pod svým chodem"
|
||||||
onChange={event => setMealType(event.target.value as MealType | '')}
|
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||||
>
|
>
|
||||||
<option value="">— chod ze šablony —</option>
|
<option value="">— výchozí —</option>
|
||||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||||
@@ -133,25 +159,22 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!groupList.length && (
|
{!recipeList.length && !historyList.length && (
|
||||||
<p className="text-body-secondary small">Žádná šablona neodpovídá hledání.</p>
|
<p className="text-body-secondary small">Hledání nic neodpovídá.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupList.map(group => (
|
{!!recipeList.length && (
|
||||||
<div className="apply-template-group" key={group.kind}>
|
<div className="apply-template-group">
|
||||||
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
<h3 className="apply-template-group-title">Recepty</h3>
|
||||||
{group.templates.map(template => (
|
{recipeList.map(recipe => (
|
||||||
<div className="workout-row" key={template.id}>
|
<div className="workout-row" key={recipe.id}>
|
||||||
<div className="workout-info">
|
<div className="workout-info">
|
||||||
<div className="workout-name">{template.name}</div>
|
<div className="workout-name">{recipe.name}</div>
|
||||||
<div className="workout-items">
|
<div className="workout-items">{describeMealTemplate(recipe)}</div>
|
||||||
{describeMealTemplate(template)}
|
|
||||||
{!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => apply(template.id)}
|
onClick={() => applyRecipe(recipe)}
|
||||||
disabled={busy || multiplierInvalid}
|
disabled={busy || multiplierInvalid}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||||
@@ -159,16 +182,45 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!historyList.length && (
|
||||||
|
<div className="apply-template-group">
|
||||||
|
<h3 className="apply-template-group-title">Dříve snědené</h3>
|
||||||
|
{historyList.map(item => (
|
||||||
|
<div className="workout-row" key={item.name}>
|
||||||
|
<div className="workout-info">
|
||||||
|
<div className="workout-name">{item.name}</div>
|
||||||
|
<div className="workout-items">
|
||||||
|
{describeMealSuggestion(item)}
|
||||||
|
{item.count > 1 && ` · ${item.count}×`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => applyHistory(item)}
|
||||||
|
disabled={busy || multiplierInvalid}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{historyMatchList.length > historyList.length && (
|
||||||
|
<p className="text-body-secondary small mt-2">
|
||||||
|
A dalších {historyMatchList.length - historyList.length}, upřesněte hledání.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
onClick={() => { onClose(); navigate(SABLONY_URL, { state: { tab: TemplateTab.RECEPTY } }); }}
|
||||||
>
|
>
|
||||||
Spravovat šablony
|
Spravovat recepty
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Form, Modal } from "react-bootstrap";
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
import {
|
import {
|
||||||
DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings,
|
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings,
|
||||||
addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal,
|
addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal,
|
||||||
} from "../../../../types";
|
} 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 {
|
import {
|
||||||
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
||||||
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
|
formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
||||||
|
parsePriceInput,
|
||||||
} from "../../Utils";
|
} from "../../Utils";
|
||||||
import CalorieLookup from "../CalorieLookup";
|
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 = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -19,7 +22,8 @@ type Props = {
|
|||||||
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
|
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
|
||||||
entry?: MealEntry,
|
entry?: MealEntry,
|
||||||
onClose: () => void,
|
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. */
|
/** Prázdný formulář pro nové jídlo v daném dni. */
|
||||||
@@ -36,9 +40,35 @@ function emptyForm(date: string) {
|
|||||||
calories: '',
|
calories: '',
|
||||||
note: '',
|
note: '',
|
||||||
multiplier: '',
|
multiplier: '',
|
||||||
|
nutrients: emptyNutrientInputs(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MealForm = ReturnType<typeof emptyForm>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<MealForm>, 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<string, unknown>)[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í. */
|
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
||||||
function normalizeSource(source: string): string {
|
function normalizeSource(source: string): string {
|
||||||
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
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
|
* 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.
|
* 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ž
|
* K rozepsanému názvu se nabízejí recepty a jídla z historie, takže jídlo, které
|
||||||
* uživatel někdy měl, nemusí dohledávat znovu.
|
* 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<Props>) {
|
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||||
const [form, setForm] = useState(() => emptyForm(date));
|
const [form, setForm] = useState(() => emptyForm(date));
|
||||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
const [recipes, setRecipes] = useState<MealTemplate[]>([]);
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
const [history, setHistory] = useState<MealSuggestion[]>([]);
|
||||||
/** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
|
/** Název návrhu, ze kterého se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
|
||||||
const [pickedTemplate, setPickedTemplate] = useState<string | undefined>();
|
const [pickedSuggestion, setPickedSuggestion] = useState<string | undefined>();
|
||||||
|
|
||||||
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setAlsoTemplate(false);
|
setPickedSuggestion(entry?.name);
|
||||||
setPickedTemplate(undefined);
|
|
||||||
setForm(entry
|
setForm(entry
|
||||||
? {
|
? {
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
@@ -82,15 +113,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
calories: entry.calories != null ? String(entry.calories) : '',
|
calories: entry.calories != null ? String(entry.calories) : '',
|
||||||
note: entry.note ?? '',
|
note: entry.note ?? '',
|
||||||
multiplier: formatMultiplierInput(entry.multiplier),
|
multiplier: formatMultiplierInput(entry.multiplier),
|
||||||
|
nutrients: toNutrientInputs(entry.nutrientsPer100g),
|
||||||
}
|
}
|
||||||
: emptyForm(date));
|
: emptyForm(date));
|
||||||
}, [isOpen, entry, 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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || entry) return;
|
if (!isOpen) return;
|
||||||
getMealTemplates().then(response => setTemplates(response.data ?? []));
|
getMealTemplates().then(response => setRecipes(response.data ?? []));
|
||||||
}, [isOpen, entry]);
|
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
|
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,22 +133,41 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
|
|
||||||
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
/** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */
|
/**
|
||||||
const applyTemplate = (template: MealTemplate) => {
|
* Vyplní formulář jednou porcí receptu. Datum a násobek zůstanou, jak jsou,
|
||||||
setPickedTemplate(template.name);
|
* a postup do záznamu dne nepatří, zůstává u receptu.
|
||||||
setForm(current => ({
|
*/
|
||||||
...current,
|
const applyRecipe = (recipe: MealTemplate) => {
|
||||||
name: template.name,
|
setPickedSuggestion(recipe.name);
|
||||||
mealType: template.mealType ?? current.mealType,
|
setForm(current => mergeSuggestion(current, {
|
||||||
source: template.source ?? '',
|
name: recipe.name,
|
||||||
price: formatPriceInput(template.price),
|
...(recipe.mealType ? { mealType: recipe.mealType } : {}),
|
||||||
pricePer100g: formatPriceInput(template.pricePer100g),
|
source: recipe.source ?? '',
|
||||||
weight: template.weight != null ? String(template.weight) : '',
|
price: formatPriceInput(recipe.price),
|
||||||
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
pricePer100g: '',
|
||||||
calories: template.calories != null ? String(template.calories) : '',
|
weight: recipe.weight != null ? String(recipe.weight) : '',
|
||||||
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
caloriesPer100g: recipe.caloriesPer100g != null ? String(recipe.caloriesPer100g) : '',
|
||||||
note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '',
|
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. */
|
/** 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 multiplier = parseMultiplierInput(form.multiplier);
|
||||||
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
|
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
|
||||||
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
|
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 () => {
|
const save = async () => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
@@ -166,22 +220,18 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
calories: derived.calories != null ? undefined : manualCalories,
|
calories: derived.calories != null ? undefined : manualCalories,
|
||||||
note: form.note.trim() || undefined,
|
note: form.note.trim() || undefined,
|
||||||
multiplier,
|
multiplier,
|
||||||
|
nutrientsPer100g: toNutrients(form.nutrients),
|
||||||
};
|
};
|
||||||
const response = entry
|
const response = entry
|
||||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||||
: await addMeal({ 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ý
|
// U nového jídla je ten správný záznam vždy ten poslední založený
|
||||||
const saved = entry
|
const saved = entry
|
||||||
? response.data.entries.find(item => item.id === entry.id)
|
? response.data.entries.find(item => item.id === entry.id)
|
||||||
: [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
: [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||||
if (saved) {
|
onSaved(response.data, saved);
|
||||||
await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSaving(false);
|
|
||||||
if (response.data) {
|
|
||||||
onSaved(response.data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,13 +248,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={form.name}
|
value={form.name}
|
||||||
placeholder="Např. Kuřecí gyros s hranolkami"
|
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); }}
|
||||||
|
/>
|
||||||
|
{pickedSuggestion !== form.name && (
|
||||||
|
<MealSuggestions
|
||||||
|
recipes={recipes}
|
||||||
|
// Upravované jídlo samo sebe nenabízí
|
||||||
|
history={entry
|
||||||
|
? history.filter(item => normalizeSearch(item.name) !== normalizeSearch(entry.name))
|
||||||
|
: history}
|
||||||
|
query={form.name}
|
||||||
|
onPickRecipe={recipe => applyRecipe(recipe)}
|
||||||
|
onPickHistory={suggestion => applyHistory(suggestion)}
|
||||||
/>
|
/>
|
||||||
{!entry && pickedTemplate !== form.name && (
|
|
||||||
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
|
|
||||||
)}
|
)}
|
||||||
{!entry && pickedTemplate === form.name && (
|
{pickedSuggestion === form.name && pickedSuggestion !== entry?.name && (
|
||||||
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
|
<Form.Text>
|
||||||
|
{entry ? 'doplněna prázdná pole z dřívějška' : 'vyplněno z dřívějška, hodnoty jde upravit'}
|
||||||
|
</Form.Text>
|
||||||
)}
|
)}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
@@ -232,17 +293,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4">
|
<div className="col-sm-4">
|
||||||
<Form.Label>Zdroj</Form.Label>
|
<Form.Label>Zdroj</Form.Label>
|
||||||
<Form.Control
|
<SourceInput
|
||||||
list="known-sources"
|
|
||||||
value={form.source}
|
value={form.source}
|
||||||
placeholder="TechTower, doma…"
|
placeholder="TechTower, doma…"
|
||||||
onChange={event => update({ source: event.target.value })}
|
onChange={source => update({ source })}
|
||||||
/>
|
/>
|
||||||
<datalist id="known-sources">
|
|
||||||
{settings?.sourceRates.map(rate => (
|
|
||||||
<option key={rate.source} value={rate.source} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,6 +408,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<NutrientsFieldset
|
||||||
|
values={form.nutrients}
|
||||||
|
weight={derived.weight}
|
||||||
|
onChange={nutrients => update({ nutrients })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label>Poznámka</Form.Label>
|
<Form.Label>Poznámka</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -365,13 +426,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Form.Check
|
|
||||||
className="modal-template-check"
|
|
||||||
id="meal-also-template"
|
|
||||||
checked={alsoTemplate}
|
|
||||||
label="Uložit i jako šablonu"
|
|
||||||
onChange={event => setAlsoTemplate(event.target.checked)}
|
|
||||||
/>
|
|
||||||
<div className="meal-modal-summary">
|
<div className="meal-modal-summary">
|
||||||
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
||||||
{totalCalories != null && (
|
{totalCalories != null && (
|
||||||
|
|||||||
+34
-8
@@ -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. */
|
/** Lidsky čitelné názvy typů jídel. */
|
||||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||||
@@ -110,24 +110,50 @@ export const POHLAVI_NAMES: Record<Pohlavi, string> = {
|
|||||||
|
|
||||||
export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA];
|
export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA];
|
||||||
|
|
||||||
|
/** Názvy pásem BMI. */
|
||||||
|
export const BMI_KATEGORIE_NAMES: Record<BmiKategorie, string> = {
|
||||||
|
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. */
|
/** Záložky stránky se šablonami. */
|
||||||
export enum TemplateTab {
|
export enum TemplateTab {
|
||||||
TRENINKY = 'TRENINKY',
|
TRENINKY = 'TRENINKY',
|
||||||
JIDLA = 'JIDLA',
|
RECEPTY = 'RECEPTY',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
||||||
TRENINKY: 'Tréninky',
|
TRENINKY: 'Tréninky',
|
||||||
JIDLA: 'Jídla',
|
RECEPTY: 'Recepty',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lidsky čitelné názvy druhů šablon jídel. */
|
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
||||||
export const MEAL_TEMPLATE_KIND_NAMES: Record<MealTemplateKind, string> = {
|
export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [
|
||||||
HOTOVE: 'Hotové jídlo',
|
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
||||||
RECEPT: 'Recept',
|
];
|
||||||
|
|
||||||
|
/** Názvy živin; "z toho" položky jsou součástí té nad nimi. */
|
||||||
|
export const NUTRIENT_NAMES: Record<keyof Nutrients, string> = {
|
||||||
|
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<Record<keyof Nutrients, string>> = {
|
||||||
|
protein: 'B',
|
||||||
|
carbohydrates: 'S',
|
||||||
|
fat: 'T',
|
||||||
|
};
|
||||||
|
|
||||||
/** Názvy režimů importu. */
|
/** Názvy režimů importu. */
|
||||||
export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
||||||
|
|||||||
@@ -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 {
|
.meal-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { Alert, Button, Spinner } from "react-bootstrap";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
|
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";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import {
|
import {
|
||||||
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
ActivityEntry, DayOverview, MealEntry,
|
||||||
deleteActivity, deleteMeal, getDayOverview, saveSleep, setActivityMultiplier, setMealMultiplier,
|
deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier,
|
||||||
} from "../../../types";
|
} from "../../../types";
|
||||||
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
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 {
|
import {
|
||||||
applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
||||||
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
formatSleepInput, formatWeightKgInput, getDayOfWeek, getHumanDate, parseSleepInput, parseWeightKgInput,
|
||||||
|
shiftDate,
|
||||||
} from "../Utils";
|
} from "../Utils";
|
||||||
import MealModal from "../components/modals/MealModal";
|
import MealModal from "../components/modals/MealModal";
|
||||||
import ActivityModal from "../components/modals/ActivityModal";
|
import ActivityModal from "../components/modals/ActivityModal";
|
||||||
@@ -43,12 +47,14 @@ export default function DayPage() {
|
|||||||
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
||||||
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
||||||
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
||||||
const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false);
|
const [mealHistoryModalOpen, setMealHistoryModalOpen] = useState(false);
|
||||||
const [sleepInput, setSleepInput] = useState('');
|
const [sleepInput, setSleepInput] = useState('');
|
||||||
|
const [weightInput, setWeightInput] = useState('');
|
||||||
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
||||||
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
||||||
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||||
const [errorNotice, setErrorNotice] = useState<string | undefined>();
|
/** Jídlo, jehož kalorie jde nabídnout ostatním jídlům stejného názvu. */
|
||||||
|
const [fillOfferEntry, setFillOfferEntry] = useState<MealEntry | undefined>();
|
||||||
|
|
||||||
const loadDay = useCallback(async (forDate: string) => {
|
const loadDay = useCallback(async (forDate: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -70,6 +76,21 @@ export default function DayPage() {
|
|||||||
if (response.data) setDay(response.data);
|
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());
|
const isToday = date === formatDate(new Date());
|
||||||
|
|
||||||
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
/** 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);
|
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 changeMealMultiplier = async (entry: MealEntry, multiplier: number) => {
|
||||||
const response = await setMealMultiplier({ body: { id: entry.id, date: entry.date, multiplier } });
|
const response = await setMealMultiplier({ body: { id: entry.id, date: entry.date, multiplier } });
|
||||||
if (response.data) setDay(response.data);
|
if (response.data) setDay(response.data);
|
||||||
@@ -132,8 +142,8 @@ export default function DayPage() {
|
|||||||
<h1>Můj den</h1>
|
<h1>Můj den</h1>
|
||||||
{tab === DayTab.PRIJEM ? (
|
{tab === DayTab.PRIJEM ? (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
<Button variant="outline-secondary" onClick={() => setMealHistoryModalOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faBowlFood} /> Ze šablony
|
<FontAwesomeIcon icon={faClockRotateLeft} /> Z historie
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||||
@@ -209,9 +219,13 @@ export default function DayPage() {
|
|||||||
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
|
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
|
||||||
<div className="summary-value">{formatBalance(energy.balance)}</div>
|
<div className="summary-value">{formatBalance(energy.balance)}</div>
|
||||||
<div className="summary-hint">
|
<div className="summary-hint">
|
||||||
{energy.hasBasal
|
{!energy.hasBasal && 'nastavte klidový výdej pro skutečný deficit'}
|
||||||
? (isDeficit ? 'jste v deficitu' : 'jste v přebytku')
|
{energy.hasBasal && energy.targetBalance == null && (isDeficit ? 'jste v deficitu' : 'jste v přebytku')}
|
||||||
: 'nastavte klidový výdej pro skutečný deficit'}
|
{energy.hasBasal && energy.targetBalance != null && (
|
||||||
|
energy.balance <= energy.targetBalance
|
||||||
|
? `cíl ${formatBalance(energy.targetBalance)}, zbývá ${energy.targetBalance - energy.balance} kcal`
|
||||||
|
: `cíl ${formatBalance(energy.targetBalance)}, o ${energy.balance - energy.targetBalance} kcal nad`
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-tile accent">
|
<div className="summary-tile accent">
|
||||||
@@ -236,13 +250,35 @@ export default function DayPage() {
|
|||||||
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
|
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faWeightScale} /> Vážení</div>
|
||||||
|
<div className="sleep-input-row">
|
||||||
|
<input
|
||||||
|
className="form-control sleep-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={weightInput}
|
||||||
|
placeholder="90,0"
|
||||||
|
aria-label="Hmotnost v kilogramech"
|
||||||
|
onChange={event => setWeightInput(event.target.value)}
|
||||||
|
onBlur={() => commitWeight()}
|
||||||
|
/>
|
||||||
|
<span className="sleep-unit">kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="summary-hint">vývoj na stránce Profil</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
||||||
{errorNotice && (
|
{fillOfferEntry && (
|
||||||
<Alert variant="danger" className="py-2 small" dismissible onClose={() => setErrorNotice(undefined)}>
|
<FillSameNameOffer
|
||||||
{errorNotice}
|
entry={fillOfferEntry}
|
||||||
</Alert>
|
onFilled={count => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="day-tabs">
|
<div className="day-tabs">
|
||||||
@@ -279,6 +315,9 @@ export default function DayPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{day.meals.totalNutrients && (
|
||||||
|
<NutrientSummary nutrients={day.meals.totalNutrients} proteinTarget={day.proteinTargetGrams} />
|
||||||
|
)}
|
||||||
{mealGroups.map(group => (
|
{mealGroups.map(group => (
|
||||||
<div className="meal-group" key={group.mealType}>
|
<div className="meal-group" key={group.mealType}>
|
||||||
<h2 className="meal-group-title">
|
<h2 className="meal-group-title">
|
||||||
@@ -298,7 +337,6 @@ export default function DayPage() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||||
onDelete={() => removeMeal(entry)}
|
onDelete={() => removeMeal(entry)}
|
||||||
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
|
||||||
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
|
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -341,8 +379,10 @@ export default function DayPage() {
|
|||||||
date={date}
|
date={date}
|
||||||
entry={editedMeal}
|
entry={editedMeal}
|
||||||
onClose={() => setMealModalOpen(false)}
|
onClose={() => setMealModalOpen(false)}
|
||||||
onSaved={saved => {
|
onSaved={(saved, savedEntry) => {
|
||||||
setMealModalOpen(false);
|
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
|
// Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den
|
||||||
if (saved.date !== date) {
|
if (saved.date !== date) {
|
||||||
setDate(saved.date);
|
setDate(saved.date);
|
||||||
@@ -380,12 +420,12 @@ export default function DayPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ApplyMealModal
|
<ApplyMealModal
|
||||||
isOpen={mealTemplateModalOpen}
|
isOpen={mealHistoryModalOpen}
|
||||||
date={date}
|
date={date}
|
||||||
onClose={() => setMealTemplateModalOpen(false)}
|
onClose={() => setMealHistoryModalOpen(false)}
|
||||||
onApplied={saved => {
|
onApplied={() => {
|
||||||
setDay(saved);
|
setMealHistoryModalOpen(false);
|
||||||
setMealTemplateModalOpen(false);
|
loadDay(date);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Spinner } from "react-bootstrap";
|
import { Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types";
|
||||||
import { MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES } from "../enums";
|
|
||||||
import MealTemplateCard from "../components/MealTemplateCard";
|
import MealTemplateCard from "../components/MealTemplateCard";
|
||||||
import MealTemplateForm from "../components/MealTemplateForm";
|
import MealTemplateForm from "../components/MealTemplateForm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panel se správou šablon jídel.
|
* 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
|
||||||
* Šablony jsou dvojího druhu: hotová jídla (objednané, koupené — pamatují si
|
* zakládání jídla se nabízejí z historie.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const MealTemplatesPanel = () => {
|
const MealTemplatesPanel = () => {
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
@@ -46,17 +43,12 @@ const MealTemplatesPanel = () => {
|
|||||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
Šablona jídla se v přehledu dne založí jedním kliknutím, nebo se nabídne, když začnete
|
Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy.
|
||||||
psát název jídla. <strong>Hotové jídlo</strong> je objednávka nebo koupená věc, u které si
|
V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát
|
||||||
jen pamatujete kalorie a cenu. <strong>Recept</strong> je vařené jídlo — zapíšete suroviny
|
název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie.
|
||||||
a postup a kalorie porce se spočítají samy.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -67,16 +59,13 @@ const MealTemplatesPanel = () => {
|
|||||||
|
|
||||||
{!loading && !templates.length && (
|
{!loading && !templates.length && (
|
||||||
<div className="card-panel empty-state">
|
<div className="card-panel empty-state">
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
<div className="empty-icon"><FontAwesomeIcon icon={faBookOpen} /></div>
|
||||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
<div className="empty-title">Zatím nemáte žádný recept</div>
|
||||||
<p className="empty-description">Sestavte si ji ve formuláři níže.</p>
|
<p className="empty-description">Zapište si ho ve formuláři níže.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && groupList.map(group => (
|
{!loading && templates.map(template => (
|
||||||
<div key={group.kind}>
|
|
||||||
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
|
||||||
{group.templates.map(template => (
|
|
||||||
<MealTemplateCard
|
<MealTemplateCard
|
||||||
key={template.id}
|
key={template.id}
|
||||||
template={template}
|
template={template}
|
||||||
@@ -85,8 +74,6 @@ const MealTemplatesPanel = () => {
|
|||||||
onDelete={() => remove(template.id)}
|
onDelete={() => remove(template.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<MealTemplateForm
|
<MealTemplateForm
|
||||||
key={`${edited?.id ?? 'new'}-${formVersion}`}
|
key={`${edited?.id ?? 'new'}-${formVersion}`}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.profile-subtitle {
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zápis vážení: den, hmotnost, tlačítko a vpravo změna za týden a měsíc
|
||||||
|
.weight-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
input[type='date'] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight-changes {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weight-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pásmo BMI barví hodnotu dlaždice — norma zeleně, mimo ni varovně
|
||||||
|
.bmi-tile {
|
||||||
|
&.bmi-norma .summary-value {
|
||||||
|
color: var(--ft-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bmi-podvaha .summary-value,
|
||||||
|
&.bmi-nadvaha .summary-value {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bmi-obezita .summary-value {
|
||||||
|
color: var(--ft-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import {
|
||||||
import { faFire, faCalculator } from "@fortawesome/free-solid-svg-icons";
|
Pohlavi, UserSettings, getSettings, saveBasalCalories, saveGoals, saveProfile,
|
||||||
import { Pohlavi, UserSettings, getSettings, saveBasalCalories, saveProfile } from "../../../types";
|
} from "../../../types";
|
||||||
import { POHLAVI_IN_ORDER, POHLAVI_NAMES } from "../enums";
|
import { POHLAVI_IN_ORDER, POHLAVI_NAMES, WEEKLY_CHANGE_OPTIONS } from "../enums";
|
||||||
import { formatCalories, formatWeightKgInput, parseIntInput, parseWeightKgInput } from "../Utils";
|
import {
|
||||||
|
formatBalance, formatWeightKgInput, parseIntInput, parseMultiplierInput, parseWeightKgInput,
|
||||||
|
} from "../Utils";
|
||||||
|
import BodyMetricsTiles from "../components/BodyMetricsTiles";
|
||||||
|
import WeightHistoryPanel from "../components/WeightHistoryPanel";
|
||||||
|
import "./ProfilePage.scss";
|
||||||
|
|
||||||
|
/** Text týdenního tempa do výběru, např. −500 → "−0,5 kg týdně (−550 kcal/den)". */
|
||||||
|
const formatWeeklyChange = (grams: number): string => {
|
||||||
|
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í
|
* Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá
|
||||||
* metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní
|
* klidový výdej, který vstupuje do bilance dne; z výšky a obvodů BMI a odhad
|
||||||
* hodnotu, může ji zadat ručně a ta má přednost.
|
* 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 ProfilePage = () => {
|
||||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
@@ -19,7 +33,12 @@ const ProfilePage = () => {
|
|||||||
const [sex, setSex] = useState('');
|
const [sex, setSex] = useState('');
|
||||||
const [birthYear, setBirthYear] = useState('');
|
const [birthYear, setBirthYear] = useState('');
|
||||||
const [heightCm, setHeightCm] = 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 [manualBasal, setManualBasal] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
@@ -27,12 +46,18 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
/** Naplní formulář z nastavení vráceného serverem. */
|
/** Naplní formulář z nastavení vráceného serverem. */
|
||||||
const fillForm = (data?: UserSettings) => {
|
const fillForm = (data?: UserSettings) => {
|
||||||
|
const intText = (value?: number) => value != null ? String(value) : '';
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
setSex(data?.profile?.sex ?? '');
|
setSex(data?.profile?.sex ?? '');
|
||||||
setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : '');
|
setBirthYear(intText(data?.profile?.birthYear));
|
||||||
setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : '');
|
setHeightCm(intText(data?.profile?.heightCm));
|
||||||
setWeightKg(formatWeightKgInput(data?.profile?.weightGrams));
|
setWaistCm(intText(data?.profile?.waistCm));
|
||||||
setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : '');
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
@@ -44,22 +69,48 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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 () => {
|
const saveAll = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
|
const errorOf = (response: { error?: unknown }, fallback: string) =>
|
||||||
|
(response.error as { error?: string })?.error ?? fallback;
|
||||||
|
|
||||||
const profileResponse = await saveProfile({
|
const profileResponse = await saveProfile({
|
||||||
body: {
|
body: {
|
||||||
...(sex ? { sex: sex as Pohlavi } : {}),
|
...(sex ? { sex: sex as Pohlavi } : {}),
|
||||||
...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}),
|
...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}),
|
||||||
...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}),
|
...(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) {
|
if (!profileResponse.data) {
|
||||||
setSaving(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +118,14 @@ const ProfilePage = () => {
|
|||||||
const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } });
|
const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } });
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (!basalResponse.data) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
fillForm(basalResponse.data);
|
fillForm(basalResponse.data);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading || !settings) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Spinner animation="border" variant="success" />
|
<Spinner animation="border" variant="success" />
|
||||||
@@ -82,25 +133,22 @@ const ProfilePage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const computed = settings?.computedBasalCalories;
|
const isFemale = sex === Pohlavi.ZENA;
|
||||||
const effective = settings?.effectiveBasalCalories;
|
|
||||||
const manualWins = settings?.basalCalories != null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Osobní údaje</h1>
|
<h1>Profil</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-body-secondary small">
|
<BodyMetricsTiles settings={settings} />
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="card-panel mb-3">
|
<WeightHistoryPanel metrics={settings.bodyMetrics} goals={settings.goals} onChanged={() => reloadMetrics()} />
|
||||||
|
|
||||||
|
<div className="card-panel chart-panel">
|
||||||
|
<h2 className="chart-title">Tělesné údaje</h2>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-4">
|
||||||
<Form.Label>Pohlaví</Form.Label>
|
<Form.Label>Pohlaví</Form.Label>
|
||||||
<Form.Select value={sex} onChange={event => setSex(event.target.value)}>
|
<Form.Select value={sex} onChange={event => setSex(event.target.value)}>
|
||||||
<option value="">— nevyplněno —</option>
|
<option value="">— nevyplněno —</option>
|
||||||
@@ -109,7 +157,7 @@ const ProfilePage = () => {
|
|||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-4">
|
||||||
<Form.Label>Rok narození</Form.Label>
|
<Form.Label>Rok narození</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -118,7 +166,7 @@ const ProfilePage = () => {
|
|||||||
onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))}
|
onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-4">
|
||||||
<Form.Label>Výška</Form.Label>
|
<Form.Label>Výška</Form.Label>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -130,39 +178,94 @@ const ProfilePage = () => {
|
|||||||
<span className="input-group-text">cm</span>
|
<span className="input-group-text">cm</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="profile-subtitle">Obvody pro odhad podílu tuku</h3>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Pas</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={waistCm}
|
||||||
|
placeholder="90"
|
||||||
|
onChange={event => setWaistCm(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">cm</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>{isFemale ? 'v nejužším místě' : 'v úrovni pupku'}</Form.Text>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Krk</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={neckCm}
|
||||||
|
placeholder="40"
|
||||||
|
onChange={event => setNeckCm(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">cm</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>pod ohryzkem</Form.Text>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Boky</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={hipCm}
|
||||||
|
placeholder="100"
|
||||||
|
onChange={event => setHipCm(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">cm</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>v nejširším místě; do výpočtu jen u žen</Form.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-panel chart-panel">
|
||||||
|
<h2 className="chart-title">Cíle</h2>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Tempo</Form.Label>
|
||||||
|
<Form.Select value={weeklyChange} onChange={event => setWeeklyChange(event.target.value)}>
|
||||||
|
<option value="">— bez cíle —</option>
|
||||||
|
{WEEKLY_CHANGE_OPTIONS.map(grams => (
|
||||||
|
<option key={grams} value={grams}>{formatWeeklyChange(grams)}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Text>v přehledu dne se bilance porovná s cílem</Form.Text>
|
||||||
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
<Form.Label>Hmotnost</Form.Label>
|
<Form.Label>Cílová hmotnost</Form.Label>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={weightKg}
|
value={targetWeightKg}
|
||||||
placeholder="90,0"
|
placeholder="85,0"
|
||||||
onChange={event => setWeightKg(event.target.value)}
|
onChange={event => setTargetWeightKg(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="input-group-text">kg</span>
|
<span className="input-group-text">kg</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Bílkoviny</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={proteinPerKg}
|
||||||
|
placeholder="1,6"
|
||||||
|
onChange={event => setProteinPerKg(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g / kg</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Form.Text>běžně 1,2–2 g na kg, při hubnutí a silovém tréninku víc</Form.Text>
|
||||||
|
|
||||||
<div className="summary-grid">
|
|
||||||
<div className="summary-tile">
|
|
||||||
<div className="summary-label"><FontAwesomeIcon icon={faCalculator} /> Spočítaný klidový výdej</div>
|
|
||||||
<div className="summary-value">{formatCalories(computed)}</div>
|
|
||||||
<div className="summary-hint">
|
|
||||||
{computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="summary-tile accent">
|
|
||||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Použije se v bilanci</div>
|
|
||||||
<div className="summary-value">{formatCalories(effective)}</div>
|
|
||||||
<div className="summary-hint">
|
|
||||||
{manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-panel mb-3">
|
<div className="card-panel chart-panel">
|
||||||
<h2 className="chart-title">Ruční klidový výdej</h2>
|
<h2 className="chart-title">Ruční klidový výdej</h2>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou.
|
Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou.
|
||||||
@@ -173,7 +276,8 @@ const ProfilePage = () => {
|
|||||||
<Form.Control
|
<Form.Control
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={manualBasal}
|
value={manualBasal}
|
||||||
placeholder={computed != null ? String(computed) : 'např. 1800'}
|
placeholder={settings.computedBasalCalories != null
|
||||||
|
? String(settings.computedBasalCalories) : 'např. 1800'}
|
||||||
onChange={event => setManualBasal(event.target.value.replace(/\D/g, ''))}
|
onChange={event => setManualBasal(event.target.value.replace(/\D/g, ''))}
|
||||||
/>
|
/>
|
||||||
<span className="input-group-text">kcal / den</span>
|
<span className="input-group-text">kcal / den</span>
|
||||||
@@ -185,14 +289,15 @@ const ProfilePage = () => {
|
|||||||
{saved && !error && <Alert variant="success" className="py-2 small">Uloženo.</Alert>}
|
{saved && !error && <Alert variant="success" className="py-2 small">Uloženo.</Alert>}
|
||||||
|
|
||||||
<Button onClick={() => saveAll()} disabled={saving}>
|
<Button onClick={() => saveAll()} disabled={saving}>
|
||||||
{saving ? 'Ukládám…' : 'Uložit'}
|
{saving ? 'Ukládám…' : 'Uložit údaje a cíle'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-body-secondary small mt-4">
|
<p className="text-body-secondary small mt-4">
|
||||||
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 —
|
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í
|
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í.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
||||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||||
import MealTemplatesPanel from "./MealTemplatesPanel";
|
import MealTemplatesPanel from "./MealTemplatesPanel";
|
||||||
import "./TemplatesPage.scss";
|
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í
|
* 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í
|
* jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin),
|
||||||
* vlastní panely a společné jen záložky.
|
* proto mají vlastní panely a společné jen záložky. Hotová jídla šablony
|
||||||
|
* nemají, nabízejí se z historie.
|
||||||
*/
|
*/
|
||||||
const TemplatesPage = () => {
|
const TemplatesPage = () => {
|
||||||
const [tab, setTab] = useState<TemplateTab>(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<TemplateTab>(() =>
|
||||||
|
(location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -30,10 +35,10 @@ const TemplatesPage = () => {
|
|||||||
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`day-tab ${tab === TemplateTab.JIDLA ? 'active' : ''}`}
|
className={`day-tab ${tab === TemplateTab.RECEPTY ? 'active' : ''}`}
|
||||||
onClick={() => setTab(TemplateTab.JIDLA)}
|
onClick={() => setTab(TemplateTab.RECEPTY)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBowlFood} /> {TEMPLATE_TAB_NAMES[TemplateTab.JIDLA]}
|
<FontAwesomeIcon icon={faBookOpen} /> {TEMPLATE_TAB_NAMES[TemplateTab.RECEPTY]}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# Spustí vývojové prostředí — server na 3001, klienta na 3000, každý ve svém okně.
|
# Spustí vývojové prostředí — server na 3001, klienta na 3000, každý ve svém okně.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = $PSScriptRoot
|
$root = $PSScriptRoot
|
||||||
|
|
||||||
|
Push-Location (Join-Path $root 'types')
|
||||||
|
try { yarn openapi-ts } finally { Pop-Location }
|
||||||
|
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
'-NoExit', '-Command',
|
'-NoExit', '-Command',
|
||||||
"Set-Location '$root\server'; `$env:NODE_ENV='development'; yarn startReload"
|
"Set-Location '$root\server'; `$env:NODE_ENV='development'; yarn startReload"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ if ! command -v tmux &> /dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cd types && yarn openapi-ts && cd ..
|
||||||
|
|
||||||
SESSION=food-tracer
|
SESSION=food-tracer
|
||||||
tmux kill-session -t $SESSION 2>/dev/null || true
|
tmux kill-session -t $SESSION 2>/dev/null || true
|
||||||
tmux new-session -d -s $SESSION -n dev
|
tmux new-session -d -s $SESSION -n dev
|
||||||
|
|||||||
Vendored
+289
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+61
@@ -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;
|
||||||
|
}
|
||||||
+78
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+128
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+58
@@ -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;
|
||||||
Vendored
+198
@@ -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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+439
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+319
@@ -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;
|
||||||
Vendored
+343
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+74
@@ -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;
|
||||||
+59
@@ -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;
|
||||||
+21
@@ -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;
|
||||||
+37
@@ -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;
|
||||||
+38
@@ -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;
|
||||||
+85
@@ -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;
|
||||||
@@ -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;
|
||||||
+52
@@ -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;
|
||||||
+35
@@ -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;
|
||||||
+66
@@ -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;
|
||||||
Vendored
+135
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+49
@@ -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;
|
||||||
+265
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
+36
@@ -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;
|
||||||
|
}
|
||||||
+76
@@ -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;
|
||||||
+37
@@ -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;
|
||||||
+77
@@ -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;
|
||||||
Vendored
+116
@@ -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;
|
||||||
+48
@@ -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;
|
||||||
Vendored
+300
@@ -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;
|
||||||
Vendored
+6
@@ -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)());
|
||||||
Vendored
+19
@@ -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);
|
||||||
Vendored
+585
@@ -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;
|
||||||
Vendored
+66
@@ -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'
|
||||||
|
};
|
||||||
Vendored
+17
@@ -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);
|
||||||
+15
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
+3
-2
@@ -5,8 +5,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ts-node src/index.ts",
|
"start": "cross-env NODE_ENV=development ts-node src/index.ts",
|
||||||
"startReload": "nodemon --watch src src/index.ts",
|
"startReload": "cross-env NODE_ENV=development nodemon --watch src src/index.ts",
|
||||||
"build": "tsc -p .",
|
"build": "tsc -p .",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"babel-jest": "^30.2.0",
|
"babel-jest": "^30.2.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import { getDay } from "./meals";
|
|||||||
import { getActivityDay } from "./activities";
|
import { getActivityDay } from "./activities";
|
||||||
import { getSettings } from "./settings";
|
import { getSettings } from "./settings";
|
||||||
import { getSleep } from "./sleep";
|
import { getSleep } from "./sleep";
|
||||||
|
import { findWeightOn, getWeights } from "./weights";
|
||||||
|
import { computeBasalCalories } from "./profile";
|
||||||
|
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spočítá energetickou bilanci dne.
|
* Spočítá energetickou bilanci dne.
|
||||||
@@ -18,8 +21,14 @@ import { getSleep } from "./sleep";
|
|||||||
* @param intake přijaté kcal z jídla
|
* @param intake přijaté kcal z jídla
|
||||||
* @param activityBurn spálené kcal pohybem
|
* @param activityBurn spálené kcal pohybem
|
||||||
* @param basal klidový výdej v kcal za den, nebo nic
|
* @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 resolvedBasal = basal ?? 0;
|
||||||
const totalBurn = resolvedBasal + activityBurn;
|
const totalBurn = resolvedBasal + activityBurn;
|
||||||
return {
|
return {
|
||||||
@@ -29,28 +38,43 @@ export const buildEnergyBalance = (intake: number, activityBurn: number, basal?:
|
|||||||
totalBurn,
|
totalBurn,
|
||||||
balance: intake - totalBurn,
|
balance: intake - totalBurn,
|
||||||
hasBasal: !!basal,
|
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 login přihlašovací jméno uživatele
|
||||||
* @param date datum ve formátu YYYY-MM-DD
|
* @param date datum ve formátu YYYY-MM-DD
|
||||||
*/
|
*/
|
||||||
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||||
const [meals, activities, settings, sleepMinutes] = await Promise.all([
|
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([
|
||||||
getDay(login, date),
|
getDay(login, date),
|
||||||
getActivityDay(login, date),
|
getActivityDay(login, date),
|
||||||
getSettings(login),
|
getSettings(login),
|
||||||
getSleep(login, date),
|
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 {
|
return {
|
||||||
date,
|
date,
|
||||||
meals,
|
meals,
|
||||||
activities,
|
activities,
|
||||||
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
energy: buildEnergyBalance(
|
||||||
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
|
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
|
||||||
...(sleepMinutes ? { sleepMinutes } : {}),
|
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||||
|
...(dayWeight != null ? { weightGrams: dayWeight } : {}),
|
||||||
|
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
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";
|
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||||
|
|
||||||
/** Označení zdroje, kterým se importované záznamy značí. */
|
/** 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
|
* 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.
|
* 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ž
|
* 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ý
|
* 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.
|
* 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 dates = Array.from(new Set(parsed.rows.map(row => row.date)));
|
||||||
const existing = await findMealsForDays(login, dates);
|
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í
|
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
|
||||||
const existingKeys = new Set(replace
|
const existingKeys = new Set(replace
|
||||||
? []
|
? []
|
||||||
@@ -439,7 +444,7 @@ export async function importLuncherExport(
|
|||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidates.push(toMealEntry(row, mealType, importKey));
|
candidates.push(fillNutrition(toMealEntry(row, mealType, importKey)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
|
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
|
||||||
|
|||||||
+65
-122
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'crypto';
|
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 getStorage from "./storage";
|
||||||
import { addMeal, getDay, isMealType } from "./meals";
|
import { addMeal, isMealType } from "./meals";
|
||||||
import { normalizeName, rememberCalories } from "./calories";
|
import { rememberCalories } from "./calories";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -14,29 +14,45 @@ const MAX_NOTE_LENGTH = 4000;
|
|||||||
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||||
const MAX_INGREDIENTS = 50;
|
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}`;
|
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Doplní šabloně druh, pokud chybí.
|
* 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
|
||||||
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
|
* `kind: 'RECEPT'`.
|
||||||
* 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: MealTemplate): MealTemplate => ({
|
type StoredMealTemplate = Omit<MealTemplate, 'ingredients'> & {
|
||||||
...template,
|
ingredients?: RecipeIngredient[];
|
||||||
kind: template.kind ?? MealTemplateKind.HOTOVE,
|
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
|
* @param login přihlašovací jméno uživatele
|
||||||
*/
|
*/
|
||||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
|
||||||
.map(withKind)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||||
|
|
||||||
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
||||||
@@ -161,40 +177,24 @@ 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
|
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
|
||||||
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
|
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
|
||||||
* použití šablony pak funguje pro oba druhy stejně.
|
* 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 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<MealTemplate[]> => {
|
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
|
||||||
const value = (input ?? {}) as Record<string, unknown>;
|
const value = (input ?? {}) as Record<string, unknown>;
|
||||||
const name = normalizeText(value.name, 'name');
|
const name = normalizeText(value.name, 'name');
|
||||||
if (!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)) {
|
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
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");
|
|
||||||
}
|
|
||||||
const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE;
|
|
||||||
|
|
||||||
const common = {
|
|
||||||
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) {
|
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||||
}
|
}
|
||||||
@@ -205,8 +205,13 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
const servings = normalizeNumber(value.servings, 'servings');
|
const servings = normalizeNumber(value.servings, 'servings');
|
||||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||||
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||||
template = definedOnly({
|
|
||||||
...common,
|
const template: MealTemplate = definedOnly({
|
||||||
|
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||||
|
name,
|
||||||
|
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
||||||
|
source: normalizeText(value.source, 'source'),
|
||||||
|
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||||
ingredients,
|
ingredients,
|
||||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
servings: servings && servings > 1 ? servings : undefined,
|
servings: servings && servings > 1 ? servings : undefined,
|
||||||
@@ -221,57 +226,48 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
await rememberCalories(login, ingredient.name, 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<MealTemplate[]>(getKey(login), current => {
|
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => [
|
||||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
...loadRecipes(current).filter(item => item.id !== template.id),
|
||||||
return [...others, template];
|
template,
|
||||||
});
|
]);
|
||||||
return getMealTemplates(login);
|
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 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<MealTemplate[]> => {
|
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
|
||||||
if (typeof id !== 'string' || !id.length) {
|
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;
|
let found = false;
|
||||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
|
||||||
found = (current ?? []).some(item => item.id === id);
|
const recipeList = loadRecipes(current);
|
||||||
return (current ?? []).filter(item => item.id !== id);
|
found = recipeList.some(item => item.id === id);
|
||||||
|
return recipeList.filter(item => item.id !== id);
|
||||||
});
|
});
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new NotFoundError("Šablona nebyla nalezena");
|
throw new NotFoundError("Recept nebyl nalezen");
|
||||||
}
|
}
|
||||||
return getMealTemplates(login);
|
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
|
* 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
|
* to ovlivnilo recept. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
|
||||||
* u ručně zadaného jídla.
|
* 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 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 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 mealType chod; bez něj se použije chod receptu, jinak oběd
|
||||||
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
|
* @param multiplier násobek porce, třeba 2 za dvě porce
|
||||||
*/
|
*/
|
||||||
export const applyMealTemplate = async (
|
export const applyMealTemplate = async (
|
||||||
login: string,
|
login: string,
|
||||||
@@ -281,7 +277,7 @@ export const applyMealTemplate = async (
|
|||||||
multiplier?: unknown,
|
multiplier?: unknown,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
if (typeof id !== 'string' || !id.length) {
|
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)) {
|
if (!isValidIsoDate(date)) {
|
||||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
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);
|
const template = (await getMealTemplates(login)).find(item => item.id === id);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new NotFoundError("Šablona nebyla nalezena");
|
throw new NotFoundError("Recept nebyl nalezen");
|
||||||
}
|
}
|
||||||
|
|
||||||
await addMeal(login, {
|
await addMeal(login, {
|
||||||
@@ -301,62 +297,9 @@ export const applyMealTemplate = async (
|
|||||||
source: template.source,
|
source: template.source,
|
||||||
weight: template.weight,
|
weight: template.weight,
|
||||||
price: template.price,
|
price: template.price,
|
||||||
pricePer100g: template.pricePer100g,
|
|
||||||
calories: template.calories,
|
calories: template.calories,
|
||||||
caloriesPer100g: template.caloriesPer100g,
|
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),
|
multiplier: normalizeMultiplier(multiplier),
|
||||||
}, template.id);
|
}, template.id);
|
||||||
return date;
|
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<MealTemplate[]> => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
+211
-2
@@ -1,10 +1,13 @@
|
|||||||
import { randomUUID } from 'crypto';
|
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 getStorage from "./storage";
|
||||||
import {
|
import {
|
||||||
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { rememberCalories } from "./calories";
|
import { normalizeName, rememberCalories } from "./calories";
|
||||||
|
import { getPortionNutrients, normalizeNutrients, sumNutrients } from "./nutrients";
|
||||||
|
|
||||||
const storage = getStorage();
|
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ů. */
|
/** Sestaví přehled dne včetně součtů z předaných záznamů. */
|
||||||
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
||||||
const sorted = sortEntries(entries);
|
const sorted = sortEntries(entries);
|
||||||
|
const totalNutrients = sumNutrients(sorted.map(entry =>
|
||||||
|
getPortionNutrients(entry.nutrientsPer100g, entry.weight, entry.multiplier)));
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
entries: sorted,
|
entries: sorted,
|
||||||
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||||
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 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;
|
weight?: number;
|
||||||
pricePer100g?: number;
|
pricePer100g?: number;
|
||||||
caloriesPer100g?: number;
|
caloriesPer100g?: number;
|
||||||
|
nutrientsPer100g?: Nutrients;
|
||||||
multiplier?: number;
|
multiplier?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,6 +179,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
|||||||
weight: normalizeNumber(value.weight, 'weight'),
|
weight: normalizeNumber(value.weight, 'weight'),
|
||||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||||
|
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||||
multiplier: normalizeMultiplier(value.multiplier),
|
multiplier: normalizeMultiplier(value.multiplier),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,6 +278,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
|||||||
weight: normalized.weight,
|
weight: normalized.weight,
|
||||||
pricePer100g: normalized.pricePer100g,
|
pricePer100g: normalized.pricePer100g,
|
||||||
caloriesPer100g: normalized.caloriesPer100g,
|
caloriesPer100g: normalized.caloriesPer100g,
|
||||||
|
nutrientsPer100g: normalized.nutrientsPer100g,
|
||||||
multiplier: normalized.multiplier,
|
multiplier: normalized.multiplier,
|
||||||
updatedAt: new Date().toISOString(),
|
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)));
|
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
|
||||||
return days.flatMap(day => day.entries);
|
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<MealSuggestion[]> {
|
||||||
|
const entryList = await findAllMeals(login);
|
||||||
|
const fillNutrition = createNutritionFiller(entryList);
|
||||||
|
const resultMap = new Map<string, MealSuggestion>();
|
||||||
|
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<MealEntry[]> {
|
||||||
|
const prefix = `meals:${login}:`;
|
||||||
|
const keyList = (await storage.listKeys(prefix)).filter(key => key.startsWith(prefix));
|
||||||
|
return (await Promise.all(keyList.map(key => storage.getData<MealEntry[]>(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<string, MealEntry>();
|
||||||
|
const byNameSourceMap = new Map<string, MealEntry>();
|
||||||
|
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<string[]> {
|
||||||
|
const countMap = new Map<string, { source: string, count: number }>();
|
||||||
|
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<FillSameNameResult> {
|
||||||
|
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<MealEntry[]>(getDayKey(login, targetDate), current => (current ?? [])
|
||||||
|
.map(entry => targetIds.has(entry.id) ? { ...fill(entry), updatedAt } : entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { count: targetList.length, dates };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
+21
-14
@@ -1,11 +1,17 @@
|
|||||||
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
|
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
|
||||||
import { BadRequestError } from "./utils";
|
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. */
|
/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */
|
||||||
const LIMITS = {
|
const LIMITS = {
|
||||||
birthYear: { min: 1900, max: new Date().getFullYear() },
|
birthYear: { min: 1900, max: new Date().getFullYear() },
|
||||||
heightCm: { min: 50, max: 250 },
|
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. */
|
/** 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)) {
|
if (value.sex != null && !isPohlavi(value.sex)) {
|
||||||
throw new BadRequestError("Nebylo předáno platné pohlaví");
|
throw new BadRequestError("Nebylo předáno platné pohlaví");
|
||||||
}
|
}
|
||||||
return {
|
const result: PersonalProfile = value.sex != null ? { sex: value.sex as Pohlavi } : {};
|
||||||
...(value.sex != null ? { sex: value.sex as Pohlavi } : {}),
|
for (const name of Object.keys(LIMITS) as (keyof typeof LIMITS)[]) {
|
||||||
...(normalizeRanged(value.birthYear, 'birthYear') != null
|
const normalized = normalizeRanged(value[name], name);
|
||||||
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
|
if (normalized != null) result[name] = normalized;
|
||||||
...(normalizeRanged(value.heightCm, 'heightCm') != null
|
}
|
||||||
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
|
return result;
|
||||||
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
|
|
||||||
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,14 +62,18 @@ export const normalizeProfile = (input: unknown): PersonalProfile => {
|
|||||||
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
|
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
|
||||||
*
|
*
|
||||||
* @param profile tělesné údaje uživatele
|
* @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 => {
|
export const computeBasalCalories = (
|
||||||
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) {
|
profile?: PersonalProfile,
|
||||||
|
weightGrams = profile?.weightGrams,
|
||||||
|
): number | undefined => {
|
||||||
|
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !weightGrams) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const age = new Date().getFullYear() - profile.birthYear;
|
const age = new Date().getFullYear() - profile.birthYear;
|
||||||
const base = 10 * (profile.weightGrams / 1000)
|
const base = 10 * (weightGrams / 1000)
|
||||||
+ 6.25 * profile.heightCm
|
+ 6.25 * profile.heightCm
|
||||||
- 5 * age;
|
- 5 * age;
|
||||||
return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161));
|
return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { saveSleep } from "../sleep";
|
import { saveSleep } from "../sleep";
|
||||||
|
import { getWeights, saveWeight } from "../weights";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
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); }
|
} 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;
|
export default router;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
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 { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
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); }
|
} 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. */
|
/** Přidá jídlo do zvoleného dne. */
|
||||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import {
|
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||||
applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate,
|
|
||||||
} from "../mealTemplates";
|
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -16,7 +14,7 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
} catch (e) { next(e); }
|
} 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) => {
|
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -24,7 +22,7 @@ router.post("/save", async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Smaže šablonu jídla. */
|
/** Smaže recept. */
|
||||||
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -32,7 +30,7 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
|||||||
} catch (e) { next(e); }
|
} 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) => {
|
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -42,13 +40,4 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
} catch (e) { next(e); }
|
} 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;
|
export default router;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
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 { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
|
|
||||||
@@ -38,4 +38,12 @@ router.post("/profile", async (req: Request, res: Response, next: NextFunction)
|
|||||||
} catch (e) { next(e); }
|
} 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;
|
export default router;
|
||||||
+31
-9
@@ -3,6 +3,8 @@ import getStorage from "./storage";
|
|||||||
import { BadRequestError } from "./utils";
|
import { BadRequestError } from "./utils";
|
||||||
import { normalizeName } from "./calories";
|
import { normalizeName } from "./calories";
|
||||||
import { computeBasalCalories, normalizeProfile } from "./profile";
|
import { computeBasalCalories, normalizeProfile } from "./profile";
|
||||||
|
import { computeBodyMetrics, normalizeGoals } from "./bodyMetrics";
|
||||||
|
import { getWeights } from "./weights";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -29,22 +31,27 @@ function emptySettings(): UserSettings {
|
|||||||
*/
|
*/
|
||||||
export async function getSettings(login: string): Promise<UserSettings> {
|
export async function getSettings(login: string): Promise<UserSettings> {
|
||||||
const stored = await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
|
const stored = await storage.getData<UserSettings>(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ů —
|
* 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.
|
* kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice.
|
||||||
*/
|
*/
|
||||||
function withDerived(settings: UserSettings): UserSettings {
|
async function withDerived(login: string, settings: UserSettings): Promise<UserSettings> {
|
||||||
const computed = computeBasalCalories(settings.profile);
|
const { computedBasalCalories: _basal, effectiveBasalCalories: _effective, bodyMetrics: _metrics, ...stored } = settings;
|
||||||
const effective = settings.basalCalories ?? computed;
|
const latestWeight = (await getWeights(login)).pop();
|
||||||
|
const computed = computeBasalCalories(stored.profile, latestWeight?.weightGrams ?? stored.profile?.weightGrams);
|
||||||
|
const effective = stored.basalCalories ?? computed;
|
||||||
return {
|
return {
|
||||||
...settings,
|
...stored,
|
||||||
...(computed != null ? { computedBasalCalories: computed } : {}),
|
...(computed != null ? { computedBasalCalories: computed } : {}),
|
||||||
...(effective != null ? { effectiveBasalCalories: effective } : {}),
|
...(effective != null ? { effectiveBasalCalories: effective } : {}),
|
||||||
|
bodyMetrics: computeBodyMetrics(stored.profile, stored.goals, latestWeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ export async function saveProfile(login: string, profile: unknown): Promise<User
|
|||||||
...(current ?? emptySettings()),
|
...(current ?? emptySettings()),
|
||||||
profile: normalized,
|
profile: normalized,
|
||||||
}));
|
}));
|
||||||
return withDerived(saved);
|
return withDerived(login, saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +100,7 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100
|
|||||||
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
||||||
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
return { ...settings, sourceRates: rate > 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;
|
const { basalCalories: _removed, ...rest } = settings;
|
||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
return withDerived(saved);
|
return withDerived(login, saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,3 +142,18 @@ export async function getSourceRate(login: string, source?: string): Promise<num
|
|||||||
const settings = await getSettings(login);
|
const settings = await getSettings(login);
|
||||||
return settings.sourceRates.find(rate => normalizeName(rate.source) === key)?.pricePer100g;
|
return settings.sourceRates.find(rate => 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<UserSettings> {
|
||||||
|
const normalized = normalizeGoals(goals);
|
||||||
|
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||||
|
const { goals: _removed, ...rest } = current ?? emptySettings();
|
||||||
|
return Object.keys(normalized).length ? { ...rest, goals: normalized } : rest;
|
||||||
|
});
|
||||||
|
return withDerived(login, saved);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
|
||||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA });
|
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);
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { importLuncherExport } from "../luncherImport";
|
import { importLuncherExport } from "../luncherImport";
|
||||||
import { getDay } from "../meals";
|
import { addMeal, getDay } from "../meals";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
import { MealType } from "../../../types/gen/types.gen";
|
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/);
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "
|
|||||||
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||||
import { getDay } from "../meals";
|
import { getDay } from "../meals";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import getStorage from "../storage";
|
||||||
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
|
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const LOGIN = 'tester';
|
const LOGIN = 'tester';
|
||||||
@@ -115,74 +116,67 @@ describe('série u cviků', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('šablony jídel', () => {
|
|
||||||
|
|
||||||
const salat = {
|
describe('recepty', () => {
|
||||||
name: 'Těstovinový salát',
|
|
||||||
source: 'Chefie',
|
const rizoto = {
|
||||||
mealType: MealType.OBED,
|
name: 'Kuřecí rizoto',
|
||||||
weight: 350,
|
source: 'doma',
|
||||||
caloriesPer100g: 160,
|
mealType: MealType.VECERE,
|
||||||
price: 12900,
|
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 () => {
|
it('uloží recept a dopočte hodnoty jedné porce', async () => {
|
||||||
const templates = await saveMealTemplate(LOGIN, salat);
|
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).toHaveLength(1);
|
||||||
expect(templates[0].name).toBe('Těstovinový salát');
|
expect(templates[0].name).toBe('Rizoto velké');
|
||||||
expect(templates[0].source).toBe('Chefie');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
|
it('použití založí porci do dne, bez postupu', async () => {
|
||||||
const [created] = await saveMealTemplate(LOGIN, salat);
|
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
const day = await getDay(LOGIN, DATE);
|
const [entry] = (await getDay(LOGIN, DATE)).entries;
|
||||||
expect(day.entries).toHaveLength(1);
|
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 });
|
||||||
expect(day.entries[0].name).toBe('Těstovinový salát');
|
expect(entry.note).toBeUndefined();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zvolený chod přebije ten ze šablony', async () => {
|
it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => {
|
||||||
const [template] = await saveMealTemplate(LOGIN, salat);
|
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 () => {
|
it('založené jídlo je samostatné — smazání receptu ho nesmaže', async () => {
|
||||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
|
||||||
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);
|
|
||||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
await deleteMealTemplate(LOGIN, template.id);
|
await deleteMealTemplate(LOGIN, template.id);
|
||||||
@@ -191,25 +185,66 @@ describe('šablony jídel', () => {
|
|||||||
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unese poznámku s postupem přípravy', async () => {
|
it('odmítne recept bez názvu, bez surovin a neexistující id', async () => {
|
||||||
const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.';
|
await expect(saveMealTemplate(LOGIN, { ...rizoto, name: ' ' })).rejects.toThrow(/název receptu/);
|
||||||
const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept });
|
await expect(saveMealTemplate(LOGIN, { ...rizoto, ingredients: [] })).rejects.toThrow(/surovinu/);
|
||||||
|
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
|
||||||
expect(template.note).toBe(recept);
|
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
|
||||||
|
|
||||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
|
||||||
expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('odmítne šablonu bez názvu a neexistující id', async () => {
|
it('recepty jsou oddělené mezi uživateli', async () => {
|
||||||
await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/);
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
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);
|
|
||||||
|
|
||||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,78 +1,15 @@
|
|||||||
process.env.CALORIE_PROVIDER = 'none';
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
import { addActivity, getActivityDay } from "../activities";
|
import { addActivity, getActivityDay } from "../activities";
|
||||||
import { addMeal, getDay } from "../meals";
|
|
||||||
import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates";
|
|
||||||
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
|
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
import { ActivityUnit } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const LOGIN = 'tester';
|
const LOGIN = 'tester';
|
||||||
const DATE = '2025-03-03';
|
const DATE = '2025-03-03';
|
||||||
|
|
||||||
beforeEach(() => resetMemoryStorage());
|
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', () => {
|
describe('překlopení aktivit do šablony tréninku', () => {
|
||||||
|
|
||||||
const kliky = {
|
const kliky = {
|
||||||
@@ -163,6 +100,5 @@ describe('překlopení aktivit do šablony tréninku', () => {
|
|||||||
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||||
|
|
||||||
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
|
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
|
||||||
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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<string, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WeightEntry[]> =>
|
||||||
|
Object.entries(await storage.getData<WeightLog>(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<string> => {
|
||||||
|
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<WeightLog>(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;
|
||||||
|
};
|
||||||
@@ -961,6 +961,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
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":
|
"@fast-csv/format@4.3.5":
|
||||||
version "4.3.5"
|
version "4.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/@fast-csv/format/-/format-4.3.5.tgz#90d83d1b47b6aaf67be70d6118f84f3e12ee1ff3"
|
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"
|
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
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:
|
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
|
||||||
version "7.0.6"
|
version "7.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,12 @@ paths:
|
|||||||
$ref: "./paths/meals/delete.yml"
|
$ref: "./paths/meals/delete.yml"
|
||||||
/meals/multiplier:
|
/meals/multiplier:
|
||||||
$ref: "./paths/meals/multiplier.yml"
|
$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)
|
# Statistiky (/api/stats)
|
||||||
/stats/summary:
|
/stats/summary:
|
||||||
@@ -36,6 +42,10 @@ paths:
|
|||||||
$ref: "./paths/day/overview.yml"
|
$ref: "./paths/day/overview.yml"
|
||||||
/day/sleep:
|
/day/sleep:
|
||||||
$ref: "./paths/day/sleep.yml"
|
$ref: "./paths/day/sleep.yml"
|
||||||
|
/day/weight:
|
||||||
|
$ref: "./paths/day/weight.yml"
|
||||||
|
/day/weights:
|
||||||
|
$ref: "./paths/day/weights.yml"
|
||||||
|
|
||||||
# Pohyb (/api/activities)
|
# Pohyb (/api/activities)
|
||||||
/activities/add:
|
/activities/add:
|
||||||
@@ -59,7 +69,7 @@ paths:
|
|||||||
/workouts/fromActivities:
|
/workouts/fromActivities:
|
||||||
$ref: "./paths/workouts/fromActivities.yml"
|
$ref: "./paths/workouts/fromActivities.yml"
|
||||||
|
|
||||||
# Šablony jídel (/api/mealTemplates)
|
# Recepty (/api/mealTemplates)
|
||||||
/mealTemplates:
|
/mealTemplates:
|
||||||
$ref: "./paths/mealTemplates/list.yml"
|
$ref: "./paths/mealTemplates/list.yml"
|
||||||
/mealTemplates/save:
|
/mealTemplates/save:
|
||||||
@@ -68,8 +78,6 @@ paths:
|
|||||||
$ref: "./paths/mealTemplates/delete.yml"
|
$ref: "./paths/mealTemplates/delete.yml"
|
||||||
/mealTemplates/apply:
|
/mealTemplates/apply:
|
||||||
$ref: "./paths/mealTemplates/apply.yml"
|
$ref: "./paths/mealTemplates/apply.yml"
|
||||||
/mealTemplates/fromMeal:
|
|
||||||
$ref: "./paths/mealTemplates/fromMeal.yml"
|
|
||||||
|
|
||||||
# Kalorie (/api/calories)
|
# Kalorie (/api/calories)
|
||||||
/calories/search:
|
/calories/search:
|
||||||
@@ -82,6 +90,8 @@ paths:
|
|||||||
$ref: "./paths/settings/basal.yml"
|
$ref: "./paths/settings/basal.yml"
|
||||||
/settings/profile:
|
/settings/profile:
|
||||||
$ref: "./paths/settings/profile.yml"
|
$ref: "./paths/settings/profile.yml"
|
||||||
|
/settings/goals:
|
||||||
|
$ref: "./paths/settings/goals.yml"
|
||||||
|
|
||||||
# Importy (/api/import)
|
# Importy (/api/import)
|
||||||
/import/luncher:
|
/import/luncher:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
post:
|
post:
|
||||||
operationId: applyMealTemplate
|
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:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -10,18 +10,18 @@ post:
|
|||||||
required: [id, date]
|
required: [id, date]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
description: Identifikátor šablony
|
description: Identifikátor receptu
|
||||||
type: string
|
type: string
|
||||||
date:
|
date:
|
||||||
description: Den, do kterého se jídlo založí
|
description: Den, do kterého se jídlo založí
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
mealType:
|
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:
|
allOf:
|
||||||
- $ref: "../../schemas/_index.yml#/MealType"
|
- $ref: "../../schemas/_index.yml#/MealType"
|
||||||
multiplier:
|
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
|
type: number
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -35,4 +35,4 @@ post:
|
|||||||
"401":
|
"401":
|
||||||
description: Neautentizovaný uživatel
|
description: Neautentizovaný uživatel
|
||||||
"404":
|
"404":
|
||||||
description: Šablona neexistuje
|
description: Recept neexistuje
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
post:
|
post:
|
||||||
operationId: deleteMealTemplate
|
operationId: deleteMealTemplate
|
||||||
summary: Smaže šablonu jídla.
|
summary: Smaže recept.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -13,7 +13,7 @@ post:
|
|||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Aktualizovaný seznam šablon
|
description: Aktualizovaný seznam receptů
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
get:
|
get:
|
||||||
operationId: getMealTemplates
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Šablony seřazené podle názvu
|
description: Recepty seřazené podle názvu
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
post:
|
post:
|
||||||
operationId: saveMealTemplate
|
operationId: saveMealTemplate
|
||||||
summary: Založí novou šablonu jídla, nebo upraví existující.
|
summary: Založí nový recept, nebo upraví existující.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -9,7 +9,7 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/MealTemplateInput"
|
$ref: "../../schemas/_index.yml#/MealTemplateInput"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Aktualizovaný seznam šablon
|
description: Aktualizovaný seznam receptů
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+243
-56
@@ -57,6 +57,8 @@ MealEntry:
|
|||||||
caloriesPer100g:
|
caloriesPer100g:
|
||||||
description: Energetická hodnota na 100 g v kcal. Slouží k dopočtu kalorií z gramáže.
|
description: Energetická hodnota na 100 g v kcal. Slouží k dopočtu kalorií z gramáže.
|
||||||
type: integer
|
type: integer
|
||||||
|
nutrientsPer100g:
|
||||||
|
$ref: "#/Nutrients"
|
||||||
note:
|
note:
|
||||||
description: Poznámka uživatele k jídlu
|
description: Poznámka uživatele k jídlu
|
||||||
type: string
|
type: string
|
||||||
@@ -130,6 +132,8 @@ MealInput:
|
|||||||
description: Energetická hodnota na 100 g v kcal
|
description: Energetická hodnota na 100 g v kcal
|
||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
|
nutrientsPer100g:
|
||||||
|
$ref: "#/Nutrients"
|
||||||
note:
|
note:
|
||||||
description: Poznámka uživatele k jídlu
|
description: Poznámka uživatele k jídlu
|
||||||
type: string
|
type: string
|
||||||
@@ -138,6 +142,100 @@ MealInput:
|
|||||||
description: Násobek porce; nevyplněno znamená 1
|
description: Násobek porce; nevyplněno znamená 1
|
||||||
type: number
|
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:
|
DayRecord:
|
||||||
description: Přehled jídel jednoho dne včetně součtů
|
description: Přehled jídel jednoho dne včetně součtů
|
||||||
type: object
|
type: object
|
||||||
@@ -163,6 +261,12 @@ DayRecord:
|
|||||||
totalCalories:
|
totalCalories:
|
||||||
description: Součet kalorií jídel dne v kcal (jídla bez kalorií se nezapočítávají)
|
description: Součet kalorií jídel dne v kcal (jídla bez kalorií se nezapočítávají)
|
||||||
type: integer
|
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:
|
PeriodTotal:
|
||||||
description: Součty za jedno období (den, měsíc nebo rok)
|
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ý,
|
Klidový výdej, který se reálně použije v bilanci dne — ručně zadaný,
|
||||||
jinak spočítaný z profilu. Doplňuje server.
|
jinak spočítaný z profilu. Doplňuje server.
|
||||||
type: integer
|
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:
|
ActivityUnit:
|
||||||
description: Jednotka, ve které se aktivita měří
|
description: Jednotka, ve které se aktivita měří
|
||||||
@@ -756,6 +956,11 @@ EnergyBalance:
|
|||||||
False, pokud uživatel nemá nastavený klidový výdej. Bilance pak
|
False, pokud uživatel nemá nastavený klidový výdej. Bilance pak
|
||||||
porovnává jen jídlo proti pohybu a není to skutečný deficit.
|
porovnává jen jídlo proti pohybu a není to skutečný deficit.
|
||||||
type: boolean
|
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:
|
DayOverview:
|
||||||
description: Kompletní přehled dne — jídlo, pohyb a jejich bilance
|
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
|
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.
|
včetně spánku a žádná standardní rovnice délku spánku nezohledňuje.
|
||||||
type: integer
|
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:
|
Pohlavi:
|
||||||
description: Pohlaví — vstupuje do výpočtu bazálního metabolismu
|
description: Pohlaví — vstupuje do výpočtu bazálního metabolismu
|
||||||
@@ -793,7 +1006,7 @@ Pohlavi:
|
|||||||
PersonalProfile:
|
PersonalProfile:
|
||||||
description: |
|
description: |
|
||||||
Tělesné údaje uživatele. Slouží k výpočtu bazálního metabolismu
|
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
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
@@ -806,7 +1019,18 @@ PersonalProfile:
|
|||||||
description: Výška v centimetrech
|
description: Výška v centimetrech
|
||||||
type: integer
|
type: integer
|
||||||
weightGrams:
|
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
|
type: integer
|
||||||
|
|
||||||
WorkoutItemKind:
|
WorkoutItemKind:
|
||||||
@@ -820,52 +1044,46 @@ WorkoutItemKind:
|
|||||||
|
|
||||||
MealTemplate:
|
MealTemplate:
|
||||||
description: |
|
description: |
|
||||||
Předpřipravené jídlo. Použitím se založí do zvoleného dne, takže opakovaně
|
Recept — vařené jídlo se surovinami a postupem. Hodnoty jedné porce
|
||||||
jedené jídlo není potřeba zadávat znovu. Na rozdíl od šablon tréninků se
|
(gramáž, energie, cena) dopočte server ze surovin při uložení a použitím
|
||||||
šablony jídel neskládají z jiných — jsou to ploché záznamy.
|
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
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- kind
|
|
||||||
- name
|
- name
|
||||||
|
- ingredients
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
kind:
|
|
||||||
$ref: "#/MealTemplateKind"
|
|
||||||
name:
|
name:
|
||||||
description: Název jídla
|
description: Název jídla
|
||||||
type: string
|
type: string
|
||||||
source:
|
source:
|
||||||
description: Zdroj jídla (podnik, obchod, "doma", ...)
|
description: Zdroj jídla, typicky "doma"
|
||||||
type: string
|
type: string
|
||||||
mealType:
|
mealType:
|
||||||
description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd.
|
description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd.
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/MealType"
|
- $ref: "#/MealType"
|
||||||
calories:
|
calories:
|
||||||
description: |
|
description: Energetická hodnota jedné porce v kcal
|
||||||
Energetická hodnota celé porce v kcal. Použije se, když jídlo nemá
|
|
||||||
vyplněnou gramáž a energii na 100 g.
|
|
||||||
type: integer
|
type: integer
|
||||||
caloriesPer100g:
|
caloriesPer100g:
|
||||||
description: Energetická hodnota na 100 g v kcal
|
description: Energetická hodnota hotového jídla na 100 g v kcal
|
||||||
type: integer
|
type: integer
|
||||||
weight:
|
weight:
|
||||||
description: Obvyklá gramáž porce v gramech
|
description: Gramáž jedné porce v gramech
|
||||||
type: integer
|
type: integer
|
||||||
price:
|
price:
|
||||||
description: Obvyklá cena v haléřích
|
description: Cena jedné porce v haléřích
|
||||||
type: integer
|
|
||||||
pricePer100g:
|
|
||||||
description: Cena za 100 g v haléřích
|
|
||||||
type: integer
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: Poznámka, u receptu postup přípravy
|
description: Postup přípravy
|
||||||
type: string
|
type: string
|
||||||
ingredients:
|
ingredients:
|
||||||
description: Suroviny receptu. U hotového jídla chybí.
|
description: Suroviny receptu
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/RecipeIngredient"
|
$ref: "#/RecipeIngredient"
|
||||||
@@ -880,46 +1098,27 @@ MealTemplate:
|
|||||||
|
|
||||||
MealTemplateInput:
|
MealTemplateInput:
|
||||||
description: |
|
description: |
|
||||||
Data pro založení nebo úpravu šablony jídla. Oproti {@link MealTemplate} je
|
Data pro založení nebo úpravu receptu. Oproti {@link MealTemplate} je
|
||||||
`id` volitelné — bez něj se šablona založí jako nová.
|
`id` volitelné — bez něj se recept založí jako nový — a hodnoty porce
|
||||||
|
chybí, protože je server dopočte ze surovin.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
|
- ingredients
|
||||||
properties:
|
properties:
|
||||||
id:
|
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
|
type: string
|
||||||
kind:
|
|
||||||
description: Druh šablony. Bez něj se založí hotové jídlo.
|
|
||||||
allOf:
|
|
||||||
- $ref: "#/MealTemplateKind"
|
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
|
||||||
mealType:
|
mealType:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/MealType"
|
- $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:
|
note:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
|
||||||
ingredients:
|
ingredients:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -947,18 +1146,6 @@ ImportMode:
|
|||||||
- PRIDAT
|
- PRIDAT
|
||||||
- NAHRADIT
|
- 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:
|
RecipeIngredient:
|
||||||
description: Jedna surovina receptu
|
description: Jedna surovina receptu
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in new issue
Block a user