From 21b01528e936accf6c38c847cb0e77699077ef75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Tue, 15 Sep 2026 14:16:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20zlep=C5=A1en=C3=AD=20=C5=A1ablon,=20p?= =?UTF-8?q?=C5=99id=C3=A1ny=20osobn=C3=AD=20=C3=BAdaje=20a=20sp=C3=A1nkov?= =?UTF-8?q?=C3=BD=20re=C5=BEim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 75 +++- README.md | 42 +- client/src/App.scss | 50 +++ client/src/AppRoutes.tsx | 6 +- client/src/Utils.tsx | 68 +++- client/src/components/ActivityRow.tsx | 31 ++ client/src/components/Header.tsx | 9 +- client/src/components/MealRow.tsx | 30 ++ client/src/components/RowActions.tsx | 35 ++ .../src/components/modals/ActivityModal.tsx | 51 ++- .../src/components/modals/ApplyMealModal.tsx | 131 ++++++ .../components/modals/ApplyWorkoutModal.tsx | 108 +++++ client/src/components/modals/MealModal.tsx | 23 +- .../modals/SaveWorkoutTemplateModal.tsx | 109 +++++ .../src/components/modals/SettingsModal.tsx | 38 +- client/src/components/modals/WorkoutModal.tsx | 257 ------------ client/src/enums.ts | 49 ++- client/src/pages/DayPage.scss | 33 ++ client/src/pages/DayPage.tsx | 207 +++++----- client/src/pages/MealTemplatesPanel.tsx | 327 +++++++++++++++ client/src/pages/ProfilePage.tsx | 201 ++++++++++ client/src/pages/StatsPage.tsx | 29 +- client/src/pages/TemplatesPage.scss | 94 +++++ client/src/pages/TemplatesPage.tsx | 45 +++ client/src/pages/WorkoutTemplatesPanel.tsx | 374 ++++++++++++++++++ client/src/routes.ts | 2 + server/src/activities.ts | 19 +- server/src/dayOverview.ts | 22 +- server/src/index.ts | 2 + server/src/mealTemplates.ts | 206 ++++++++++ server/src/profile.ts | 73 ++++ server/src/routes/dayRoutes.ts | 11 + server/src/routes/mealTemplateRoutes.ts | 54 +++ server/src/routes/settingsRoutes.ts | 10 +- server/src/routes/workoutRoutes.ts | 14 +- server/src/settings.ts | 48 ++- server/src/sleep.ts | 47 +++ server/src/tests/setsAndMealTemplates.test.ts | 215 ++++++++++ server/src/tests/toTemplate.test.ts | 168 ++++++++ server/src/tests/workoutComposition.test.ts | 247 ++++++++++++ server/src/workouts.ts | 250 +++++++++--- types/api.yml | 18 + types/paths/day/sleep.yml | 29 ++ types/paths/mealTemplates/apply.yml | 35 ++ types/paths/mealTemplates/delete.yml | 26 ++ types/paths/mealTemplates/fromMeal.yml | 33 ++ types/paths/mealTemplates/list.yml | 14 + types/paths/mealTemplates/save.yml | 22 ++ types/paths/settings/profile.yml | 20 + types/paths/workouts/fromActivities.yml | 40 ++ types/schemas/_index.yml | 196 ++++++++- 51 files changed, 3739 insertions(+), 504 deletions(-) create mode 100644 client/src/components/ActivityRow.tsx create mode 100644 client/src/components/MealRow.tsx create mode 100644 client/src/components/RowActions.tsx create mode 100644 client/src/components/modals/ApplyMealModal.tsx create mode 100644 client/src/components/modals/ApplyWorkoutModal.tsx create mode 100644 client/src/components/modals/SaveWorkoutTemplateModal.tsx delete mode 100644 client/src/components/modals/WorkoutModal.tsx create mode 100644 client/src/pages/MealTemplatesPanel.tsx create mode 100644 client/src/pages/ProfilePage.tsx create mode 100644 client/src/pages/TemplatesPage.scss create mode 100644 client/src/pages/TemplatesPage.tsx create mode 100644 client/src/pages/WorkoutTemplatesPanel.tsx create mode 100644 server/src/mealTemplates.ts create mode 100644 server/src/profile.ts create mode 100644 server/src/routes/mealTemplateRoutes.ts create mode 100644 server/src/sleep.ts create mode 100644 server/src/tests/setsAndMealTemplates.test.ts create mode 100644 server/src/tests/toTemplate.test.ts create mode 100644 server/src/tests/workoutComposition.test.ts create mode 100644 types/paths/day/sleep.yml create mode 100644 types/paths/mealTemplates/apply.yml create mode 100644 types/paths/mealTemplates/delete.yml create mode 100644 types/paths/mealTemplates/fromMeal.yml create mode 100644 types/paths/mealTemplates/list.yml create mode 100644 types/paths/mealTemplates/save.yml create mode 100644 types/paths/settings/profile.yml create mode 100644 types/paths/workouts/fromActivities.yml diff --git a/CLAUDE.md b/CLAUDE.md index 24607f3..ede4723 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`, `statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes` - **Domain:** `meals.ts` (jídlo), `activities.ts` (pohyb), `workouts.ts` (šablony - tréninků), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace), + tréninků), `sleep.ts` (spánek), `profile.ts` (tělesné údaje a výpočet BMR), + `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace), `luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts` - **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy - **Storage:** `storage/index.ts` factory dle proměnné `STORAGE`; backendy: @@ -70,9 +71,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist ### Client (client/src/) - **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka - **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej), + `TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej), `StatsPage` (statistiky + import) - **Components:** `components/` (Header, CalorieLookup) a `components/modals/` - (MealModal, ActivityModal, WorkoutModal, SettingsModal, ImportModal) + (MealModal, ActivityModal, ApplyWorkoutModal, SettingsModal, ImportModal) +- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků, + ne jako inline union u komponenty (`DayTab`, `StatsRange`) - **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv) - **Routing:** konstanty adres jsou v `routes.ts`, ne v `AppRoutes.tsx` — hlavička je potřebuje a kruhový import by je nechal nedefinované @@ -140,23 +144,72 @@ Den má dvě strany a `DayPage` je dělí do záložek: - **Výdej** — pohyb (`ActivityEntry`), klíč `activities::` Aktivita se měří v jednotce (`ActivityUnit`: KROKY, MINUTY, KM, OPAKOVANI) a -kalorie se dopočtou jako `quantity / 100 * caloriesPer100Units`. **Sto jednotek, -ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal) a aplikace -všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g. +kalorie se dopočtou jako `getTotalQuantity(quantity, sets) / 100 * caloriesPer100Units`. +**Sto jednotek, ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal) +a aplikace všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g. -`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam cviků. Použitím vzniknou -běžné aktivity s vazbou `templateId` — jsou samostatné, takže úprava založené -položky šablonu nemění. +`quantity` je množství **na jednu sérii**, `sets` je počet sérií; celkové množství +je jejich součin. Drží se odděleně schválně: 3 × 40 opakování s pauzami je jiný +trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série je +výchozí stav a neukládá se. + +Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` — +z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. U jídla to +umí `createMealTemplateFromMeal`, které navíc **přepíše šablonu stejného názvu**; +opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik +stejných. Záznamy ve dni zůstanou v obou případech beze změny. + +`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď +cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se +při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou +samostatné, takže úprava založené položky šablonu nemění. + +**Zanoření je povolené jen na jednu úroveň** a `checkNesting` to hlídá z obou stran: +šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít +skládat, pokud ji už někdo používá. Bez druhé kontroly by druhá úroveň vznikla +oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — zůstal by odkaz +do prázdna. Šablony uložené dřív, než skládání přibylo, nemají `kind`; `withKind` +jim ho při načtení doplní na `CVIK`. `buildEnergyBalance` v `dayOverview.ts` počítá: `bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit. -Bez nastaveného klidového výdeje (`basalCalories` v nastavení) porovnává bilance -jen jídlo proti pohybu — **to není skutečný deficit**, proto se to přes -`hasBasal: false` propisuje do UI, aby to číslo nikoho nemátlo. +Bez nastaveného klidového výdeje porovnává bilance jen jídlo proti pohybu — +**to není skutečný deficit**, proto se to přes `hasBasal: false` propisuje do UI, +aby to číslo nikoho nemátlo. + +Klidový výdej se bere z `settings.effectiveBasalCalories`: ručně zadaný +`basalCalories` má přednost před hodnotou spočítanou z tělesných údajů +(`computeBasalCalories` v `profile.ts`, rovnice Mifflin–St Jeor). Výpočet +**záměrně nenásobí koeficientem fyzické aktivity (PAL)**, jak to dělají kalkulačky +TDEE — pohyb se eviduje zvlášť a přičítá se, takže vynásobení by ho započítalo +dvakrát. + +**Spánek** (`sleep.ts`, klíč `sleep::`) se eviduje jako kontext dne +a do bilance **nevstupuje**. Klidový výdej je hodnota za celých 24 hodin včetně +spánku a žádná ze standardních rovnic délku spánku jako proměnnou nemá — přičítat +nebo odečítat za něj kalorie by bylo vymýšlení čísla. Test to hlídá: +bilance se po zadání spánku nesmí změnit. `GET /api/day?date=` vrací `DayOverview` se vším naráz (jídlo, pohyb, bilance), takže `DayPage` si vystačí s jedním voláním. +## Šablony jídel + +`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:`) je opakovaně +jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných** — je to plochý +záznam. Použitím vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu +i kalorie dopočítá stejná logika jako u ručně zadaného jídla. + +Šablona může nést buď `calories` (celá porce), nebo `caloriesPer100g` + `weight`. +Obojí projde `deriveAmounts`, takže stačí vyplnit, co o jídle víte. + +Pole `note` slouží i na postup přípravy u domácích jídel. Strukturované suroviny +(recept jako seznam položek s množstvím) mohou přibýt později jako další volitelné +pole, aniž by se muselo měnit cokoli stávajícího. + +Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate` +ho vyžaduje a nešlo by jím posílat novou šablonu. + ## Import z Luncheru `server/src/luncherImport.ts` čte měsíční přehled ze stránky statistik Luncheru diff --git a/README.md b/README.md index bcb60f8..a3c2e65 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,44 @@ Přehled dne má dvě záložky: - **Příjem** — co jste snědli, s cenou, gramáží a kaloriemi. - **Výdej** — pohyb. Kroky, minuty, kilometry nebo opakování; z množství a spotřeby na 100 jednotek se dopočtou spálené kalorie (10 000 kroků ≈ 400 kcal). + U silových cviků jde zadat i **počet sérií** — 3 × 40 kliků se tak neplete + se 120 v kuse, i když kalorie vyjdou stejně. -Opakované tréninky se dají uložit jako **šablonu** — *Workout day 1* pak stačí -jedním kliknutím založit do dne celý, místo zadávání cviku po cviku. Založené -položky jsou samostatné, takže je jde doupravit, aniž by se změnila šablona. +Volitelně se dá k dni zapsat i **délka spánku**. Slouží jako kontext — do +energetické bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin +včetně spánku a žádná standardní rovnice s délkou spánku nepočítá. -Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`. Klidový výdej -(bazální metabolismus) se nastavuje v *Nastavení* — bez něj se porovnává jen -jídlo proti pohybu a nejde o skutečný deficit, na což aplikace upozorní. +Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`. + +## Šablony tréninků + +Opakované tréninky se ukládají jako šablony na stránce **Šablony** — v přehledu dne +pak stačí jedno kliknutí a celý trénink se založí, místo zadávání cviku po cviku. +Do šablony jde zařadit i **jinou šablonu**, třeba rozcvičku, kterou máte v každém +tréninku; při použití se její cviky rozbalí. Zanoření je kvůli jednoduchosti jen +na jednu úroveň a aplikace to hlídá. + +Založené položky jsou samostatné aktivity, takže je jde v dni doupravit, aniž by +se změnila šablona. + +Šablonu jde vytvořit i **opačným směrem** — z toho, co už máte v dni zapsané. +U jídla ikonou záložky v řádku, u tréninku tlačítkem *Uložit jako šablonu* v záložce +Výdej (vybere se, které cviky zahrnout). Při zakládání jídla nebo aktivity je +v dialogu i zaškrtávátko *Uložit i jako šablonu*. + +Na druhé záložce jsou **šablony jídel** — opakovaně jedené jídlo (název, zdroj, +kalorie, gramáž, cena), které se v přehledu dne založí jedním kliknutím. Ty se +neskládají z jiných. U domácích jídel jde do poznámky zapsat postup přípravy. + +## Osobní údaje + +Na stránce **Osobní údaje** se z pohlaví, roku narození, výšky a hmotnosti spočítá +klidový výdej (bazální metabolismus) rovnicí Mifflin–St Jeor. Kdo má hodnotu +změřenou přesněji, může ji zadat ručně — ta pak má přednost. + +Výpočet se záměrně nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky +TDEE: pohyb si aplikace eviduje zvlášť a přičítá ho, takže vynásobení by ho +započítalo dvakrát. ## Kalorie a gramáž diff --git a/client/src/App.scss b/client/src/App.scss index 720bf00..5fb8b4e 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -681,6 +681,12 @@ body { gap: 8px; margin-bottom: 8px; + // Počet sérií je skoro vždy jednociferný, stačí užší pole než na množství + .workout-item-sets { + width: 74px; + flex: 0 0 auto; + } + .workout-item-quantity { width: 90px; flex: 0 0 auto; @@ -699,6 +705,7 @@ body { @media (max-width: 640px) { flex-wrap: wrap; + .workout-item-sets, .workout-item-quantity, .workout-item-unit, .workout-item-rate { @@ -721,3 +728,46 @@ body { font-size: 0.85rem; } } + +// Ikona u skládané šablony ve výběru +.workout-nested-icon { + margin-left: 8px; + color: var(--ft-primary); + font-size: 0.78rem; +} + +// Výběr cviků při ukládání dne jako šablony +.template-pick-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--ft-border-light); + font-size: 0.88rem; + + .form-check { + flex: 1; + margin: 0; + min-width: 0; + } + + .template-pick-quantity { + color: var(--ft-text-secondary); + white-space: nowrap; + } + + .template-pick-calories { + color: var(--ft-warning); + font-weight: 600; + white-space: nowrap; + min-width: 72px; + text-align: right; + } +} + +// Zaškrtávátko v patičce dialogu stojí vlevo, před souhrnem a tlačítky +.modal-template-check { + margin: 0 16px 0 0; + font-size: 0.85rem; + color: var(--ft-text-secondary); +} diff --git a/client/src/AppRoutes.tsx b/client/src/AppRoutes.tsx index 9c31b47..8fd4c5c 100644 --- a/client/src/AppRoutes.tsx +++ b/client/src/AppRoutes.tsx @@ -5,7 +5,9 @@ import Header from "./components/Header"; import Login from "./Login"; import DayPage from "./pages/DayPage"; import StatsPage from "./pages/StatsPage"; -import { DEN_URL, STATISTIKY_URL } from "./routes"; +import TemplatesPage from "./pages/TemplatesPage"; +import ProfilePage from "./pages/ProfilePage"; +import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes"; export default function AppRoutes() { const auth = useAuth(); @@ -21,6 +23,8 @@ export default function AppRoutes() { } /> } /> + } /> + } /> } /> diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index 047366f..e7d3704 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -182,14 +182,74 @@ export function formatQuantity(quantity: number, unitShort: string): string { return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`; } -/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */ -export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined { +/** + * Dopočte spálené kalorie z množství, počtu sérií a sazby na 100 jednotek. + * Musí dávat stejné výsledky jako `deriveActivityCalories` na serveru. + */ +export const deriveActivityCalories = ( + quantity?: number, + caloriesPer100Units?: number, + sets?: number, +): number | undefined => { if (quantity == null || !caloriesPer100Units) return undefined; - return Math.round(quantity / 100 * caloriesPer100Units); -} + return Math.round(quantity * Math.max(sets ?? 1, 1) / 100 * caloriesPer100Units); +}; /** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */ export function formatBalance(kcal: number): string { const sign = kcal < 0 ? '−' : '+'; return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`; } + +/** Naformátuje délku spánku z minut, např. 450 → "7 h 30 min". */ +export const formatSleep = (minutes?: number): string => { + if (!minutes) return '—'; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest ? `${hours} h ${rest} min` : `${hours} h`; +}; + +/** Převede minuty na hodiny s jedním desetinným místem pro editační pole. */ +export const formatSleepInput = (minutes?: number): string => + minutes ? (minutes / 60).toFixed(1).replace('.', ',') : ''; + +/** Převede hodiny zadané uživatelem (i s desetinnou čárkou) na minuty. */ +export const parseSleepInput = (value: string): number | undefined => { + const trimmed = value.trim().replace(',', '.'); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 60) : undefined; +}; + +/** Naformátuje hmotnost z gramů na kilogramy, např. 90000 → "90,0 kg". */ +export const formatWeightKg = (grams?: number): string => + grams == null ? '—' : `${(grams / 1000).toFixed(1).replace('.', ',')} kg`; + +/** Převede kilogramy zadané uživatelem na gramy. */ +export const parseWeightKgInput = (value: string): number | undefined => { + const trimmed = value.trim().replace(',', '.'); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 1000) : undefined; +}; + +/** Převede gramy na kilogramy pro editační pole. */ +export const formatWeightKgInput = (grams?: number): string => + grams == null ? '' : (grams / 1000).toFixed(1).replace('.', ','); + +/** + * Naformátuje množství aktivity i se sériemi. + * + * Bez sérií vrací "40 opak.", se sériemi "3 × 40 opak. (120)" — celkové množství + * v závorce, protože z něj vychází spálené kalorie, ale rozpad na série je to, + * co odlišuje tři série s pauzami od jedné dlouhé. + */ +export const formatQuantityWithSets = (quantity: number, unitShort: string, sets?: number): string => { + const one = formatQuantity(quantity, unitShort); + if (!sets || sets <= 1) return one; + return `${sets} × ${one} (${(quantity * sets).toLocaleString('cs-CZ')})`; +}; + +/** Vrátí celkové množství aktivity — počet sérií krát množství v jedné. */ +export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined => + quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1); diff --git a/client/src/components/ActivityRow.tsx b/client/src/components/ActivityRow.tsx new file mode 100644 index 0000000..4afe801 --- /dev/null +++ b/client/src/components/ActivityRow.tsx @@ -0,0 +1,31 @@ +import { ActivityEntry } from "../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../enums"; +import { formatCalories, formatQuantityWithSets } from "../Utils"; +import RowActions from "./RowActions"; + +type Props = { + entry: ActivityEntry, + onEdit: () => void, + onDelete: () => void, + onSaveTemplate: () => void, +}; + +/** Jeden řádek seznamu pohybových aktivit v přehledu dne. */ +const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly) => ( +
+
+
{entry.name}
+
+ + {formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)} + + {entry.templateId && ze šablony} +
+ {entry.note &&
{entry.note}
} +
+
{formatCalories(entry.calories)}
+ +
+); + +export default ActivityRow; diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 52b2e6c..9019f17 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -2,15 +2,16 @@ import { useState } from "react"; import { Navbar, Nav, NavDropdown } from "react-bootstrap"; import { useLocation, useNavigate } from "react-router"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons"; +import { faSun, faMoon, faGear, faUser } from "@fortawesome/free-solid-svg-icons"; import { useAuth } from "../context/auth"; import { useSettings, ThemePreference } from "../context/settings"; -import { DEN_URL, STATISTIKY_URL } from "../routes"; +import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes"; import SettingsModal from "./modals/SettingsModal"; /** Položky hlavní navigace. */ const NAV_ITEMS = [ { url: DEN_URL, label: 'Můj den' }, + { url: SABLONY_URL, label: 'Šablony' }, { url: STATISTIKY_URL, label: 'Statistiky' }, ]; @@ -53,7 +54,11 @@ export default function Header() { navigate(DEN_URL)}>Můj den + navigate(SABLONY_URL)}>Šablony tréninků navigate(STATISTIKY_URL)}>Statistiky + navigate(PROFIL_URL)}> + Osobní údaje + setSettingsOpen(true)}> Nastavení diff --git a/client/src/components/MealRow.tsx b/client/src/components/MealRow.tsx new file mode 100644 index 0000000..9078c69 --- /dev/null +++ b/client/src/components/MealRow.tsx @@ -0,0 +1,30 @@ +import { MealEntry } from "../../../types"; +import { formatCalories, formatPrice, formatWeight } from "../Utils"; +import RowActions from "./RowActions"; + +type Props = { + entry: MealEntry, + onEdit: () => void, + onDelete: () => void, + onSaveTemplate: () => void, +}; + +/** Jeden řádek seznamu jídel v přehledu dne. */ +const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly) => ( +
+
+
{entry.name}
+
+ {entry.source && {entry.source}} + {entry.importSource && z Luncheru} + {entry.weight != null && {formatWeight(entry.weight)}} + {entry.calories != null && {formatCalories(entry.calories)}} +
+ {entry.note &&
{entry.note}
} +
+
{formatPrice(entry.price)}
+ +
+); + +export default MealRow; diff --git a/client/src/components/RowActions.tsx b/client/src/components/RowActions.tsx new file mode 100644 index 0000000..ecfec26 --- /dev/null +++ b/client/src/components/RowActions.tsx @@ -0,0 +1,35 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPenToSquare, faTrash, faBookmark } from "@fortawesome/free-solid-svg-icons"; + +type Props = { + onEdit: () => void, + onDelete: () => void, + /** Když je vyplněné, přibude tlačítko pro uložení záznamu jako šablony. */ + onSaveTemplate?: () => void, + /** Doplní se do popisku pro čtečky, např. "Upravit jídlo". */ + label: string, +}; + +/** Tlačítka pro úpravu a smazání řádku seznamu. */ +const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly) => ( +
+ {onSaveTemplate && ( + + )} + + +
+); + +export default RowActions; diff --git a/client/src/components/modals/ActivityModal.tsx b/client/src/components/modals/ActivityModal.tsx index 2fe8220..fd183d6 100644 --- a/client/src/components/modals/ActivityModal.tsx +++ b/client/src/components/modals/ActivityModal.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; -import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types"; +import { + ActivityEntry, ActivityUnit, DayOverview, + addActivity, createWorkoutTemplateFromActivities, updateActivity, +} from "../../../../types"; import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums"; import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils"; @@ -21,6 +24,7 @@ function emptyForm(date: string) { name: '', unit: ActivityUnit.KROKY as ActivityUnit, quantity: '', + sets: '', caloriesPer100Units: '', calories: '', note: '', @@ -37,15 +41,18 @@ function emptyForm(date: string) { export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly) { const [form, setForm] = useState(() => emptyForm(date)); const [saving, setSaving] = useState(false); + const [alsoTemplate, setAlsoTemplate] = useState(false); useEffect(() => { if (!isOpen) return; + setAlsoTemplate(false); setForm(entry ? { date: entry.date, name: entry.name, unit: entry.unit, quantity: String(entry.quantity), + sets: entry.sets != null ? String(entry.sets) : '', caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '', calories: entry.calories != null ? String(entry.calories) : '', note: entry.note ?? '', @@ -70,8 +77,9 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: }; const quantity = parseIntInput(form.quantity); + const sets = parseIntInput(form.sets); const caloriesPer100Units = parseIntInput(form.caloriesPer100Units); - const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units); + const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets); const manualCalories = parseIntInput(form.calories); const effectiveCalories = derivedCalories ?? manualCalories; @@ -85,6 +93,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: name: form.name.trim(), unit: form.unit, quantity, + sets, caloriesPer100Units, // Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu calories: derivedCalories != null ? undefined : manualCalories, @@ -93,6 +102,17 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: const response = entry ? await updateActivity({ body: { id: entry.id, ...body } }) : await addActivity({ body }); + if (response.data && alsoTemplate) { + const saved = entry + ? response.data.activities.entries.find(item => item.id === entry.id) + : [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop(); + if (saved) { + // Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony + await createWorkoutTemplateFromActivities({ + body: { date: saved.date, name: saved.name, ids: [saved.id] }, + }); + } + } setSaving(false); if (response.data) { onSaved(response.data); @@ -135,14 +155,26 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
Množství a spotřeba
+
+ Série + update({ sets: event.target.value.replace(/\D/g, '') })} + /> +
- Množství + {(sets ?? 1) > 1 ? 'Na sérii' : 'Množství'} update({ quantity: event.target.value.replace(/\D/g, '') })} /> + {(sets ?? 1) > 1 && quantity != null && ( + celkem {(quantity * sets!).toLocaleString('cs-CZ')} + )}
Jednotka @@ -155,8 +187,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: ))}
-
- Kcal na 100 jedn. +
+ Kcal/100 update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })} />
-
+
Spáleno {derivedCalories != null ? (
{formatCalories(derivedCalories)}
@@ -195,6 +227,13 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: + setAlsoTemplate(event.target.checked)} + />
{effectiveCalories != null && {formatCalories(effectiveCalories)}}
diff --git a/client/src/components/modals/ApplyMealModal.tsx b/client/src/components/modals/ApplyMealModal.tsx new file mode 100644 index 0000000..a9b0f6d --- /dev/null +++ b/client/src/components/modals/ApplyMealModal.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import { Button, Form, Modal, Spinner } from "react-bootstrap"; +import { useNavigate } from "react-router"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons"; +import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; +import { deriveAmounts, formatCalories, formatPrice, formatWeight } from "../../Utils"; +import { SABLONY_URL } from "../../routes"; + +type Props = { + isOpen: boolean, + /** Den, do kterého se jídlo založí. */ + date: string, + onClose: () => void, + /** Zavolá se po založení jídla s aktualizovaným přehledem dne. */ + onApplied: (day: DayOverview) => void, +}; + +/** Popíše šablonu jednou řádkou — gramáž, kalorie a cena, co je vyplněné. */ +const describe = (template: MealTemplate): string => { + const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g); + const calories = derived.calories ?? template.calories; + return [ + template.source, + derived.weight != null ? formatWeight(derived.weight) : undefined, + calories != null ? formatCalories(calories) : undefined, + template.price != null ? formatPrice(template.price) : undefined, + ].filter(Boolean).join(' · '); +}; + +/** + * Výběr šablony jídla k založení do dne. + * + * Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen + * vybrat hotové jídlo. Chod lze při zakládání přepsat, protože totéž jídlo může + * být jednou oběd a podruhé večeře. + */ +const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly) => { + const [templates, setTemplates] = useState([]); + const [mealType, setMealType] = useState(''); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + setMealType(''); + getMealTemplates().then(response => { + setTemplates(response.data ?? []); + setLoading(false); + }); + }, [isOpen]); + + const apply = async (id: string) => { + setBusy(true); + const response = await applyMealTemplate({ + body: { id, date, ...(mealType ? { mealType } : {}) }, + }); + setBusy(false); + if (response.data) { + onApplied(response.data); + } + }; + + return ( + + + Použít šablonu jídla + + + {loading && ( +
+ +
+ )} + + {!loading && !templates.length && ( +
+
+
Zatím nemáte žádnou šablonu jídla
+

Vytvořit si ji můžete na stránce Šablony.

+
+ )} + + {!loading && !!templates.length && ( + <> + + Založit jako + setMealType(event.target.value as MealType | '')} + > + + {MEAL_TYPES_IN_ORDER.map(type => ( + + ))} + + + + {templates.map(template => ( +
+
+
{template.name}
+
{describe(template)}
+
+ +
+ ))} + + )} +
+ + + + +
+ ); +}; + +export default ApplyMealModal; diff --git a/client/src/components/modals/ApplyWorkoutModal.tsx b/client/src/components/modals/ApplyWorkoutModal.tsx new file mode 100644 index 0000000..e8b6d5a --- /dev/null +++ b/client/src/components/modals/ApplyWorkoutModal.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { Button, Modal, Spinner } from "react-bootstrap"; +import { useNavigate } from "react-router"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons"; +import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../../enums"; +import { formatCalories, formatQuantityWithSets } from "../../Utils"; +import { SABLONY_URL } from "../../routes"; + +type Props = { + isOpen: boolean, + /** Den, do kterého se šablona použije. */ + date: string, + onClose: () => void, + /** Zavolá se po použití šablony s aktualizovaným přehledem dne. */ + onApplied: (day: DayOverview) => void, +}; + +/** Popíše položky šablony jednou řádkou. */ +const describeItems = (template: WorkoutTemplate): string => + template.items.map(item => item.kind === WorkoutItemKind.SABLONA + ? (item.templateRefName ?? 'šablona') + : `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}` + ).join(' · '); + +/** + * Výběr šablony tréninku k založení do dne. + * + * Zakládání a úpravy šablon se řeší na vlastní stránce, sem chodí uživatel jen + * vybrat hotovou šablonu a rozbalit ji do dne. + */ +const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly) => { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + getWorkoutTemplates().then(response => { + setTemplates(response.data ?? []); + setLoading(false); + }); + }, [isOpen]); + + const apply = async (id: string) => { + setBusy(true); + const response = await applyWorkoutTemplate({ body: { id, date } }); + setBusy(false); + if (response.data) { + onApplied(response.data); + } + }; + + return ( + + + Použít šablonu tréninku + + + {loading && ( +
+ +
+ )} + + {!loading && !templates.length && ( +
+
+
Zatím nemáte žádnou šablonu
+

Vytvořit si ji můžete na stránce Šablony.

+
+ )} + + {!loading && templates.map(template => ( +
+
+
+ {template.name} + {template.hasNested && ( + + )} +
+
{describeItems(template)}
+
+ {formatCalories(template.estimatedCalories)} + +
+ ))} +
+ + + + +
+ ); +}; + +export default ApplyWorkoutModal; diff --git a/client/src/components/modals/MealModal.tsx b/client/src/components/modals/MealModal.tsx index eef073a..d4a7d51 100644 --- a/client/src/components/modals/MealModal.tsx +++ b/client/src/components/modals/MealModal.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; -import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types"; +import { + DayRecord, MealEntry, MealType, UserSettings, + addMeal, createMealTemplateFromMeal, getSettings, updateMeal, +} from "../../../../types"; import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; import { deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput, @@ -50,10 +53,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea const [form, setForm] = useState(() => emptyForm(date)); const [settings, setSettings] = useState(); const [saving, setSaving] = useState(false); + const [alsoTemplate, setAlsoTemplate] = useState(false); // Při každém otevření se formulář naplní dle toho, co se upravuje useEffect(() => { if (!isOpen) return; + setAlsoTemplate(false); setForm(entry ? { date: entry.date, @@ -126,6 +131,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea const response = entry ? await updateMeal({ body: { id: entry.id, ...body } }) : await addMeal({ body }); + if (response.data && alsoTemplate) { + // U nového jídla je ten správný záznam vždy ten poslední založený + const saved = entry + ? response.data.entries.find(item => item.id === entry.id) + : [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop(); + if (saved) { + await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } }); + } + } setSaving(false); if (response.data) { onSaved(response.data); @@ -295,6 +309,13 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea + setAlsoTemplate(event.target.checked)} + />
{derived.weight != null && {formatWeight(derived.weight)}} {effectiveCalories != null && {formatCalories(effectiveCalories)}} diff --git a/client/src/components/modals/SaveWorkoutTemplateModal.tsx b/client/src/components/modals/SaveWorkoutTemplateModal.tsx new file mode 100644 index 0000000..5509474 --- /dev/null +++ b/client/src/components/modals/SaveWorkoutTemplateModal.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Form, Modal } from "react-bootstrap"; +import { ActivityEntry, createWorkoutTemplateFromActivities } from "../../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../../enums"; +import { formatCalories, formatQuantityWithSets } from "../../Utils"; + +type Props = { + isOpen: boolean, + /** Den, ze kterého se aktivity berou. */ + date: string, + /** Aktivity daného dne, ze kterých lze vybírat. */ + activities: ActivityEntry[], + /** Aktivity zaškrtnuté při otevření. Bez nich se zaškrtnou všechny. */ + preselected?: string[], + onClose: () => void, + /** Zavolá se po úspěšném vytvoření šablony. */ + onSaved: () => void, +}; + +/** + * Uložení aktivit dne jako šablony tréninku. + * + * Opačný směr než použití šablony: odcvičený trénink jde takhle povýšit na + * šablonu a příště ho založit jedním kliknutím. Aktivity ve dni zůstanou. + */ +const SaveWorkoutTemplateModal = ({ + isOpen, date, activities, preselected, onClose, onSaved, +}: Readonly) => { + const [name, setName] = useState(''); + const [selected, setSelected] = useState([]); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + const initial = preselected?.length ? preselected : activities.map(entry => entry.id); + setSelected(initial); + setError(undefined); + // U jediného cviku se jako název nabídne jeho jméno, jinak nechá uživatel na sobě + setName(initial.length === 1 + ? (activities.find(entry => entry.id === initial[0])?.name ?? '') + : ''); + }, [isOpen, activities, preselected]); + + const toggle = (id: string) => setSelected(current => + current.includes(id) ? current.filter(item => item !== id) : [...current, id]); + + const canSave = name.trim().length > 0 && selected.length > 0 && !saving; + + const save = async () => { + if (!canSave) return; + setSaving(true); + setError(undefined); + const response = await createWorkoutTemplateFromActivities({ + body: { date, name: name.trim(), ids: selected }, + }); + setSaving(false); + if (response.data) { + onSaved(); + } else { + setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit'); + } + }; + + return ( + + + Uložit jako šablonu tréninku + + + {error && {error}} + + + Název šablony + setName(event.target.value)} + /> + + + Zahrnout cviky + {activities.map(entry => ( +
+ toggle(entry.id)} + /> + + {formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)} + + {formatCalories(entry.calories)} +
+ ))} +
+ + + + +
+ ); +}; + +export default SaveWorkoutTemplateModal; diff --git a/client/src/components/modals/SettingsModal.tsx b/client/src/components/modals/SettingsModal.tsx index ceb5dbd..79bfe44 100644 --- a/client/src/components/modals/SettingsModal.tsx +++ b/client/src/components/modals/SettingsModal.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types"; -import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils"; +import { UserSettings, getSettings, saveSourceRate } from "../../../../types"; +import { formatPriceInput, parsePriceInput } from "../../Utils"; type Props = { isOpen: boolean, @@ -20,17 +20,13 @@ export default function SettingsModal({ isOpen, onClose }: Readonly) { const [settings, setSettings] = useState(); const [newSource, setNewSource] = useState(''); const [newRate, setNewRate] = useState(''); - const [basal, setBasal] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { if (!isOpen) return; setNewSource(''); setNewRate(''); - getSettings().then(response => { - setSettings(response.data); - setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : ''); - }); + getSettings().then(response => setSettings(response.data)); }, [isOpen]); const save = async (source: string, pricePer100g: number | null) => { @@ -42,16 +38,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly) { } }; - /** Uloží klidový výdej. Prázdné pole ho odstraní. */ - const saveBasal = async () => { - setSaving(true); - const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } }); - setSaving(false); - if (response.data) { - setSettings(response.data); - } - }; - const addRate = async () => { const rate = parsePriceInput(newRate); if (!newSource.trim().length || !rate) return; @@ -66,24 +52,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly) { Nastavení -

Klidový výdej

-

- Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj - porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit. -

-
-
- setBasal(event.target.value.replace(/\D/g, ''))} - /> - kcal / den -
- -
-

Cena za 100 g

U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá diff --git a/client/src/components/modals/WorkoutModal.tsx b/client/src/components/modals/WorkoutModal.tsx deleted file mode 100644 index 09a4d33..0000000 --- a/client/src/components/modals/WorkoutModal.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { useEffect, useState } from "react"; -import { Button, Form, Modal } from "react-bootstrap"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons"; -import { - ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem, - applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate, -} from "../../../../types"; -import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums"; -import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils"; - -type Props = { - isOpen: boolean, - /** Den, do kterého se šablona použije. */ - date: string, - onClose: () => void, - /** Zavolá se po použití šablony s aktualizovaným přehledem dne. */ - onApplied: (day: DayOverview) => void, -}; - -/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */ -type DraftItem = { - name: string, - unit: ActivityUnit, - quantity: string, - caloriesPer100Units: string, -}; - -function emptyItem(): DraftItem { - return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' }; -} - -/** - * Správa šablon tréninků a jejich použití na den. - * - * Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný - * trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou - * pak samostatné aktivity, takže úprava jedné z nich šablonu nemění. - */ -export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly) { - const [templates, setTemplates] = useState([]); - const [editedId, setEditedId] = useState(); - const [name, setName] = useState(''); - const [items, setItems] = useState([emptyItem()]); - const [busy, setBusy] = useState(false); - - const resetForm = () => { - setEditedId(undefined); - setName(''); - setItems([emptyItem()]); - }; - - useEffect(() => { - if (!isOpen) return; - resetForm(); - getWorkoutTemplates().then(response => setTemplates(response.data ?? [])); - }, [isOpen]); - - const updateItem = (index: number, changes: Partial) => - setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item))); - - /** Výběr známého cviku předvyplní jednotku i orientační sazbu. */ - const applyPreset = (index: number, value: string) => { - const preset = ACTIVITY_PRESETS.find(item => item.name === value); - updateItem(index, preset - ? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) } - : { name: value }); - }; - - /** Převede rozpracované položky na ty, které jdou uložit. */ - const toItems = (): WorkoutTemplateItem[] => items - .filter(item => item.name.trim().length && parseIntInput(item.quantity) != null) - .map(item => ({ - name: item.name.trim(), - unit: item.unit, - quantity: parseIntInput(item.quantity)!, - ...(parseIntInput(item.caloriesPer100Units) != null - ? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) } - : {}), - })); - - const canSave = name.trim().length > 0 && toItems().length > 0 && !busy; - - const save = async () => { - if (!canSave) return; - setBusy(true); - const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } }); - setBusy(false); - if (response.data) { - setTemplates(response.data); - resetForm(); - } - }; - - const remove = async (id: string) => { - setBusy(true); - const response = await deleteWorkoutTemplate({ body: { id } }); - setBusy(false); - if (response.data) { - setTemplates(response.data); - if (editedId === id) resetForm(); - } - }; - - const apply = async (id: string) => { - setBusy(true); - const response = await applyWorkoutTemplate({ body: { id, date } }); - setBusy(false); - if (response.data) { - onApplied(response.data); - } - }; - - /** Načte šablonu do formuláře k úpravě. */ - const edit = (template: WorkoutTemplate) => { - setEditedId(template.id); - setName(template.name); - setItems(template.items.map(item => ({ - name: item.name, - unit: item.unit, - quantity: String(item.quantity), - caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '', - }))); - }; - - /** Průběžný odhad kalorií rozpracované šablony. */ - const draftCalories = items.reduce((sum, item) => - sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0); - - return ( - - - Šablony tréninků - - - {templates.length === 0 && ( -

- Zatím nemáte žádnou šablonu. Sestavte si ji níže — příště pak stačí jedno kliknutí. -

- )} - - {templates.map(template => ( -
-
-
{template.name}
-
- {template.items.map(item => - `${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}` - ).join(' · ')} -
-
- {formatCalories(template.estimatedCalories)} - - - -
- ))} - -
- {editedId ? 'Úprava šablony' : 'Nová šablona'} - - - Název - setName(event.target.value)} - /> - - - {items.map((item, index) => ( - // Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč -
- applyPreset(index, event.target.value)} - /> - updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })} - /> - updateItem(index, { unit: event.target.value as ActivityUnit })} - > - {ACTIVITY_UNITS_IN_ORDER.map(unit => ( - - ))} - - updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })} - /> - -
- ))} - - {ACTIVITY_PRESETS.map(preset => ( - - -
- - {draftCalories > 0 && ( - celkem {formatCalories(draftCalories)} - )} - {editedId && ( - - )} - -
-
-
- - - - - ); -} diff --git a/client/src/enums.ts b/client/src/enums.ts index 7a952a0..2303541 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -1,4 +1,4 @@ -import { ActivityUnit, MealType } from "../../types"; +import { ActivityUnit, MealType, Pohlavi } from "../../types"; /** Lidsky čitelné názvy typů jídel. */ export const MEAL_TYPE_NAMES: Record = { @@ -38,12 +38,12 @@ export const ACTIVITY_UNIT_NAMES: Record = { OPAKOVANI: 'opakování', }; -/** Zkratka jednotky za číslo, např. "10 000 kroků". */ +/** Zkratka jednotky za číslo, např. "10 000 kroků". U opakování nesmí být '×', kolidovalo by se zápisem sérií. */ export const ACTIVITY_UNIT_SHORT: Record = { KROKY: 'kroků', MINUTY: 'min', KM: 'km', - OPAKOVANI: '×', + OPAKOVANI: 'opak.', }; /** Jednotky v pořadí, v jakém se nabízejí. */ @@ -71,3 +71,46 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10 { name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 }, { name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 }, ]; + +/** Záložky přehledu dne. */ +export enum DayTab { + PRIJEM = 'PRIJEM', + VYDEJ = 'VYDEJ', +} + +export const DAY_TAB_NAMES: Record = { + PRIJEM: 'Příjem', + VYDEJ: 'Výdej', +}; + +/** Rozsahy, za které lze zobrazit statistiky. */ +export enum StatsRange { + MESIC = 'MESIC', + ROK = 'ROK', + VSE = 'VSE', +} + +export const STATS_RANGE_NAMES: Record = { + MESIC: 'Měsíc', + ROK: 'Rok', + VSE: 'Vše', +}; + +/** Lidsky čitelné názvy pohlaví. */ +export const POHLAVI_NAMES: Record = { + MUZ: 'Muž', + ZENA: 'Žena', +}; + +export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA]; + +/** Záložky stránky se šablonami. */ +export enum TemplateTab { + TRENINKY = 'TRENINKY', + JIDLA = 'JIDLA', +} + +export const TEMPLATE_TAB_NAMES: Record = { + TRENINKY: 'Tréninky', + JIDLA: 'Jídla', +}; diff --git a/client/src/pages/DayPage.scss b/client/src/pages/DayPage.scss index 2cde90e..99658c3 100644 --- a/client/src/pages/DayPage.scss +++ b/client/src/pages/DayPage.scss @@ -198,3 +198,36 @@ .meal-price.burn { color: var(--ft-warning); } + +// Spánek se zadává přímo v dlaždici, ať to není další dialog +.sleep-input-row { + display: flex; + align-items: baseline; + gap: 6px; + + .sleep-input { + width: 84px; + padding: 0 6px; + height: auto; + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -0.02em; + background: transparent; + border: none; + border-bottom: 1px dashed var(--ft-border); + border-radius: 0; + color: var(--ft-text); + + &:focus { + background: transparent; + box-shadow: none; + border-bottom-color: var(--ft-primary); + } + } + + .sleep-unit { + font-size: 1.1rem; + font-weight: 600; + color: var(--ft-text-secondary); + } +} diff --git a/client/src/pages/DayPage.tsx b/client/src/pages/DayPage.tsx index 687453c..f79bbb8 100644 --- a/client/src/pages/DayPage.tsx +++ b/client/src/pages/DayPage.tsx @@ -1,26 +1,28 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Spinner } from "react-bootstrap"; +import { Alert, Button, Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { - faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash, - faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell, + faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning, + faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark, } from "@fortawesome/free-solid-svg-icons"; -import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types"; import { - ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, -} from "../enums"; + ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal, + deleteActivity, deleteMeal, getDayOverview, saveSleep, +} from "../../../types"; +import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; import { - formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight, - getDayOfWeek, getHumanDate, shiftDate, + formatBalance, formatCalories, formatDate, formatPrice, formatSleep, + formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate, } from "../Utils"; import MealModal from "../components/modals/MealModal"; import ActivityModal from "../components/modals/ActivityModal"; -import WorkoutModal from "../components/modals/WorkoutModal"; +import ApplyWorkoutModal from "../components/modals/ApplyWorkoutModal"; +import ApplyMealModal from "../components/modals/ApplyMealModal"; +import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal"; +import MealRow from "../components/MealRow"; +import ActivityRow from "../components/ActivityRow"; import "./DayPage.scss"; -/** Záložky dne — co do těla přišlo a co se vydalo. */ -type Tab = 'prijem' | 'vydej'; - /** Prázdný přehled dne — použije se, než dorazí data ze serveru. */ function emptyOverview(date: string): DayOverview { return { @@ -34,13 +36,18 @@ function emptyOverview(date: string): DayOverview { export default function DayPage() { const [date, setDate] = useState(() => formatDate(new Date())); const [day, setDay] = useState(() => emptyOverview(date)); - const [tab, setTab] = useState('prijem'); + const [tab, setTab] = useState(DayTab.PRIJEM); const [loading, setLoading] = useState(true); const [editedMeal, setEditedMeal] = useState(); const [mealModalOpen, setMealModalOpen] = useState(false); const [editedActivity, setEditedActivity] = useState(); const [activityModalOpen, setActivityModalOpen] = useState(false); const [workoutModalOpen, setWorkoutModalOpen] = useState(false); + const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false); + const [sleepInput, setSleepInput] = useState(''); + const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false); + const [preselectedActivities, setPreselectedActivities] = useState([]); + const [savedNotice, setSavedNotice] = useState(); const loadDay = useCallback(async (forDate: string) => { setLoading(true); @@ -51,6 +58,17 @@ export default function DayPage() { useEffect(() => { loadDay(date); }, [date, loadDay]); + // Pole spánku se plní z načteného dne; uživatelovu rozepsanou hodnotu přepíše jen při změně dne + useEffect(() => { setSleepInput(formatSleepInput(day.sleepMinutes)); }, [day.date, day.sleepMinutes]); + + /** Uloží spánek při opuštění pole — průběžné ukládání po každé číslici nedává smysl. */ + const commitSleep = async () => { + const minutes = parseSleepInput(sleepInput) ?? null; + if (minutes === (day.sleepMinutes ?? null)) return; + const response = await saveSleep({ body: { date, sleepMinutes: minutes } }); + if (response.data) setDay(response.data); + }; + const isToday = date === formatDate(new Date()); /** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */ @@ -69,6 +87,25 @@ export default function DayPage() { loadDay(date); }; + /** Krátká hláška o úspěchu; sama zmizí, ať nepřekáží. */ + const showNotice = (text: string) => { + setSavedNotice(text); + setTimeout(() => setSavedNotice(undefined), 3000); + }; + + const saveMealAsTemplate = async (entry: MealEntry) => { + const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } }); + if (response.data) { + showNotice(`„${entry.name}" uloženo jako šablonu jídla.`); + } + }; + + /** Otevře dialog pro uložení tréninku, se zaškrtnutou jednou aktivitou nebo všemi. */ + const openSaveWorkout = (entry?: ActivityEntry) => { + setPreselectedActivities(entry ? [entry.id] : []); + setSaveWorkoutOpen(true); + }; + const removeActivity = async (entry: ActivityEntry) => { const response = await deleteActivity({ body: { id: entry.id, date: entry.date } }); if (response.data) setDay(response.data); @@ -78,12 +115,22 @@ export default function DayPage() { <>

Můj den

- {tab === 'prijem' ? ( - + {tab === DayTab.PRIJEM ? ( +
+ + +
) : (
+ {!!day.activities.entries.length && ( + + )} @@ -156,21 +203,41 @@ export default function DayPage() {
Útrata
{formatPrice(day.meals.totalPrice)}
+
+
Spánek
+
+ setSleepInput(event.target.value)} + onBlur={() => commitSleep()} + /> + h +
+
+ {day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'} +
+
+ {savedNotice && {savedNotice}} +
@@ -181,7 +248,7 @@ export default function DayPage() {
)} - {!loading && tab === 'prijem' && ( + {!loading && tab === DayTab.PRIJEM && ( <> {!day.meals.entries.length && (
@@ -208,6 +275,7 @@ export default function DayPage() { entry={entry} onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }} onDelete={() => removeMeal(entry)} + onSaveTemplate={() => saveMealAsTemplate(entry)} /> ))}
@@ -216,7 +284,7 @@ export default function DayPage() { )} - {!loading && tab === 'vydej' && ( + {!loading && tab === DayTab.VYDEJ && ( <> {!day.activities.entries.length && (
@@ -235,6 +303,7 @@ export default function DayPage() { entry={entry} onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }} onDelete={() => removeActivity(entry)} + onSaveTemplate={() => openSaveWorkout(entry)} /> ))}
@@ -273,7 +342,29 @@ export default function DayPage() { }} /> - setSaveWorkoutOpen(false)} + onSaved={() => { + setSaveWorkoutOpen(false); + showNotice('Šablona tréninku vytvořena.'); + }} + /> + + setMealTemplateModalOpen(false)} + onApplied={saved => { + setDay(saved); + setMealTemplateModalOpen(false); + }} + /> + + setWorkoutModalOpen(false)} @@ -285,67 +376,3 @@ export default function DayPage() { ); } - -type MealRowProps = { - entry: MealEntry, - onEdit: () => void, - onDelete: () => void, -}; - -/** Jeden řádek seznamu jídel. */ -function MealRow({ entry, onEdit, onDelete }: Readonly) { - return ( -
-
-
{entry.name}
-
- {entry.source && {entry.source}} - {entry.importSource && z Luncheru} - {entry.weight != null && {formatWeight(entry.weight)}} - {entry.calories != null && {formatCalories(entry.calories)}} -
- {entry.note &&
{entry.note}
} -
-
{formatPrice(entry.price)}
- -
- ); -} - -type ActivityRowProps = { - entry: ActivityEntry, - onEdit: () => void, - onDelete: () => void, -}; - -/** Jeden řádek seznamu aktivit. */ -function ActivityRow({ entry, onEdit, onDelete }: Readonly) { - return ( -
-
-
{entry.name}
-
- {formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])} - {entry.templateId && ze šablony} -
- {entry.note &&
{entry.note}
} -
-
{formatCalories(entry.calories)}
- -
- ); -} - -/** Tlačítka pro úpravu a smazání řádku. */ -function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) { - return ( -
- - -
- ); -} diff --git a/client/src/pages/MealTemplatesPanel.tsx b/client/src/pages/MealTemplatesPanel.tsx new file mode 100644 index 0000000..bd6fdf2 --- /dev/null +++ b/client/src/pages/MealTemplatesPanel.tsx @@ -0,0 +1,327 @@ +import { useCallback, useEffect, useState } from "react"; +import { Alert, Button, Form, Spinner } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrash, faPenToSquare, faBowlFood } from "@fortawesome/free-solid-svg-icons"; +import { MealTemplate, MealType, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../../../types"; +import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; +import { + deriveAmounts, formatCalories, formatPrice, formatPriceInput, formatWeight, + parseIntInput, parsePriceInput, +} from "../Utils"; + +/** Prázdný formulář nové šablony jídla. */ +const emptyForm = () => ({ + name: '', + source: '', + mealType: '' as MealType | '', + weight: '', + caloriesPer100g: '', + calories: '', + price: '', + pricePer100g: '', + note: '', +}); + +/** + * Panel se správou šablon jídel. + * + * Na rozdíl od šablon tréninků se neskládají z jiných — jsou to ploché záznamy. + * Poznámka slouží i na postup přípravy u domácích jídel; strukturované suroviny + * mohou přibýt později, aniž by se model musel měnit. + */ +const MealTemplatesPanel = () => { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [editedId, setEditedId] = useState(); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + const response = await getMealTemplates(); + setTemplates(response.data ?? []); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const update = (changes: Partial>) => + setForm(current => ({ ...current, ...changes })); + + const resetForm = () => { + setEditedId(undefined); + setForm(emptyForm()); + setError(undefined); + }; + + const canSave = form.name.trim().length > 0 && !busy; + + const save = async () => { + if (!canSave) return; + setBusy(true); + setError(undefined); + const response = await saveMealTemplate({ + body: { + ...(editedId ? { id: editedId } : {}), + name: form.name.trim(), + ...(form.source.trim() ? { source: form.source.trim() } : {}), + ...(form.mealType ? { mealType: form.mealType } : {}), + ...(form.note.trim() ? { note: form.note.trim() } : {}), + weight: parseIntInput(form.weight), + caloriesPer100g: parseIntInput(form.caloriesPer100g), + calories: parseIntInput(form.calories), + price: parsePriceInput(form.price), + pricePer100g: parsePriceInput(form.pricePer100g), + }, + }); + setBusy(false); + if (response.data) { + setTemplates(response.data); + resetForm(); + } else { + setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit'); + } + }; + + const remove = async (id: string) => { + setBusy(true); + const response = await deleteMealTemplate({ body: { id } }); + setBusy(false); + if (response.data) { + setTemplates(response.data); + if (editedId === id) resetForm(); + } + }; + + const edit = (template: MealTemplate) => { + setEditedId(template.id); + setError(undefined); + setForm({ + name: template.name, + source: template.source ?? '', + mealType: template.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 ?? '', + }); + globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }; + + /** Živý náhled toho, co šablona při použití založí. */ + const preview = deriveAmounts( + parsePriceInput(form.price), + parsePriceInput(form.pricePer100g), + parseIntInput(form.weight), + parseIntInput(form.caloriesPer100g)); + const previewCalories = preview.calories ?? parseIntInput(form.calories); + + return ( + <> +

+ Šablona jídla je opakovaně jedené jídlo, které v přehledu dne založíte jedním + kliknutím. Stačí název; gramáž, kalorie a cena se vyplňují podle toho, co o jídle + víte. U domácích jídel jde do poznámky zapsat i postup přípravy. +

+ + {loading && ( +
+ +
+ )} + + {!loading && !templates.length && ( +
+
+
Zatím nemáte žádnou šablonu jídla
+

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

+
+ )} + + {!loading && templates.map(template => { + const derived = deriveAmounts( + template.price, template.pricePer100g, template.weight, template.caloriesPer100g); + const calories = derived.calories ?? template.calories; + return ( +
+
+
+ {template.name} + {template.mealType && ( + + {MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]} + + )} + {template.source && {template.source}} + {derived.weight != null && {formatWeight(derived.weight)}} + {template.note && s postupem} +
+ {calories != null && ( + {formatCalories(calories)} + )} + {template.price != null && ( + {formatPrice(template.price)} + )} + + +
+ {template.note &&

{template.note}

} +
+ ); + })} + +
+

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

+ + {error && {error}} + +
+
+ Název + update({ name: event.target.value })} + /> +
+
+ Zdroj + update({ source: event.target.value })} + /> +
+
+ Chod + update({ mealType: event.target.value as MealType | '' })} + > + + {MEAL_TYPES_IN_ORDER.map(type => ( + + ))} + +
+
+ +
+ Kalorie, gramáž a cena +
+
+ Gramáž +
+ update({ weight: event.target.value.replace(/\D/g, '') })} + /> + g +
+
+
+ Energie na 100 g +
+ update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })} + /> + kcal +
+
+
+ Cena +
+ update({ price: event.target.value })} + /> + Kč +
+
+
+ Cena za 100 g +
+ update({ pricePer100g: event.target.value })} + /> + Kč +
+
+
+ + {/* Kalorie celé porce dávají smysl jen tam, kde se nedají dopočítat z gramáže */} + {preview.calories == null && ( +
+
+ Kalorie porce +
+ update({ calories: event.target.value.replace(/\D/g, '') })} + /> + kcal +
+ když neznáte gramáž ani hodnotu na 100 g +
+
+ )} +
+ + + Poznámka / postup + update({ note: event.target.value })} + /> + + +
+ {previewCalories != null && ( + + {preview.weight != null && `${formatWeight(preview.weight)} · `} + {formatCalories(previewCalories)} + + )} + {editedId && ( + + )} + +
+
+ + ); +}; + +export default MealTemplatesPanel; diff --git a/client/src/pages/ProfilePage.tsx b/client/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..07c5dbc --- /dev/null +++ b/client/src/pages/ProfilePage.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from "react"; +import { Alert, Button, Form, Spinner } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFire, faCalculator } from "@fortawesome/free-solid-svg-icons"; +import { Pohlavi, UserSettings, getSettings, saveBasalCalories, saveProfile } from "../../../types"; +import { POHLAVI_IN_ORDER, POHLAVI_NAMES } from "../enums"; +import { formatCalories, formatWeightKgInput, parseIntInput, parseWeightKgInput } from "../Utils"; + +/** + * Osobní údaje a klidový výdej. + * + * Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá bazální + * metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní + * hodnotu, může ji zadat ručně a ta má přednost. + */ +const ProfilePage = () => { + const [settings, setSettings] = useState(); + const [loading, setLoading] = useState(true); + const [sex, setSex] = useState(''); + const [birthYear, setBirthYear] = useState(''); + const [heightCm, setHeightCm] = useState(''); + const [weightKg, setWeightKg] = useState(''); + const [manualBasal, setManualBasal] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const [saved, setSaved] = useState(false); + + /** Naplní formulář z nastavení vráceného serverem. */ + const fillForm = (data?: UserSettings) => { + setSettings(data); + setSex(data?.profile?.sex ?? ''); + setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : ''); + setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : ''); + setWeightKg(formatWeightKgInput(data?.profile?.weightGrams)); + setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : ''); + }; + + const load = useCallback(async () => { + setLoading(true); + const response = await getSettings(); + fillForm(response.data); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const saveAll = async () => { + setSaving(true); + setError(undefined); + setSaved(false); + + const profileResponse = await saveProfile({ + body: { + ...(sex ? { sex: sex as Pohlavi } : {}), + ...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}), + ...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}), + ...(parseWeightKgInput(weightKg) != null ? { weightGrams: parseWeightKgInput(weightKg) } : {}), + }, + }); + if (!profileResponse.data) { + setSaving(false); + setError((profileResponse.error as { error?: string })?.error ?? 'Údaje se nepodařilo uložit'); + return; + } + + // Ruční klidový výdej se ukládá zvlášť; prázdné pole ho odstraní + const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } }); + setSaving(false); + if (!basalResponse.data) { + setError((basalResponse.error as { error?: string })?.error ?? 'Klidový výdej se nepodařilo uložit'); + return; + } + fillForm(basalResponse.data); + setSaved(true); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + const computed = settings?.computedBasalCalories; + const effective = settings?.effectiveBasalCalories; + const manualWins = settings?.basalCalories != null; + + return ( + <> +
+

Osobní údaje

+
+ +

+ 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. +

+ +
+
+
+ Pohlaví + setSex(event.target.value)}> + + {POHLAVI_IN_ORDER.map(value => ( + + ))} + +
+
+ Rok narození + setBirthYear(event.target.value.replace(/\D/g, ''))} + /> +
+
+ Výška +
+ setHeightCm(event.target.value.replace(/\D/g, ''))} + /> + cm +
+
+
+ Hmotnost +
+ setWeightKg(event.target.value)} + /> + kg +
+
+
+
+ +
+
+
Spočítaný klidový výdej
+
{formatCalories(computed)}
+
+ {computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'} +
+
+
+
Použije se v bilanci
+
{formatCalories(effective)}
+
+ {manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')} +
+
+
+ +
+

Ruční klidový výdej

+

+ Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou. + Prázdné pole znamená, že se použije výpočet z údajů výše. +

+
+
+ setManualBasal(event.target.value.replace(/\D/g, ''))} + /> + kcal / den +
+
+
+ + {error && {error}} + {saved && !error && Uloženo.} + + + +

+ Poznámka k výpočtu: klidový výdej je hodnota za celých 24 hodin včetně spánku. + Záměrně se nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky TDEE — + pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení + by ho započítalo dvakrát. +

+ + ); +}; + +export default ProfilePage; diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index 17e209b..43aa04a 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -6,7 +6,7 @@ import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend, } from "recharts"; import { FoodStats, MealType, getFoodStats } from "../../../types"; -import { MEAL_TYPE_NAMES } from "../enums"; +import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums"; import { formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort, getHumanMonth, getMonthEnd, getMonthStart, @@ -14,15 +14,6 @@ import { import ImportModal from "../components/modals/ImportModal"; import "./StatsPage.scss"; -/** Rozsahy, za které lze statistiky zobrazit. */ -type RangeMode = 'month' | 'year' | 'all'; - -const RANGE_LABELS: Record = { - month: 'Měsíc', - year: 'Rok', - all: 'Vše', -}; - /** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */ const EARLIEST_YEAR = 2015; @@ -43,7 +34,7 @@ function formatChartPrice(value: unknown): string { } export default function StatsPage() { - const [mode, setMode] = useState('month'); + const [mode, setMode] = useState(StatsRange.MESIC); const [month, setMonth] = useState(() => formatMonth(new Date())); const [year, setYear] = useState(() => new Date().getFullYear()); const [stats, setStats] = useState(); @@ -52,10 +43,10 @@ export default function StatsPage() { /** Přepočte hranice období z aktuálně zvoleného rozsahu. */ const range = useMemo(() => { - if (mode === 'month') { + if (mode === StatsRange.MESIC) { return { from: getMonthStart(month), to: getMonthEnd(month) }; } - if (mode === 'year') { + if (mode === StatsRange.ROK) { return { from: `${year}-01-01`, to: `${year}-12-31` }; } return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` }; @@ -73,14 +64,14 @@ export default function StatsPage() { /** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */ const series = useMemo(() => { if (!stats) return []; - if (mode === 'month') { + if (mode === StatsRange.MESIC) { return stats.byDay.map(total => ({ label: formatDateString(total.period).slice(0, 5), price: total.price / 100, calories: total.calories, })); } - if (mode === 'year') { + if (mode === StatsRange.ROK) { return stats.byMonth.map(total => ({ label: getHumanMonth(total.period).split(' ')[0], price: total.price / 100, @@ -117,17 +108,17 @@ export default function StatsPage() {
- {(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => ( + {Object.values(StatsRange).map(value => ( ))}
- {mode === 'month' && ( + {mode === StatsRange.MESIC && ( event.target.value && setMonth(event.target.value)} /> )} - {mode === 'year' && ( + {mode === StatsRange.ROK && ( { + const [tab, setTab] = useState(TemplateTab.TRENINKY); + + return ( + <> +
+

Šablony

+
+ +
+ + +
+ + {tab === TemplateTab.TRENINKY ? : } + + ); +}; + +export default TemplatesPage; diff --git a/client/src/pages/WorkoutTemplatesPanel.tsx b/client/src/pages/WorkoutTemplatesPanel.tsx new file mode 100644 index 0000000..5a46169 --- /dev/null +++ b/client/src/pages/WorkoutTemplatesPanel.tsx @@ -0,0 +1,374 @@ +import { useCallback, useEffect, useState } from "react"; +import { Alert, Button, Form, Spinner } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; +import { + ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, + deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate, +} from "../../../types"; +import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums"; +import { deriveActivityCalories, formatCalories, formatQuantityWithSets, parseIntInput } from "../Utils"; + +/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */ +type DraftItem = { + kind: WorkoutItemKind, + name: string, + unit: ActivityUnit, + quantity: string, + sets: string, + caloriesPer100Units: string, + templateRefId: string, +}; + +const emptyExercise = (): DraftItem => ({ + kind: WorkoutItemKind.CVIK, + name: '', + unit: ActivityUnit.OPAKOVANI, + quantity: '', + sets: '', + caloriesPer100Units: '', + templateRefId: '', +}); + +const emptyReference = (templateRefId: string): DraftItem => ({ + ...emptyExercise(), + kind: WorkoutItemKind.SABLONA, + templateRefId, +}); + +/** + * Panel se správou šablon tréninků. + * + * Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu, + * jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu + * úroveň, takže šablona, která už něco skládá, se sama vnořit nedá. + */ +const WorkoutTemplatesPanel = () => { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [editedId, setEditedId] = useState(); + const [name, setName] = useState(''); + const [items, setItems] = useState([emptyExercise()]); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + const response = await getWorkoutTemplates(); + setTemplates(response.data ?? []); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const resetForm = () => { + setEditedId(undefined); + setName(''); + setItems([emptyExercise()]); + setError(undefined); + }; + + const updateItem = (index: number, changes: Partial) => + setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item))); + + /** Výběr známého cviku předvyplní jednotku i orientační sazbu. */ + const applyPreset = (index: number, value: string) => { + const preset = ACTIVITY_PRESETS.find(item => item.name === value); + updateItem(index, preset + ? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) } + : { name: value }); + }; + + /** + * Šablony, které lze vnořit do právě upravované. + * + * Vypadne ta upravovaná (odkaz sám na sebe) a všechny, které samy něco + * skládají — ty už by druhou úroveň zanoření přinesly. + */ + const nestable = templates.filter(template => template.id !== editedId && !template.hasNested); + + /** True, pokud upravovanou šablonu už někdo skládá — pak do ní nelze vnořit další. */ + const isUsedByOther = !!editedId && (templates.find(t => t.id === editedId)?.usedByCount ?? 0) > 0; + + const toItems = (): WorkoutTemplateItem[] => items + .filter(item => item.kind === WorkoutItemKind.SABLONA + ? item.templateRefId.length > 0 + : item.name.trim().length > 0 && parseIntInput(item.quantity) != null) + .map(item => item.kind === WorkoutItemKind.SABLONA + ? { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId } + : { + kind: WorkoutItemKind.CVIK, + name: item.name.trim(), + unit: item.unit, + quantity: parseIntInput(item.quantity)!, + ...(parseIntInput(item.sets) != null && parseIntInput(item.sets)! > 1 + ? { sets: parseIntInput(item.sets) } + : {}), + ...(parseIntInput(item.caloriesPer100Units) != null + ? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) } + : {}), + }); + + const canSave = name.trim().length > 0 && toItems().length > 0 && !busy; + + const save = async () => { + if (!canSave) return; + setBusy(true); + setError(undefined); + const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } }); + setBusy(false); + if (response.data) { + setTemplates(response.data); + resetForm(); + } else { + // Server odmítá typicky kvůli pravidlu o jedné úrovni zanoření + setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit'); + } + }; + + const remove = async (id: string) => { + setBusy(true); + setError(undefined); + const response = await deleteWorkoutTemplate({ body: { id } }); + setBusy(false); + if (response.data) { + setTemplates(response.data); + if (editedId === id) resetForm(); + } else { + setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo smazat'); + } + }; + + /** Načte šablonu do formuláře k úpravě. */ + const edit = (template: WorkoutTemplate) => { + setEditedId(template.id); + setName(template.name); + setError(undefined); + setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA + ? emptyReference(item.templateRefId ?? '') + : { + kind: WorkoutItemKind.CVIK, + name: item.name ?? '', + unit: item.unit ?? ActivityUnit.OPAKOVANI, + quantity: item.quantity != null ? String(item.quantity) : '', + sets: item.sets != null ? String(item.sets) : '', + caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '', + templateRefId: '', + })); + globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }; + + /** Průběžný odhad kalorií rozpracované šablony, včetně vnořených. */ + const draftCalories = items.reduce((sum, item) => { + if (item.kind === WorkoutItemKind.SABLONA) { + return sum + (templates.find(t => t.id === item.templateRefId)?.estimatedCalories ?? 0); + } + return sum + (deriveActivityCalories( + parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0); + }, 0); + + return ( + <> +

+ Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím. + Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku. + Zanoření je kvůli jednoduchosti jen na jednu úroveň. +

+ + {loading && ( +
+ +
+ )} + + {!loading && !templates.length && ( +
+
+
Zatím nemáte žádnou šablonu
+

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

+
+ )} + + {!loading && templates.map(template => ( +
+
+
+ {template.name} + {template.hasNested && ( + + skládaná + + )} + {template.usedByCount > 0 && ( + používá ji {template.usedByCount}× + )} +
+ {formatCalories(template.estimatedCalories)} + + +
+
    + {template.items.map((item, index) => ( + // Položky nemají vlastní id, v rámci jedné šablony je index stabilní +
  • + {item.kind === WorkoutItemKind.SABLONA ? ( + <> + + {item.templateRefName ?? 'neznámá šablona'} + + {formatCalories(item.templateRefCalories)} + + + ) : ( + <> + {item.name} + + {formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)} + + + {formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))} + + + )} +
  • + ))} +
+
+ ))} + +
+

{editedId ? 'Úprava šablony' : 'Nová šablona'}

+ + {error && {error}} + + + Název + setName(event.target.value)} + /> + + + {items.map((item, index) => ( + // Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč +
+ {item.kind === WorkoutItemKind.SABLONA ? ( + <> + + updateItem(index, { templateRefId: event.target.value })} + > + + {nestable.map(template => ( + + ))} + + + ) : ( + <> + applyPreset(index, event.target.value)} + /> + updateItem(index, { sets: event.target.value.replace(/\D/g, '') })} + /> + updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })} + /> + updateItem(index, { unit: event.target.value as ActivityUnit })} + > + {ACTIVITY_UNITS_IN_ORDER.map(unit => ( + + ))} + + updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })} + /> + + )} + +
+ ))} + + {ACTIVITY_PRESETS.map(preset => ( + + +
+ + + {draftCalories > 0 && ( + celkem {formatCalories(draftCalories)} + )} + {editedId && ( + + )} + +
+
+ + ); +}; + +export default WorkoutTemplatesPanel; diff --git a/client/src/routes.ts b/client/src/routes.ts index 1b77b98..c917940 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -6,3 +6,5 @@ */ export const DEN_URL = '/'; export const STATISTIKY_URL = '/statistiky'; +export const SABLONY_URL = '/sablony'; +export const PROFIL_URL = '/profil'; diff --git a/server/src/activities.ts b/server/src/activities.ts index 3e7d222..48189c1 100644 --- a/server/src/activities.ts +++ b/server/src/activities.ts @@ -79,12 +79,25 @@ export type NormalizedActivityInput = { date: string; name: string; unit: ActivityUnit; + /** Množství na jednu sérii. */ quantity: number; + /** Počet sérií; nevyplněno znamená jednu. */ + sets?: number; caloriesPer100Units?: number; calories?: number; note?: string; }; +/** + * 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. + */ +export const getTotalQuantity = (quantity: number, sets?: number): number => + quantity * Math.max(sets ?? 1, 1); + /** * Dopočte spálené kalorie z množství a sazby na 100 jednotek. * @@ -93,7 +106,7 @@ export type NormalizedActivityInput = { */ export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput { const calories = input.caloriesPer100Units - ? Math.round(input.quantity / 100 * input.caloriesPer100Units) + ? Math.round(getTotalQuantity(input.quantity, input.sets) / 100 * input.caloriesPer100Units) : input.calories; return { ...input, calories }; } @@ -115,11 +128,14 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput if (quantity == null) { throw new 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'), @@ -193,6 +209,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown) const updated: ActivityEntry = { ...original, ...normalized, + sets: normalized.sets, caloriesPer100Units: normalized.caloriesPer100Units, calories: normalized.calories, note: normalized.note, diff --git a/server/src/dayOverview.ts b/server/src/dayOverview.ts index ee1380b..67adccd 100644 --- a/server/src/dayOverview.ts +++ b/server/src/dayOverview.ts @@ -2,6 +2,7 @@ import { DayOverview, EnergyBalance } from "../../types/gen/types.gen"; import { getDay } from "./meals"; import { getActivityDay } from "./activities"; import { getSettings } from "./settings"; +import { getSleep } from "./sleep"; /** * Spočítá energetickou bilanci dne. @@ -10,11 +11,15 @@ import { getSettings } from "./settings"; * 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 */ -export function buildEnergyBalance(intake: number, activityBurn: number, basal?: number): EnergyBalance { +export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => { const resolvedBasal = basal ?? 0; const totalBurn = resolvedBasal + activityBurn; return { @@ -25,24 +30,27 @@ export function buildEnergyBalance(intake: number, activityBurn: number, basal?: balance: intake - totalBurn, hasBasal: !!basal, }; -} +}; /** - * Sestaví kompletní přehled dne — jídlo, pohyb a jejich bilanci. + * 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 */ -export async function getDayOverview(login: string, date: string): Promise { - const [meals, activities, settings] = await Promise.all([ +export const getDayOverview = async (login: string, date: string): Promise => { + const [meals, activities, settings, sleepMinutes] = await Promise.all([ getDay(login, date), getActivityDay(login, date), getSettings(login), + getSleep(login, date), ]); return { date, meals, activities, - energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.basalCalories), + // Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů + energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories), + ...(sleepMinutes ? { sleepMinutes } : {}), }; -} +}; diff --git a/server/src/index.ts b/server/src/index.ts index e80d9a3..4c71686 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import settingsRoutes from "./routes/settingsRoutes"; import activityRoutes from "./routes/activityRoutes"; import workoutRoutes from "./routes/workoutRoutes"; import dayRoutes from "./routes/dayRoutes"; +import mealTemplateRoutes from "./routes/mealTemplateRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -144,6 +145,7 @@ app.use("/api/settings", settingsRoutes); app.use("/api/activities", activityRoutes); app.use("/api/workouts", workoutRoutes); app.use("/api/day", dayRoutes); +app.use("/api/mealTemplates", mealTemplateRoutes); app.use(express.static(path.join(process.cwd(), 'public'))); app.get('*splat', (_req, res) => { diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts new file mode 100644 index 0000000..3dc43c9 --- /dev/null +++ b/server/src/mealTemplates.ts @@ -0,0 +1,206 @@ +import { randomUUID } from 'crypto'; +import { MealTemplate, MealType } from "../../types/gen/types.gen"; +import getStorage from "./storage"; +import { addMeal, getDay, isMealType } from "./meals"; +import { normalizeName } from "./calories"; +import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils"; + +const storage = getStorage(); + +/** 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; + +/** Klíč úložiště se šablonami jídel jednoho uživatele. */ +const getKey = (login: string): string => `mealTemplates:${login}`; + +/** + * Vrátí šablony jídel uživatele seřazené podle názvu. + * + * @param login přihlašovací jméno uživatele + */ +export const getMealTemplates = async (login: string): Promise => + (await storage.getData(getKey(login)) ?? []) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + +/** Ořízne a znormalizuje volitelný textový vstup. */ +const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => { + if (value == null) return undefined; + if (typeof value !== 'string') { + throw new BadRequestError(`Pole '${name}' musí být text`); + } + const trimmed = value.trim(); + if (!trimmed.length) return undefined; + if (trimmed.length > maxLength) { + throw new BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`); + } + return trimmed; +}; + +/** Ověří volitelnou nezápornou celočíselnou hodnotu. */ +const normalizeNumber = (value: unknown, name: string): 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 = Math.round(value); + if (rounded < 0) { + throw new BadRequestError(`Pole '${name}' nesmí být záporné`); + } + return rounded; +}; + +/** + * Založí novou šablonu jídla, nebo přepíše existující podle předaného id. + * + * @param login přihlašovací jméno uživatele + * @param input data šablony + */ +export const saveMealTemplate = async (login: string, input: unknown): Promise => { + const value = (input ?? {}) as Record; + const name = normalizeText(value.name, 'name'); + if (!name) { + throw new BadRequestError("Nebyl předán název šablony"); + } + if (value.mealType != null && !isMealType(value.mealType)) { + throw new BadRequestError("Nebyl předán platný typ jídla"); + } + + const template: MealTemplate = { + id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(), + name, + ...(value.mealType != null ? { mealType: value.mealType as MealType } : {}), + ...(normalizeText(value.source, 'source') != null + ? { source: normalizeText(value.source, 'source') } : {}), + ...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null + ? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}), + ...(normalizeNumber(value.calories, 'calories') != null + ? { calories: normalizeNumber(value.calories, 'calories') } : {}), + ...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null + ? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}), + ...(normalizeNumber(value.weight, 'weight') != null + ? { weight: normalizeNumber(value.weight, 'weight') } : {}), + ...(normalizeNumber(value.price, 'price') != null + ? { price: normalizeNumber(value.price, 'price') } : {}), + ...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null + ? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}), + }; + + await storage.updateData(getKey(login), current => { + const others = (current ?? []).filter(item => item.id !== template.id); + return [...others, template]; + }); + return getMealTemplates(login); +}; + +/** + * Smaže šablonu jídla. + * + * @param login přihlašovací jméno uživatele + * @param id identifikátor šablony + */ +export const deleteMealTemplate = async (login: string, id: unknown): Promise => { + if (typeof id !== 'string' || !id.length) { + throw new BadRequestError("Nebyl předán identifikátor šablony"); + } + 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 NotFoundError("Šablona nebyla nalezena"); + } + return getMealTemplates(login); +}; + +/** + * 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 + */ +export const applyMealTemplate = async ( + login: string, + id: unknown, + date: unknown, + mealType?: unknown, +): Promise => { + if (typeof id !== 'string' || !id.length) { + throw new BadRequestError("Nebyl předán identifikátor šablony"); + } + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (mealType != null && !isMealType(mealType)) { + throw new BadRequestError("Nebyl předán platný typ jídla"); + } + const template = (await getMealTemplates(login)).find(item => item.id === id); + if (!template) { + throw new NotFoundError("Šablona nebyla nalezena"); + } + + await addMeal(login, { + date, + mealType: mealType ?? template.mealType ?? MealType.OBED, + name: template.name, + source: template.source, + weight: template.weight, + price: template.price, + pricePer100g: template.pricePer100g, + calories: template.calories, + caloriesPer100g: template.caloriesPer100g, + note: template.note, + }); + 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. + * + * @param login přihlašovací jméno uživatele + * @param date den, ve kterém jídlo je + * @param id identifikátor jídla + */ +export const createMealTemplateFromMeal = async ( + login: string, + date: unknown, + id: unknown, +): Promise => { + if (typeof id !== 'string' || !id.length) { + throw new BadRequestError("Nebyl předán identifikátor jídla"); + } + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + const meal = (await getDay(login, date)).entries.find(entry => entry.id === id); + if (!meal) { + throw new NotFoundError("Jídlo nebylo nalezeno"); + } + + const key = normalizeName(meal.name); + const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key); + + return saveMealTemplate(login, { + ...(existing ? { id: existing.id } : {}), + name: meal.name, + source: meal.source, + mealType: meal.mealType, + weight: meal.weight, + price: meal.price, + pricePer100g: meal.pricePer100g, + // Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže + calories: meal.caloriesPer100g ? undefined : meal.calories, + caloriesPer100g: meal.caloriesPer100g, + note: meal.note, + }); +}; diff --git a/server/src/profile.ts b/server/src/profile.ts new file mode 100644 index 0000000..b4b483f --- /dev/null +++ b/server/src/profile.ts @@ -0,0 +1,73 @@ +import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen"; +import { BadRequestError } from "./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. */ +export const isPohlavi = (value: unknown): value is Pohlavi => + value === Pohlavi.MUZ || value === Pohlavi.ZENA; + +/** Ověří volitelné celé číslo v daném rozsahu. */ +const normalizeRanged = (value: unknown, name: keyof typeof LIMITS): 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 = Math.round(value); + const { min, max } = LIMITS[name]; + if (rounded < min || rounded > max) { + throw new BadRequestError(`Pole '${name}' je mimo rozsah (${min}–${max})`); + } + return rounded; +}; + +/** Ověří a znormalizuje tělesné údaje. Prázdné hodnoty se uloží jako nevyplněné. */ +export const normalizeProfile = (input: unknown): PersonalProfile => { + const value = (input ?? {}) as Record; + if (value.sex != null && !isPohlavi(value.sex)) { + throw new BadRequestError("Nebylo předáno platné pohlaví"); + } + return { + ...(value.sex != null ? { sex: value.sex as Pohlavi } : {}), + ...(normalizeRanged(value.birthYear, 'birthYear') != null + ? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}), + ...(normalizeRanged(value.heightCm, 'heightCm') != null + ? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}), + ...(normalizeRanged(value.weightGrams, 'weightGrams') != null + ? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}), + }; +}; + +/** + * 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ý + */ +export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => { + 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 === Pohlavi.MUZ ? 5 : -161)); +}; diff --git a/server/src/routes/dayRoutes.ts b/server/src/routes/dayRoutes.ts index 97125dc..4f16d53 100644 --- a/server/src/routes/dayRoutes.ts +++ b/server/src/routes/dayRoutes.ts @@ -1,5 +1,6 @@ import express, { Request, Response, NextFunction } from "express"; import { getDayOverview } from "../dayOverview"; +import { saveSleep } from "../sleep"; import { getLogin } from "../auth"; import { formatDate, getToday, parseToken, requireIsoDate } from "../utils"; @@ -16,4 +17,14 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => { } catch (e) { next(e); } }); +/** Uloží délku spánku pro zvolený den. */ +router.post("/sleep", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { date, sleepMinutes } = req.body ?? {}; + const day = await saveSleep(login, date, sleepMinutes); + res.status(200).json(await getDayOverview(login, day)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/routes/mealTemplateRoutes.ts b/server/src/routes/mealTemplateRoutes.ts new file mode 100644 index 0000000..2e0002f --- /dev/null +++ b/server/src/routes/mealTemplateRoutes.ts @@ -0,0 +1,54 @@ +import express, { Request, Response, NextFunction } from "express"; +import { + applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate, +} from "../mealTemplates"; +import { getDayOverview } from "../dayOverview"; +import { getLogin } from "../auth"; +import { parseToken } from "../utils"; + +const router = express.Router(); + +/** Vrátí šablony jídel přihlášeného uživatele. */ +router.get("/", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await getMealTemplates(login)); + } catch (e) { next(e); } +}); + +/** Založí novou šablonu jídla, nebo upraví existující. */ +router.post("/save", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await saveMealTemplate(login, req.body)); + } catch (e) { next(e); } +}); + +/** Smaže šablonu jídla. */ +router.post("/delete", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await deleteMealTemplate(login, req.body?.id)); + } catch (e) { next(e); } +}); + +/** Založí jídlo ze šablony do zvoleného dne. */ +router.post("/apply", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { id, date, mealType } = req.body ?? {}; + const day = await applyMealTemplate(login, id, date, mealType); + res.status(200).json(await getDayOverview(login, day)); + } catch (e) { next(e); } +}); + +/** Vytvoří šablonu z existujícího jídla v přehledu dne. */ +router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { id, date } = req.body ?? {}; + res.status(200).json(await createMealTemplateFromMeal(login, date, id)); + } catch (e) { next(e); } +}); + +export default router; diff --git a/server/src/routes/settingsRoutes.ts b/server/src/routes/settingsRoutes.ts index e851fbc..d4137ce 100644 --- a/server/src/routes/settingsRoutes.ts +++ b/server/src/routes/settingsRoutes.ts @@ -1,5 +1,5 @@ import express, { Request, Response, NextFunction } from "express"; -import { getSettings, saveBasalCalories, saveSourceRate } from "../settings"; +import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; @@ -30,4 +30,12 @@ router.post("/basal", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); +/** Uloží tělesné údaje uživatele. */ +router.post("/profile", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + res.status(200).json(await saveProfile(login, req.body)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/routes/workoutRoutes.ts b/server/src/routes/workoutRoutes.ts index 731f2ea..a49ca9d 100644 --- a/server/src/routes/workoutRoutes.ts +++ b/server/src/routes/workoutRoutes.ts @@ -1,5 +1,8 @@ import express, { Request, Response, NextFunction } from "express"; -import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts"; +import { + applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate, + getWorkoutTemplates, saveWorkoutTemplate, +} from "../workouts"; import { getDayOverview } from "../dayOverview"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; @@ -41,4 +44,13 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) => } catch (e) { next(e); } }); +/** Vytvoří šablonu z aktivit zaznamenaných v přehledu dne. */ +router.post("/fromActivities", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { date, name, ids } = req.body ?? {}; + res.status(200).json(await createWorkoutTemplateFromActivities(login, date, name, ids)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/settings.ts b/server/src/settings.ts index 9205b9c..f54f534 100644 --- a/server/src/settings.ts +++ b/server/src/settings.ts @@ -1,7 +1,8 @@ -import { UserSettings } from "../../types/gen/types.gen"; +import { PersonalProfile, UserSettings } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { BadRequestError } from "./utils"; import { normalizeName } from "./calories"; +import { computeBasalCalories, normalizeProfile } from "./profile"; const storage = getStorage(); @@ -27,7 +28,39 @@ function emptySettings(): UserSettings { * @param login přihlašovací jméno uživatele */ export async function getSettings(login: string): Promise { - return await storage.getData(getSettingsKey(login)) ?? emptySettings(); + 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: UserSettings): UserSettings { + const computed = 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 + */ +export async function saveProfile(login: string, profile: unknown): Promise { + const normalized: PersonalProfile = normalizeProfile(profile); + const saved = await storage.updateData(getSettingsKey(login), current => ({ + ...(current ?? emptySettings()), + profile: normalized, + })); + return withDerived(saved); } /** @@ -54,11 +87,13 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100 const name = source.trim(); const key = normalizeName(name); - return await storage.updateData(getSettingsKey(login), current => { - const rates = (current ?? emptySettings()).sourceRates.filter(item => normalizeName(item.source) !== key); + const saved = await storage.updateData(getSettingsKey(login), current => { + const settings = current ?? emptySettings(); + const rates = settings.sourceRates.filter(item => normalizeName(item.source) !== key); // Nulová sazba znamená smazání — ukládat ji nemá smysl - return { sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates }; + return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates }; }); + return withDerived(saved); } /** @@ -76,7 +111,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown): if (value < 0 || value > MAX_BASAL_CALORIES) { throw new BadRequestError("Klidový výdej je mimo rozsah"); } - return await storage.updateData(getSettingsKey(login), current => { + const saved = await storage.updateData(getSettingsKey(login), current => { const settings = current ?? emptySettings(); // Nula znamená smazání — ukládat ji nemá smysl if (value > 0) { @@ -85,6 +120,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown): const { basalCalories: _removed, ...rest } = settings; return rest; }); + return withDerived(saved); } /** diff --git a/server/src/sleep.ts b/server/src/sleep.ts new file mode 100644 index 0000000..e5c63bf --- /dev/null +++ b/server/src/sleep.ts @@ -0,0 +1,47 @@ +import getStorage from "./storage"; +import { BadRequestError, isValidIsoDate } from "./utils"; + +const storage = getStorage(); + +/** 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: string, date: string): string => `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 + */ +export const getSleep = async (login: string, date: string): Promise => + await storage.getData(getSleepKey(login, date)); + +/** + * 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 + */ +export const saveSleep = async (login: string, date: unknown, sleepMinutes: unknown): Promise => { + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (sleepMinutes != null && (typeof sleepMinutes !== 'number' || !Number.isFinite(sleepMinutes))) { + throw new 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 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; +}; diff --git a/server/src/tests/setsAndMealTemplates.test.ts b/server/src/tests/setsAndMealTemplates.test.ts new file mode 100644 index 0000000..71e1b70 --- /dev/null +++ b/server/src/tests/setsAndMealTemplates.test.ts @@ -0,0 +1,215 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { addActivity, getActivityDay, getTotalQuantity, updateActivity } from "../activities"; +import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts"; +import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates"; +import { getDay } from "../meals"; +import { resetMemoryStorage } from "../storage/memory"; +import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +const DATE = '2025-03-03'; + +beforeEach(() => resetMemoryStorage()); + +describe('série u cviků', () => { + + it('celkové množství je počet sérií krát množství v sérii', () => { + expect(getTotalQuantity(40, 3)).toBe(120); + expect(getTotalQuantity(40)).toBe(40); + // Nula ani záporná hodnota sérií nedává smysl, bere se jako jedna + expect(getTotalQuantity(40, 0)).toBe(40); + }); + + it('3 × 40 spálí stejně jako 120 v kuse', async () => { + await addActivity(LOGIN, { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 40, sets: 3, caloriesPer100Units: 50, + }); + await addActivity(LOGIN, { + date: '2025-03-04', name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 120, caloriesPer100Units: 50, + }); + + expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60); + expect((await getActivityDay(LOGIN, '2025-03-04')).totalCalories).toBe(60); + }); + + it('ale série zůstane v záznamu, aby šlo 3 × 40 od 120 rozlišit', async () => { + await addActivity(LOGIN, { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 40, sets: 3, caloriesPer100Units: 50, + }); + + const [entry] = (await getActivityDay(LOGIN, DATE)).entries; + expect(entry.quantity).toBe(40); + expect(entry.sets).toBe(3); + }); + + it('jedna série se neukládá, je to výchozí stav', async () => { + await addActivity(LOGIN, { + date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, sets: 1, caloriesPer100Units: 1000, + }); + + expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined(); + }); + + it('úprava umí série přidat i odebrat', async () => { + await addActivity(LOGIN, { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, caloriesPer100Units: 50, + }); + const [created] = (await getActivityDay(LOGIN, DATE)).entries; + + await updateActivity(LOGIN, created.id, { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 40, sets: 3, caloriesPer100Units: 50, + }); + expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBe(3); + expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60); + + await updateActivity(LOGIN, created.id, { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 40, caloriesPer100Units: 50, + }); + expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined(); + expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(20); + }); + + it('šablona počítá odhad ze všech sérií', async () => { + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [ + { kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 }, + ]); + + expect(templates[0].estimatedCalories).toBe(60); + expect(templates[0].items[0].sets).toBe(3); + }); + + it('použití šablony přenese série do založené aktivity', async () => { + const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [ + { kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 }, + ]); + + await applyWorkoutTemplate(LOGIN, template.id, DATE); + + const [entry] = (await getActivityDay(LOGIN, DATE)).entries; + expect(entry.sets).toBe(3); + expect(entry.quantity).toBe(40); + expect(entry.calories).toBe(60); + }); + + it('série se rozbalí i z vnořené šablony', async () => { + const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [ + { kind: WorkoutItemKind.CVIK, name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, quantity: 20, sets: 4, caloriesPer100Units: 40 }, + ]); + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Celý trénink', [ + { kind: WorkoutItemKind.SABLONA, templateRefId: zaklad.id }, + ]); + const celyTrenink = templates.find(t => t.name === 'Celý trénink')!; + expect(celyTrenink.estimatedCalories).toBe(32); + + await applyWorkoutTemplate(LOGIN, celyTrenink.id, DATE); + + const [entry] = (await getActivityDay(LOGIN, DATE)).entries; + expect(entry.sets).toBe(4); + expect(entry.calories).toBe(32); + }); +}); + +describe('šablony jídel', () => { + + const salat = { + name: 'Těstovinový salát', + source: 'Chefie', + mealType: MealType.OBED, + weight: 350, + caloriesPer100g: 160, + price: 12900, + }; + + it('uloží šablonu a vrátí ji v seznamu', async () => { + const templates = await saveMealTemplate(LOGIN, salat); + + expect(templates).toHaveLength(1); + expect(templates[0].name).toBe('Těstovinový salát'); + expect(templates[0].source).toBe('Chefie'); + }); + + it('úprava podle id šablonu přepíše, nezaloží druhou', async () => { + const [created] = await saveMealTemplate(LOGIN, salat); + + const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' }); + + expect(templates).toHaveLength(1); + expect(templates[0].name).toBe('Salát velký'); + }); + + it('použití založí jídlo do dne a dopočte kalorie', async () => { + const [template] = await saveMealTemplate(LOGIN, salat); + + await applyMealTemplate(LOGIN, template.id, DATE); + + const day = await getDay(LOGIN, DATE); + expect(day.entries).toHaveLength(1); + expect(day.entries[0].name).toBe('Těstovinový salát'); + expect(day.entries[0].source).toBe('Chefie'); + // 350 g × 160 kcal/100 g + expect(day.entries[0].calories).toBe(560); + expect(day.totalPrice).toBe(12900); + }); + + it('zvolený chod přebije ten ze šablony', async () => { + const [template] = await saveMealTemplate(LOGIN, salat); + + await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE); + + expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE); + }); + + it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => { + const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 }); + + await applyMealTemplate(LOGIN, template.id, DATE); + + expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED); + }); + + it('šablona jen s absolutními kaloriemi funguje taky', async () => { + const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 }); + + await applyMealTemplate(LOGIN, template.id, DATE); + + expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80); + }); + + it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => { + const [template] = await saveMealTemplate(LOGIN, salat); + await applyMealTemplate(LOGIN, template.id, DATE); + + await deleteMealTemplate(LOGIN, template.id); + + expect(await getMealTemplates(LOGIN)).toEqual([]); + expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1); + }); + + it('unese poznámku s postupem přípravy', async () => { + const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.'; + const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept }); + + expect(template.note).toBe(recept); + + await applyMealTemplate(LOGIN, template.id, DATE); + expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept); + }); + + it('odmítne šablonu bez názvu a neexistující id', async () => { + await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/); + await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/); + await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/); + }); + + it('šablony jídel jsou oddělené mezi uživateli', async () => { + await saveMealTemplate(LOGIN, salat); + + expect(await getMealTemplates('nekdo-jiny')).toEqual([]); + }); +}); diff --git a/server/src/tests/toTemplate.test.ts b/server/src/tests/toTemplate.test.ts new file mode 100644 index 0000000..085863a --- /dev/null +++ b/server/src/tests/toTemplate.test.ts @@ -0,0 +1,168 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { addActivity, getActivityDay } from "../activities"; +import { addMeal, getDay } from "../meals"; +import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates"; +import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts"; +import { resetMemoryStorage } from "../storage/memory"; +import { ActivityUnit, MealType } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +const DATE = '2025-03-03'; + +beforeEach(() => resetMemoryStorage()); + +describe('překlopení jídla do šablony', () => { + + const gyros = { + date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros', + source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172, + }; + + it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => { + const day = await addMeal(LOGIN, gyros); + + const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); + + expect(templates).toHaveLength(1); + expect(templates[0]).toMatchObject({ + name: 'Kuřecí gyros', + source: 'TechTower', + mealType: MealType.OBED, + weight: 400, + caloriesPer100g: 172, + price: 17600, + pricePer100g: 4400, + }); + }); + + it('jídlo v dni zůstane beze změny', async () => { + const day = await addMeal(LOGIN, gyros); + + await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); + + expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1); + }); + + it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => { + const day = await addMeal(LOGIN, gyros); + await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); + + // Totéž jídlo jindy a s jinou gramáží + const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 }); + const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id); + + expect(templates).toHaveLength(1); + expect(templates[0].weight).toBe(500); + }); + + it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => { + const day = await addMeal(LOGIN, { + date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80, + }); + + const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id); + + expect(templates[0].calories).toBe(80); + expect(templates[0].caloriesPer100g).toBeUndefined(); + }); + + it('hlásí chybu u neexistujícího jídla', async () => { + await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje')) + .rejects.toThrow(/nebylo nalezeno/); + }); +}); + +describe('překlopení aktivit do šablony tréninku', () => { + + const kliky = { + date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, + quantity: 40, sets: 3, caloriesPer100Units: 50, + }; + const beh = { + date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, + quantity: 20, caloriesPer100Units: 1000, + }; + + it('bez výčtu vezme všechny aktivity dne', async () => { + await addActivity(LOGIN, kliky); + await addActivity(LOGIN, beh); + + const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3'); + + expect(templates).toHaveLength(1); + expect(templates[0].items.map(item => item.name)).toEqual(['Kliky', 'Běh']); + // 3 × 40 × 0,5 + 20 × 10 + expect(templates[0].estimatedCalories).toBe(260); + }); + + it('přenese i počet sérií', async () => { + await addActivity(LOGIN, kliky); + + const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok'); + + expect(templates[0].items[0].sets).toBe(3); + expect(templates[0].items[0].quantity).toBe(40); + }); + + it('s výčtem vezme jen vybrané aktivity', async () => { + await addActivity(LOGIN, kliky); + await addActivity(LOGIN, beh); + const entries = (await getActivityDay(LOGIN, DATE)).entries; + const klikyId = entries.find(entry => entry.name === 'Kliky')!.id; + + const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Jen kliky', [klikyId]); + + expect(templates[0].items).toHaveLength(1); + expect(templates[0].items[0].name).toBe('Kliky'); + }); + + it('aktivity v dni zůstanou beze změny', async () => { + await addActivity(LOGIN, kliky); + + await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3'); + + expect((await getActivityDay(LOGIN, DATE)).entries).toHaveLength(1); + }); + + it('aktivita bez sazby dá položku bez odhadu kalorií', async () => { + await addActivity(LOGIN, { + date: DATE, name: 'Plavání', unit: ActivityUnit.MINUTY, quantity: 30, calories: 250, + }); + + const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Bazén'); + + expect(templates[0].items[0].caloriesPer100Units).toBeUndefined(); + expect(templates[0].estimatedCalories).toBe(0); + }); + + it('odmítne den bez aktivit a den bez názvu', async () => { + await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, 'Prázdný')) + .rejects.toThrow(/nejsou žádné aktivity/); + + await addActivity(LOGIN, kliky); + await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' ')) + .rejects.toThrow(/název šablony/); + }); + + it('vytvořená šablona jde rovnou použít', async () => { + await addActivity(LOGIN, kliky); + const [template] = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok'); + + const { applyWorkoutTemplate } = await import("../workouts"); + await applyWorkoutTemplate(LOGIN, template.id, '2025-03-20'); + + const [entry] = (await getActivityDay(LOGIN, '2025-03-20')).entries; + expect(entry.name).toBe('Kliky'); + expect(entry.sets).toBe(3); + expect(entry.calories).toBe(60); + }); + + it('šablony jsou oddělené mezi uživateli', async () => { + await addActivity(LOGIN, kliky); + await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok'); + + expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]); + expect(await getMealTemplates('nekdo-jiny')).toEqual([]); + }); +}); diff --git a/server/src/tests/workoutComposition.test.ts b/server/src/tests/workoutComposition.test.ts new file mode 100644 index 0000000..b2577d3 --- /dev/null +++ b/server/src/tests/workoutComposition.test.ts @@ -0,0 +1,247 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts"; +import { getActivityDay } from "../activities"; +import { getSettings, saveBasalCalories, saveProfile } from "../settings"; +import { computeBasalCalories } from "../profile"; +import { getDayOverview } from "../dayOverview"; +import { saveSleep } from "../sleep"; +import { resetMemoryStorage } from "../storage/memory"; +import { ActivityUnit, Pohlavi, WorkoutItemKind } from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +const DATE = '2025-03-03'; + +/** Cvik jako položka šablony. */ +const cvik = (name: string, quantity: number, rate: number) => ({ + kind: WorkoutItemKind.CVIK, + name, + unit: ActivityUnit.OPAKOVANI, + quantity, + caloriesPer100Units: rate, +}); + +/** Odkaz na jinou šablonu. */ +const odkaz = (templateRefId: string) => ({ kind: WorkoutItemKind.SABLONA, templateRefId }); + +beforeEach(() => resetMemoryStorage()); + +describe('skládání šablon z jiných šablon', () => { + + it('šablona složená z jiné sečte kalorie obou', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [ + odkaz(rozcvicka.id), + cvik('Kliky', 60, 50), + ]); + + const workout = templates.find(t => t.name === 'Workout day 1')!; + // 40 dřepů × 0,4 kcal + 60 kliků × 0,5 kcal + expect(workout.estimatedCalories).toBe(46); + expect(workout.hasNested).toBe(true); + }); + + it('doplní název a kalorie vnořené šablony', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]); + + const workout = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Workout day 1')!; + expect(workout.items[0].templateRefName).toBe('Rozcvička'); + expect(workout.items[0].templateRefCalories).toBe(16); + }); + + it('použití rozbalí vnořenou šablonu na její cviky', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [ + cvik('Dřepy', 40, 40), + cvik('Výpady', 20, 30), + ]); + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [ + odkaz(rozcvicka.id), + cvik('Kliky', 60, 50), + ]); + const workout = templates.find(t => t.name === 'Workout day 1')!; + + await applyWorkoutTemplate(LOGIN, workout.id, DATE); + + const day = await getActivityDay(LOGIN, DATE); + expect(day.entries.map(e => e.name)).toEqual(['Dřepy', 'Výpady', 'Kliky']); + expect(day.totalCalories).toBe(52); + }); + + it('počítá, kolik jiných šablon danou šablonu používá', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 2', [odkaz(rozcvicka.id)]); + + const updated = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Rozcvička')!; + expect(updated.usedByCount).toBe(2); + }); + + it('nedovolí zanoření o dvě úrovně', async () => { + const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [cvik('Dřepy', 40, 40)]); + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Střední', [odkaz(zaklad.id)]); + const stredni = templates.find(t => t.name === 'Střední')!; + + // "Střední" už sama skládá jinou šablonu, takže ji nelze vnořit dál + await expect(saveWorkoutTemplate(LOGIN, undefined, 'Vrchní', [odkaz(stredni.id)])) + .rejects.toThrow(/jen na jednu úroveň/); + }); + + it('nedovolí začít vnořovat v šabloně, kterou už někdo skládá', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + const [jiny] = await saveWorkoutTemplate(LOGIN, undefined, 'Jiný blok', [cvik('Kliky', 60, 50)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]); + + // Rozcvičku už skládá Workout day 1, takže do ní nelze vnořit další šablonu + await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', [odkaz(jiny.id)])) + .rejects.toThrow(/jen na jednu úroveň/); + }); + + it('nedovolí odkaz sám na sebe', async () => { + const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + + await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)])) + .rejects.toThrow(/sama na sebe/); + }); + + it('nedovolí odkaz na neexistující šablonu', async () => { + await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')])) + .rejects.toThrow(/nebyla nalezena/); + }); + + it('nedovolí smazat šablonu, kterou skládá jiná', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]); + + await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/); + }); + + it('šablonu bez vazeb smazat lze', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + + expect(await deleteWorkoutTemplate(LOGIN, rozcvicka.id)).toEqual([]); + }); + + it('šablonám uloženým bez druhu položky doplní CVIK', async () => { + // Podoba dat z doby, kdy skládání z jiných šablon ještě neexistovalo + const { default: getStorage } = await import("../storage"); + await getStorage().setData('workouts:' + LOGIN, [{ + id: 'stara', name: 'Stará šablona', + items: [{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 }], + }]); + + const [template] = await getWorkoutTemplates(LOGIN); + expect(template.items[0].kind).toBe(WorkoutItemKind.CVIK); + expect(template.estimatedCalories).toBe(30); + expect(template.hasNested).toBe(false); + }); +}); + +describe('tělesné údaje a bazální metabolismus', () => { + + /** Muž, 90 kg, 185 cm, ročník 1990. */ + const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 }; + + it('spočítá klidový výdej rovnicí Mifflin–St Jeor', () => { + const age = new Date().getFullYear() - 1990; + // 10 × 90 + 6,25 × 185 − 5 × věk + 5 + const expected = Math.round(900 + 1156.25 - 5 * age + 5); + + expect(computeBasalCalories(muz)).toBe(expected); + }); + + it('u ženy odečítá 161 místo přičtení 5', () => { + const zena = { ...muz, sex: Pohlavi.ZENA }; + + expect(computeBasalCalories(muz)! - computeBasalCalories(zena)!).toBe(166); + }); + + it('neúplný profil klidový výdej nespočítá', () => { + expect(computeBasalCalories(undefined)).toBeUndefined(); + expect(computeBasalCalories({ sex: Pohlavi.MUZ, heightCm: 185 })).toBeUndefined(); + }); + + it('uložený profil se propíše do nastavení i do bilance dne', async () => { + await saveProfile(LOGIN, muz); + + const settings = await getSettings(LOGIN); + expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz)); + expect(settings.effectiveBasalCalories).toBe(computeBasalCalories(muz)); + + const overview = await getDayOverview(LOGIN, DATE); + expect(overview.energy.basal).toBe(computeBasalCalories(muz)); + expect(overview.energy.hasBasal).toBe(true); + }); + + it('ručně zadaný klidový výdej má přednost před spočítaným', async () => { + await saveProfile(LOGIN, muz); + await saveBasalCalories(LOGIN, 1800); + + const settings = await getSettings(LOGIN); + expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz)); + expect(settings.effectiveBasalCalories).toBe(1800); + expect((await getDayOverview(LOGIN, DATE)).energy.basal).toBe(1800); + }); + + it('po smazání ručního údaje se vrátí spočítaný z profilu', async () => { + await saveProfile(LOGIN, muz); + await saveBasalCalories(LOGIN, 1800); + await saveBasalCalories(LOGIN, 0); + + expect((await getSettings(LOGIN)).effectiveBasalCalories).toBe(computeBasalCalories(muz)); + }); + + it('odmítne nesmyslné tělesné údaje', async () => { + await expect(saveProfile(LOGIN, { ...muz, heightCm: 5 })).rejects.toThrow(/mimo rozsah/); + await expect(saveProfile(LOGIN, { ...muz, weightGrams: 5_000_000 })).rejects.toThrow(/mimo rozsah/); + await expect(saveProfile(LOGIN, { ...muz, sex: 'JINE' })).rejects.toThrow(/pohlaví/); + }); + + it('uložení profilu nepřepíše sazby podniků', async () => { + const { saveSourceRate } = await import("../settings"); + await saveSourceRate(LOGIN, 'TechTower', 4400); + + await saveProfile(LOGIN, muz); + + expect((await getSettings(LOGIN)).sourceRates).toHaveLength(1); + }); +}); + +describe('spánek', () => { + + it('uloží se ke dni a vrátí v přehledu', async () => { + await saveSleep(LOGIN, DATE, 450); + + expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBe(450); + }); + + it('nulová hodnota záznam odstraní', async () => { + await saveSleep(LOGIN, DATE, 450); + await saveSleep(LOGIN, DATE, 0); + + expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBeUndefined(); + }); + + it('do energetické bilance nevstupuje', async () => { + await saveProfile(LOGIN, { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 }); + const bezSpanku = await getDayOverview(LOGIN, DATE); + + await saveSleep(LOGIN, DATE, 450); + const seSpankem = await getDayOverview(LOGIN, DATE); + + // Klidový výdej pokrývá celých 24 hodin včetně spánku, bilance se měnit nesmí + expect(seSpankem.energy).toEqual(bezSpanku.energy); + }); + + it('odmítne nesmyslnou délku', async () => { + await expect(saveSleep(LOGIN, DATE, 2000)).rejects.toThrow(/mimo rozsah/); + await expect(saveSleep(LOGIN, 'včera', 450)).rejects.toThrow(/platné datum/); + }); + + it('je oddělený mezi uživateli', async () => { + await saveSleep(LOGIN, DATE, 450); + + expect((await getDayOverview('nekdo-jiny', DATE)).sleepMinutes).toBeUndefined(); + }); +}); diff --git a/server/src/workouts.ts b/server/src/workouts.ts index f375955..9e63fd6 100644 --- a/server/src/workouts.ts +++ b/server/src/workouts.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'crypto'; -import { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen"; +import { + ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, +} from "../../types/gen/types.gen"; import getStorage from "./storage"; -import { addActivities, isActivityUnit } from "./activities"; +import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities"; import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils"; const storage = getStorage(); @@ -10,38 +12,99 @@ const storage = getStorage(); const MAX_ITEMS = 50; /** Klíč úložiště se šablonami tréninků jednoho uživatele. */ -function getKey(login: string): string { - return `workouts:${login}`; -} +const getKey = (login: string): string => `workouts:${login}`; -/** Spočítá odhad spálených kcal za celou šablonu. */ -function estimateCalories(items: WorkoutTemplateItem[]): number { - return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0); -} +/** Uložená podoba šablony — dopočítávaná pole se neukládají. */ +type StoredTemplate = { + id: string; + name: string; + items: WorkoutTemplateItem[]; +}; -/** Doplní šabloně dopočítaný odhad kalorií. */ -function withEstimate(template: Omit): WorkoutTemplate { - return { ...template, estimatedCalories: estimateCalories(template.items) }; -} +/** Vrátí true, pokud je položka odkazem na jinou šablonu. */ +const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA; -/** Uložená podoba šablony — odhad kalorií se dopočítává, neukládá se. */ -type StoredTemplate = Omit; +/** + * 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: StoredTemplate): StoredTemplate => ({ + ...template, + items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })), +}); + +/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */ +const itemCalories = (item: WorkoutTemplateItem): number => + Math.round(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: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => { + 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: string): Promise => + (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 */ -export async function getWorkoutTemplates(login: string): Promise { - const templates = await storage.getData(getKey(login)) ?? []; - return templates - .map(withEstimate) +export const getWorkoutTemplates = async (login: string): Promise => { + const stored = await loadTemplates(login); + return stored + .map(template => withDerived(template, stored)) .sort((a, b) => a.name.localeCompare(b.name, 'cs')); -} +}; /** Ověří a znormalizuje jednu položku šablony. */ -function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem { +const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => { const value = (raw ?? {}) as Record; + const kind = value.kind === WorkoutItemKind.SABLONA ? WorkoutItemKind.SABLONA : WorkoutItemKind.CVIK; + + if (kind === WorkoutItemKind.SABLONA) { + if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) { + throw new 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 BadRequestError(`Položka ${index + 1} nemá název`); @@ -53,15 +116,56 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem { throw new BadRequestError(`Položka '${name}' nemá platné množství`); } const rate = value.caloriesPer100Units; + const sets = value.sets; return { + kind, name, - unit: value.unit, + unit: value.unit as ActivityUnit, 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) } : {}), }; -} +}; + +/** + * 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: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => { + 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 BadRequestError("Šablona nemůže odkazovat sama na sebe"); + } + const nested = byId.get(item.templateRefId!); + if (!nested) { + throw new NotFoundError("Vnořená šablona nebyla nalezena"); + } + if (nested.items.some(isTemplateRef)) { + throw new 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 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. @@ -71,12 +175,12 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem { * @param name název šablony * @param items položky šablony */ -export async function saveWorkoutTemplate( +export const saveWorkoutTemplate = async ( login: string, id: unknown, name: unknown, items: unknown, -): Promise { +): Promise => { if (typeof name !== 'string' || !name.trim().length) { throw new BadRequestError("Nebyl předán název šablony"); } @@ -86,42 +190,49 @@ export async function saveWorkoutTemplate( if (items.length > MAX_ITEMS) { throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`); } + const normalized: StoredTemplate = { id: typeof id === 'string' && id.length ? id : 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 getWorkoutTemplates(login); -} +}; /** - * Smaže šablonu tréninku. + * 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 */ -export async function deleteWorkoutTemplate(login: string, id: unknown): Promise { +export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise => { if (typeof id !== 'string' || !id.length) { throw new BadRequestError("Nebyl předán identifikátor šablony"); } - let found = false; - await storage.updateData(getKey(login), current => { - found = (current ?? []).some(template => template.id === id); - return (current ?? []).filter(template => template.id !== id); - }); - if (!found) { + const all = await loadTemplates(login); + if (!all.some(template => template.id === id)) { throw new 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 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 getWorkoutTemplates(login); -} +}; /** - * Založí do zvoleného dne všechny položky šablony. + * 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, @@ -131,31 +242,82 @@ export async function deleteWorkoutTemplate(login: string, id: unknown): Promise * @param id identifikátor šablony * @param date den, do kterého se položky založí */ -export async function applyWorkoutTemplate(login: string, id: unknown, date: unknown): Promise { +export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise => { if (typeof id !== 'string' || !id.length) { throw new BadRequestError("Nebyl předán identifikátor šablony"); } if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); } - const template = (await getWorkoutTemplates(login)).find(item => item.id === id); + const all = await loadTemplates(login); + const template = all.find(item => item.id === id); if (!template) { throw new 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: ActivityEntry[] = template.items.map(item => ({ + const entries: ActivityEntry[] = exercises.map(item => ({ id: randomUUID(), date, - name: item.name, - unit: item.unit, - quantity: item.quantity, + name: item.name!, + unit: item.unit!, + quantity: item.quantity!, + ...(item.sets != null ? { sets: item.sets } : {}), ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), - calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), + calories: itemCalories(item), templateId: template.id, createdAt: now, })); await addActivities(login, date, entries); return date; -} +}; + +/** + * 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 + */ +export const createWorkoutTemplateFromActivities = async ( + login: string, + date: unknown, + name: unknown, + ids?: unknown, +): Promise => { + if (!isValidIsoDate(date)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (ids != null && !Array.isArray(ids)) { + throw new BadRequestError("Seznam aktivit musí být pole identifikátorů"); + } + const selected = ids == null ? undefined : new Set(ids.map(String)); + const activities = (await getActivityDay(login, date)).entries + .filter(entry => !selected || selected.has(entry.id)); + if (!activities.length) { + throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit"); + } + + const items = activities.map(entry => ({ + kind: 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 } : {}), + })); + + return saveWorkoutTemplate(login, undefined, name, items); +}; diff --git a/types/api.yml b/types/api.yml index 08cbf99..477b05c 100644 --- a/types/api.yml +++ b/types/api.yml @@ -30,6 +30,8 @@ paths: # Přehled dne (/api/day) /day: $ref: "./paths/day/overview.yml" + /day/sleep: + $ref: "./paths/day/sleep.yml" # Pohyb (/api/activities) /activities/add: @@ -48,6 +50,20 @@ paths: $ref: "./paths/workouts/delete.yml" /workouts/apply: $ref: "./paths/workouts/apply.yml" + /workouts/fromActivities: + $ref: "./paths/workouts/fromActivities.yml" + + # Šablony jídel (/api/mealTemplates) + /mealTemplates: + $ref: "./paths/mealTemplates/list.yml" + /mealTemplates/save: + $ref: "./paths/mealTemplates/save.yml" + /mealTemplates/delete: + $ref: "./paths/mealTemplates/delete.yml" + /mealTemplates/apply: + $ref: "./paths/mealTemplates/apply.yml" + /mealTemplates/fromMeal: + $ref: "./paths/mealTemplates/fromMeal.yml" # Kalorie (/api/calories) /calories/search: @@ -58,6 +74,8 @@ paths: $ref: "./paths/settings/settings.yml" /settings/basal: $ref: "./paths/settings/basal.yml" + /settings/profile: + $ref: "./paths/settings/profile.yml" # Importy (/api/import) /import/luncher: diff --git a/types/paths/day/sleep.yml b/types/paths/day/sleep.yml new file mode 100644 index 0000000..841c0e6 --- /dev/null +++ b/types/paths/day/sleep.yml @@ -0,0 +1,29 @@ +post: + operationId: saveSleep + summary: Uloží délku spánku pro zvolený den. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [date] + properties: + date: + type: string + format: date + sleepMinutes: + description: Délka spánku v minutách. Null nebo 0 záznam odstraní. + type: integer + nullable: true + responses: + "200": + description: Aktualizovaný přehled dne + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/DayOverview" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/mealTemplates/apply.yml b/types/paths/mealTemplates/apply.yml new file mode 100644 index 0000000..ad319e5 --- /dev/null +++ b/types/paths/mealTemplates/apply.yml @@ -0,0 +1,35 @@ +post: + operationId: applyMealTemplate + summary: Založí jídlo ze šablony do zvoleného dne. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, date] + properties: + id: + description: Identifikátor šablony + type: string + date: + description: Den, do kterého se jídlo založí + type: string + format: date + mealType: + description: Chod. Bez něj se použije chod ze šablony, jinak oběd. + allOf: + - $ref: "../../schemas/_index.yml#/MealType" + 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 + "404": + description: Šablona neexistuje diff --git a/types/paths/mealTemplates/delete.yml b/types/paths/mealTemplates/delete.yml new file mode 100644 index 0000000..1ba8d44 --- /dev/null +++ b/types/paths/mealTemplates/delete.yml @@ -0,0 +1,26 @@ +post: + operationId: deleteMealTemplate + summary: Smaže šablonu jídla. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + 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 diff --git a/types/paths/mealTemplates/fromMeal.yml b/types/paths/mealTemplates/fromMeal.yml new file mode 100644 index 0000000..d9f17c9 --- /dev/null +++ b/types/paths/mealTemplates/fromMeal.yml @@ -0,0 +1,33 @@ +post: + operationId: createMealTemplateFromMeal + summary: Vytvoří šablonu jídla z existujícího záznamu v přehledu dne. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, date] + properties: + id: + description: Identifikátor jídla, ze kterého se šablona vytvoří + type: string + date: + description: Den, ve kterém jídlo je + type: string + format: date + responses: + "200": + description: Aktualizovaný seznam šablon + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/MealTemplate" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel + "404": + description: Jídlo neexistuje diff --git a/types/paths/mealTemplates/list.yml b/types/paths/mealTemplates/list.yml new file mode 100644 index 0000000..b1c5631 --- /dev/null +++ b/types/paths/mealTemplates/list.yml @@ -0,0 +1,14 @@ +get: + operationId: getMealTemplates + summary: Vrátí šablony jídel přihlášeného uživatele. + responses: + "200": + description: Šablony seřazené podle názvu + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/MealTemplate" + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/mealTemplates/save.yml b/types/paths/mealTemplates/save.yml new file mode 100644 index 0000000..5a19259 --- /dev/null +++ b/types/paths/mealTemplates/save.yml @@ -0,0 +1,22 @@ +post: + operationId: saveMealTemplate + summary: Založí novou šablonu jídla, nebo upraví existující. + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/MealTemplateInput" + 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 diff --git a/types/paths/settings/profile.yml b/types/paths/settings/profile.yml new file mode 100644 index 0000000..f59b941 --- /dev/null +++ b/types/paths/settings/profile.yml @@ -0,0 +1,20 @@ +post: + operationId: saveProfile + summary: Uloží tělesné údaje uživatele a přepočte z nich klidový výdej. + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/PersonalProfile" + responses: + "200": + description: Aktualizované nastavení včetně dopočteného klidového výdeje + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/UserSettings" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/paths/workouts/fromActivities.yml b/types/paths/workouts/fromActivities.yml new file mode 100644 index 0000000..4c0cbe9 --- /dev/null +++ b/types/paths/workouts/fromActivities.yml @@ -0,0 +1,40 @@ +post: + operationId: createWorkoutTemplateFromActivities + summary: | + 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. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [date, name] + properties: + date: + description: Den, ze kterého se aktivity berou + type: string + format: date + name: + description: Název nové šablony + type: string + ids: + description: | + Identifikátory aktivit, které se do šablony dostanou. + Bez nich se použijí všechny aktivity dne. + type: array + items: + type: string + responses: + "200": + description: Aktualizovaný seznam šablon + content: + application/json: + schema: + type: array + items: + $ref: "../../schemas/_index.yml#/WorkoutTemplate" + "400": + description: Neplatná data + "401": + description: Neautentizovaný uživatel diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 2489f43..e2f69e5 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -444,8 +444,21 @@ UserSettings: $ref: "#/SourceRate" basalCalories: description: | - Klidový výdej (bazální metabolismus) v kcal za den. Bez něj se bilance dne - počítá jen jako jídlo proti pohybu a nejde o skutečný deficit. + Ručně zadaný klidový výdej v kcal za den. Má přednost před hodnotou + spočítanou z tělesných údajů. Bez obojího se bilance dne počítá jen + jako jídlo proti pohybu a nejde o skutečný deficit. + type: integer + profile: + $ref: "#/PersonalProfile" + computedBasalCalories: + description: | + Klidový výdej spočítaný z tělesných údajů rovnicí Mifflin–St Jeor. + Doplňuje server, při ukládání se ignoruje. Chybí, pokud profil není úplný. + type: integer + effectiveBasalCalories: + description: | + Klidový výdej, který se reálně použije v bilanci dne — ručně zadaný, + jinak spočítaný z profilu. Doplňuje server. type: integer ActivityUnit: @@ -482,7 +495,15 @@ ActivityEntry: unit: $ref: "#/ActivityUnit" quantity: - description: Množství v dané jednotce — počet kroků, minut, kilometrů nebo opakování + description: | + Množství v dané jednotce na jednu sérii — počet kroků, minut, kilometrů + nebo opakování. + type: integer + sets: + description: | + Počet sérií. Celkové množství je sets × quantity, z něj se počítají kalorie. + Chybí-li, počítá se s jednou sérií. Drží se odděleně od množství, protože + 3 × 40 opakování s pauzami je jiný trénink než 120 opakování v kuse. type: integer caloriesPer100Units: description: | @@ -528,8 +549,12 @@ ActivityInput: unit: $ref: "#/ActivityUnit" quantity: - description: Množství v dané jednotce + description: Množství v dané jednotce na jednu sérii type: integer + sets: + description: Počet sérií. Nevyplněno znamená jednu. + type: integer + nullable: true caloriesPer100Units: description: Spálené kcal na 100 jednotek type: integer @@ -564,21 +589,38 @@ ActivityDay: type: integer WorkoutTemplateItem: - description: Jedna položka šablony tréninku + description: | + Jedna položka šablony tréninku — buď konkrétní cvik, nebo odkaz na jinou + šablonu, jejíž cviky se při použití rozbalí. type: object additionalProperties: false required: - - name - - unit - - quantity + - kind properties: + kind: + $ref: "#/WorkoutItemKind" name: + description: Název cviku. Povinný u položky druhu CVIK. type: string unit: $ref: "#/ActivityUnit" quantity: + description: Množství v dané jednotce na jednu sérii. Povinné u položky druhu CVIK. + type: integer + sets: + description: Počet sérií. Nevyplněno znamená jednu. type: integer caloriesPer100Units: + description: Spálené kcal na 100 jednotek + type: integer + templateRefId: + description: Identifikátor vnořené šablony. Povinný u položky druhu SABLONA. + type: string + templateRefName: + description: Název vnořené šablony. Doplňuje server, při ukládání se ignoruje. + type: string + templateRefCalories: + description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje. type: integer WorkoutTemplate: @@ -592,6 +634,8 @@ WorkoutTemplate: - name - items - estimatedCalories + - hasNested + - usedByCount properties: id: type: string @@ -603,7 +647,15 @@ WorkoutTemplate: items: $ref: "#/WorkoutTemplateItem" estimatedCalories: - description: Odhad spálených kcal za celou šablonu + description: Odhad spálených kcal za celou šablonu včetně vnořených + type: integer + hasNested: + description: | + True, pokud šablona obsahuje odkaz na jinou šablonu. Takovou šablonu + už nelze vnořit do další — zanoření je povolené jen na jednu úroveň. + type: boolean + usedByCount: + description: Kolik jiných šablon tuto šablonu používá type: integer EnergyBalance: @@ -662,3 +714,129 @@ DayOverview: $ref: "#/ActivityDay" energy: $ref: "#/EnergyBalance" + sleepMinutes: + description: | + Délka spánku v minutách. Eviduje se jako kontext dne — do energetické + bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin + včetně spánku a žádná standardní rovnice délku spánku nezohledňuje. + type: integer + +Pohlavi: + description: Pohlaví — vstupuje do výpočtu bazálního metabolismu + type: string + enum: + - MUZ + - ZENA + +PersonalProfile: + description: | + Tělesné údaje uživatele. Slouží k výpočtu bazálního metabolismu + (klidového výdeje) rovnicí Mifflin–St Jeor. + type: object + additionalProperties: false + properties: + sex: + $ref: "#/Pohlavi" + birthYear: + description: Rok narození, ze kterého se odvozuje věk + type: integer + heightCm: + description: Výška v centimetrech + type: integer + weightGrams: + description: Hmotnost v gramech. Celé číslo, aby se počítalo bez desetinných chyb. + type: integer + +WorkoutItemKind: + description: | + Druh položky šablony — buď přímo cvik, nebo odkaz na jinou šablonu. + Zanoření je povolené jen na jednu úroveň. + type: string + enum: + - CVIK + - SABLONA + +MealTemplate: + description: | + Předpřipravené jídlo. Použitím se založí do zvoleného dne, takže opakovaně + jedené jídlo není potřeba zadávat znovu. Na rozdíl od šablon tréninků se + šablony jídel neskládají z jiných — jsou to ploché záznamy. + type: object + additionalProperties: false + required: + - id + - name + properties: + id: + type: string + name: + description: Název jídla + type: string + source: + description: Zdroj jídla (podnik, obchod, "doma", ...) + type: string + mealType: + description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd. + allOf: + - $ref: "#/MealType" + calories: + description: | + Energetická hodnota celé porce v kcal. Použije se, když jídlo nemá + vyplněnou gramáž a energii na 100 g. + type: integer + caloriesPer100g: + description: Energetická hodnota na 100 g v kcal + type: integer + weight: + description: Obvyklá gramáž porce v gramech + type: integer + price: + description: Obvyklá cena v haléřích + type: integer + pricePer100g: + description: Cena za 100 g v haléřích + type: integer + note: + description: | + Poznámka nebo postup přípravy. U domácích jídel sem lze zapsat recept + volným textem; strukturované suroviny mohou přibýt později. + type: string + +MealTemplateInput: + description: | + Data pro založení nebo úpravu šablony jídla. Oproti {@link MealTemplate} je + `id` volitelné — bez něj se šablona založí jako nová. + type: object + additionalProperties: false + required: + - name + properties: + id: + description: Identifikátor upravované šablony. Bez něj se založí nová. + type: string + name: + type: string + source: + type: string + nullable: true + mealType: + allOf: + - $ref: "#/MealType" + calories: + type: integer + nullable: true + caloriesPer100g: + type: integer + nullable: true + weight: + type: integer + nullable: true + price: + type: integer + nullable: true + pricePer100g: + type: integer + nullable: true + note: + type: string + nullable: true