From 7641e26c9f455fa5a2f524f193f911863c8c3923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Thu, 22 Oct 2026 12:11:05 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20graf=20db=20feat:=20workout=20plans=20?= =?UTF-8?q?(voln=C3=BD,=20fixn=C3=AD,=20plovouc=C3=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 72 ++- README.md | 233 +++++++++- client/src/App.scss | 10 + client/src/Utils.tsx | 5 + client/src/components/ActivityRow.tsx | 11 +- .../components/PendingReplacementsPanel.tsx | 82 ++++ client/src/components/PlannedWorkoutCard.tsx | 140 ++++++ client/src/components/RowActions.tsx | 4 +- client/src/components/WorkoutItemsEditor.tsx | 137 ++++++ client/src/components/WorkoutItemsList.tsx | 42 ++ .../src/components/WorkoutPlanDayEditor.tsx | 102 ++++ client/src/components/WorkoutTemplateCard.tsx | 86 ++++ client/src/components/WorkoutTemplateForm.tsx | 270 +++++++++++ .../src/components/modals/ActivityModal.tsx | 2 +- .../components/modals/ApplyWorkoutModal.tsx | 52 ++- .../modals/SaveWorkoutTemplateModal.tsx | 8 +- client/src/enums.ts | 25 + client/src/pages/ActivityStatsPanel.tsx | 85 ++-- client/src/pages/DayPage.scss | 118 +++++ client/src/pages/DayPage.tsx | 33 +- client/src/pages/StatsPage.scss | 9 + client/src/pages/StatsPage.tsx | 2 +- client/src/pages/WorkoutTemplatesPanel.tsx | 383 ++-------------- client/src/pages/activityChartSeries.ts | 65 ++- client/src/pages/templates.scss | 142 ++++++ client/src/pages/workoutDraft.ts | 111 +++++ server/src/activities.ts | 15 +- server/src/dayOverview.ts | 10 +- server/src/routes/workoutRoutes.ts | 21 +- server/src/statsService.ts | 127 +++-- server/src/tests/activities.test.ts | 6 +- server/src/tests/toTemplate.test.ts | 2 +- server/src/tests/workoutComposition.test.ts | 6 +- server/src/tests/workoutPlanSchedule.test.ts | 409 +++++++++++++++++ server/src/utils.ts | 25 + server/src/workoutPlans.ts | 115 ++++- server/src/workoutSchedule.ts | 264 +++++++++++ server/src/workouts.ts | 434 ++++++++++++++---- types/api.yml | 2 + types/paths/workouts/apply.yml | 16 +- types/paths/workouts/replace.yml | 40 ++ types/paths/workouts/save.yml | 15 +- types/schemas/_index.yml | 322 ++++++++++++- 43 files changed, 3459 insertions(+), 599 deletions(-) create mode 100644 client/src/components/PendingReplacementsPanel.tsx create mode 100644 client/src/components/PlannedWorkoutCard.tsx create mode 100644 client/src/components/WorkoutItemsEditor.tsx create mode 100644 client/src/components/WorkoutItemsList.tsx create mode 100644 client/src/components/WorkoutPlanDayEditor.tsx create mode 100644 client/src/components/WorkoutTemplateCard.tsx create mode 100644 client/src/components/WorkoutTemplateForm.tsx create mode 100644 client/src/pages/workoutDraft.ts create mode 100644 server/src/tests/workoutPlanSchedule.test.ts create mode 100644 server/src/workoutSchedule.ts create mode 100644 types/paths/workouts/replace.yml diff --git a/CLAUDE.md b/CLAUDE.md index 9777868..37fb6f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **Domain:** `meals.ts` (jídlo a historie jídel), `mealGroups.ts` (seznam jídel — skupiny podle názvu a zdroje, doplnění a přejmenování), `mealPhotos.ts` (fotky jídel), `nutrients.ts` (živiny), `mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony - tréninků), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts` + tréninků a plány), `workoutSchedule.ts` (co plán navrhuje na den, náhrady + vynechaných cviků), `workoutPlans.ts` (záznamy použití šablon), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts` (tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace), `luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts` @@ -76,11 +77,13 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej), `MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje, hromadným doplněním údajů a přejmenováním), - `WorkoutsPage` (`/treninky` — šablony tréninků), `ProfilePage` (Profil — ukazatele, + `WorkoutsPage` (`/treninky` — plány a šablony tréninků), `ProfilePage` (Profil — ukazatele, vývoj hmotnosti, tělesné údaje, cíle a klidový výdej), `StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely `IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`) -- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset) +- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset, + PlannedWorkoutCard a PendingReplacementsPanel — návrh tréninku a náhrady v přehledu dne, + WorkoutTemplateForm/WorkoutTemplateCard — šablony a plány) a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal — výběr z historie a receptů, SettingsModal, ImportModal, SpinWheelModal — kolo štěstí) - **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků, @@ -186,7 +189,7 @@ trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série výchozí stav a neukládá se. Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` — -z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. Záznamy ve dni +z aktivit odcvičeného dne (všech, nebo vybraných) vznikne volná šablona. Záznamy ve dni zůstanou beze změny. U jídla nic takového není, opakovaně jedená jídla se nabízejí z historie (viz Historie jídel a recepty). @@ -195,17 +198,49 @@ je jen informace o tréninku, kalorie **nemění** — žádný jednoduchý vzor spotřebu podle zátěže nedává, takže by šlo o vymyšlené číslo. Kdo chce, promítne ji do sazby na 100 jednotek. -`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď -cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se -při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou -samostatné, takže úprava založené položky šablonu nemění. +`WorkoutTemplate` (`workouts.ts`) má typ (`WorkoutTemplateType`): -**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`. +- **VOLNY** — pojmenovaný seznam položek, do dne se zakládá ručně. Výchozí typ, + nezapisuje se; šablony uložené dřív, než typy přibyly, jsou volné. +- **FIXNI** — plán s dny (`days`) navázanými na den v týdnu (`weekday` 1–7). +- **PLOVOUCI** — plán jako sekvence dnů zakončená dnem volna (volno se jako den + nezapisuje, plyne z typu). + +Položka je buď cvik (`kind: CVIK`), nebo odkaz na volnou šablonu (`kind: SABLONA`), +jejíž cviky se při použití rozbalí (`expandExercises`). Použitím vzniknou běžné +aktivity s vazbou `templateId` (u plánu i `planDayId`) — jsou samostatné, takže +úprava založené položky šablonu nemění. Plán má navíc `replaceWithinDays` +(lhůta pro náhradu vynechaných cviků) a `startDate`. Dny plánu mají stálé `id`, +podle kterého se v historii pozná, který den se kdy cvičil, i po úpravě plánu. + +**Zanoření volných šablon je povolené jen na jednu úroveň** a `checkReferences` +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ž jiná volná šablona používá. Bez druhé +kontroly by druhá úroveň vznikla oklikou přes úpravu. Den plánu úroveň nepřidává, +smí odkázat na libovolnou volnou šablonu; plán sám do ničeho vnořit nejde a volná +šablona, kterou něco používá, se nemůže stát plánem. Smazat nelze šablonu, kterou +skládá jiná nebo kterou má plán v některém dni — 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`. + +**Návrh tréninku** (`getPlannedWorkouts` ve `workoutSchedule.ts`, v přehledu dne +`DayOverview.plannedWorkouts`): fixní plán navrhne den podle dne v týdnu. +Plovoucí prochází záznamy použití od `startDate` jako stavový automat +(`advanceDone`, `advanceGap`): odcvičený den posune sekvenci, den bez tréninku je +řádné volno (když je na řadě), **posunuté volno** (uprostřed rozběhnutého cyklu, +jednou za cyklus — po posledním dni se pak pokračuje rovnou od začátku), jinak +trénink jen odsune. Budoucí dny se počítají, jako by se navržený trénink splnil. +Návrh se potvrdí přes `applyWorkoutTemplate` s `dayId` a `doneIndexes` +(pořadí v rozbaleném seznamu cviků); odškrtnuté cviky se zapíšou do záznamu bez +`entryId`, jako vynechané. Den jde potvrdit i celý jako vynechaný. + +**Náhrady** (`getPendingReplacements`, `replaceSkippedExercise`, +`POST /api/workouts/replace`): vynechaný cvik (bez aktivity a bez náhrady) se +nabízí v přehledu dne po dobu `replaceWithinDays` ze záznamu (lhůta, jaká platila +v den vynechání, nejvýš 14). Náhrada založí aktivitu s `replacesDate` a k cviku +v záznamu původního dne zapíše `replacement` (datum a `entryId`). Smazání náhradní +aktivity vazbu zruší, přesun jí změní datum (`updateReplacementLink`). Náhrada ani +den volna plovoucí plán neposouvají — posouvá ho jen záznam dne plánu. `buildEnergyBalance` v `dayOverview.ts` počítá: `bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit. @@ -251,8 +286,15 @@ jednotlivé cviky s `entryId` aktivity, která z nich vznikla; podle něj `analyzeDay` u každého dne pozná cvik **odebraný** (aktivita chybí) a **upravený** (jiné množství, série nebo násobek) — podle názvu by to nešlo, šablona může mít stejný cvik dvakrát. Smazání všech cviků šablony ve dni `dropOrphanedPlans` -bere jako vrácené použití a plán zahodí. Aktivity ze šablony bez plánu (cvik +bere jako vrácené použití a plán zahodí — kromě záznamu vynechaného celého dne +plánu a záznamu, jehož cvik už má náhradu. Aktivity ze šablony bez plánu (cvik přesunutý z jiného dne) se počítají jako splněné přesně dle plánu. +Plán se ve statistikách dělí po dnech (klíč `templateId:planDayId`). Nahrazený +cvik je odchylka **NAHRAZENO** s `replacementDate` a počítá se jako provedený; +náhrada sama je v `ActivityDayStats.replacements` dne, kdy se odcvičila, a její +kalorie jdou do skutečnosti dne plánu, ze kterého pochází. Graf u takového dne +místo „!" ukáže šipku na den náhrady. Dny volna ani necvičené dny plánu se do +statistik nijak nepromítají. **Statistiky příjmu** (`getIntakeStats`, `GET /api/stats/intake`) jsou protějšek výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej diff --git a/README.md b/README.md index 94c0727..8a1358d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,192 @@ JWT_SECRET=... docker compose up --build Produkčně běží aplikace nad Redisem, ve vývoji nad JSON souborem (`server/data/db.json`). Viz `server/.env.template`. +## Datový model + +Úložiště je klíč–hodnota (JSON soubor ve vývoji, Redis v produkci). „Tabulka" je +tedy typ klíče a hodnotou je pod ním buď celé pole záznamů, nebo jeden objekt. +Klíče začínají typem a přihlašovacím jménem uživatele, data jednotlivých +uživatelů se nemíchají. + +| Klíč | Hodnota | +|---|---| +| `meals::` | pole jídel dne (`MealEntry`) | +| `mealTemplates:` | pole receptů (`MealTemplate`) | +| `mealPhotos:` | rejstřík fotek: klíč jídla (název + zdroj) → pole náhledů | +| `mealPhoto::` | plný obrázek jedné fotky (data URL) | +| `calorieLibrary:` | vlastní knihovna kalorií: znormalizovaný název → záznam | +| `settings:` | nastavení, tělesné údaje a cíle | +| `weights:` | vážení: datum → hmotnost v gramech | +| `sleep::` | délka spánku v minutách | +| `activities::` | pole aktivit dne (`ActivityEntry`) | +| `workouts:` | pole šablon tréninků — volné šablony i plány | +| `workoutPlans::` | pole záznamů o použití šablon a dnů plánu v daném dni | + +Výchozí hodnoty se do úložiště nezapisují (jedna série, násobek 1, volný typ +šablony, slaná chuť receptu) a dopočítávaná pole (součty, odhady kalorií, +ukazatele) se počítají při čtení. + +```mermaid +erDiagram + MEAL_ENTRY { + string id PK + date date + enum mealType "SNIDANE, OBED, ..." + string name + string source "podnik / obchod" + int price "haléře" + int weight "g" + int pricePer100g "haléře" + int calories "kcal za porci" + int caloriesPer100g + object nutrientsPer100g "g na 100 g, na desetiny" + decimal multiplier + string note + string templateId FK "recept" + string importSource + string importKey "duplicity importu" + datetime createdAt + datetime updatedAt + } + MEAL_TEMPLATE { + string id PK + string name + string source + string category "typ: polévka, omáčka..." + enum flavor "SLANE, SLADKE" + int servings + int cookedWeight "g" + int prepMinutes + int weight "porce, dopočteno" + int calories "porce, dopočteno" + int price "porce, dopočteno" + string note "postup v Markdownu" + array sideDishIds FK "přílohy" + array similarIds FK "podobné" + } + RECIPE_INGREDIENT { + string name + decimal amount "v jiné jednotce než g" + enum unit "KS, ML, L, LZICE..." + int weight "g" + int caloriesPer100g + int calories + int price + } + MEAL_PHOTO_INDEX { + string mealKey PK "název + zdroj" + string id FK + string thumbnail "data URL ~240 px" + datetime createdAt + } + MEAL_PHOTO { + string id PK + string image "data URL ~1600 px" + } + CALORIE_LIBRARY { + string normalizedName PK + string name + int caloriesPer100g + int uses + datetime updatedAt + } + SETTINGS { + array sourceRates "podnik a sazba za 100 g" + int basalCalories "ručně zadaný klidový výdej" + object profile "pohlaví, rok narození, výška, obvody" + object goals "tempo, cílová hmotnost, bílkoviny/kg" + } + WEIGHTS { + date date PK + int weightGrams + } + SLEEP { + date date PK + int minutes + } + ACTIVITY_ENTRY { + string id PK + date date + string name + enum unit "KROKY, MINUTY, SEKUNDY, KM, OPAKOVANI" + int quantity "na sérii" + int sets + int caloriesPer100Units + int calories "za jedno provedení" + int extraWeightGrams + decimal multiplier + string note + string templateId FK "šablona / plán" + string planDayId FK "den plánu" + date replacesDate "nahrazuje vynechaný cvik z tohoto dne" + datetime createdAt + datetime updatedAt + } + WORKOUT_TEMPLATE { + string id PK + string name + enum type "VOLNY (výchozí), FIXNI, PLOVOUCI" + int replaceWithinDays "jen plán" + date startDate "jen plán" + } + WORKOUT_PLAN_DAY { + string id PK + string name + int weekday "1-7, jen fixní plán" + } + WORKOUT_TEMPLATE_ITEM { + enum kind "CVIK, SABLONA" + string name + enum unit + int quantity + int sets + int caloriesPer100Units + int extraWeightGrams + string templateRefId FK "volná šablona" + } + WORKOUT_PLAN { + string id PK + string templateId FK + string templateName "v době použití" + string planDayId FK + string planDayName "v době použití" + int replaceWithinDays "v době použití" + datetime appliedAt + } + WORKOUT_PLAN_ITEM { + string entryId FK "chybí = vynecháno" + string name + enum unit + int quantity + int sets + int caloriesPer100Units + int calories + object replacement "date + entryId náhrady" + } + + MEAL_ENTRY }o--o| MEAL_TEMPLATE : "templateId" + MEAL_TEMPLATE ||--|{ RECIPE_INGREDIENT : "ingredients" + MEAL_TEMPLATE }o--o{ MEAL_TEMPLATE : "přílohy, podobné" + MEAL_PHOTO_INDEX ||--|| MEAL_PHOTO : "id" + MEAL_PHOTO_INDEX }o..o{ MEAL_ENTRY : "název + zdroj" + CALORIE_LIBRARY }o..o{ MEAL_ENTRY : "znormalizovaný název" + WORKOUT_TEMPLATE ||--o{ WORKOUT_TEMPLATE_ITEM : "items (volná)" + WORKOUT_TEMPLATE ||--o{ WORKOUT_PLAN_DAY : "days (plán)" + WORKOUT_PLAN_DAY ||--|{ WORKOUT_TEMPLATE_ITEM : "items" + WORKOUT_TEMPLATE_ITEM }o--o| WORKOUT_TEMPLATE : "templateRefId" + ACTIVITY_ENTRY }o--o| WORKOUT_TEMPLATE : "templateId" + ACTIVITY_ENTRY }o--o| WORKOUT_PLAN_DAY : "planDayId" + WORKOUT_PLAN }o--|| WORKOUT_TEMPLATE : "templateId" + WORKOUT_PLAN }o--o| WORKOUT_PLAN_DAY : "planDayId" + WORKOUT_PLAN ||--|{ WORKOUT_PLAN_ITEM : "items" + WORKOUT_PLAN_ITEM |o--o| ACTIVITY_ENTRY : "entryId (odcvičeno)" + WORKOUT_PLAN_ITEM |o--o| ACTIVITY_ENTRY : "replacement (náhrada)" +``` + +Vazby na šablony a plány jsou volné — smazaná šablona v historii zůstane pod +názvem uloženým v `WORKOUT_PLAN`. Záznam použití (`workoutPlans`) je u plánu zároveň +evidence, který den plánu se kdy odcvičil; podle ní se počítá, co je na řadě. + ## Den: příjem a výdej Přehled dne má dvě záložky: @@ -75,25 +261,42 @@ 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)`. -## Šablony tréninků +## Tréninky a plány -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á. +Na stránce **Tréninky** jsou tři typy: + +- **Plovoucí plán** — sekvence tréninkových dnů (Den 1, Den 2, Den 3…), po které + následuje den volna a cyklus se opakuje. Na kalendář navázaný není: další den + přijde na řadu, až se splní předchozí. Den volna jde v rámci cyklu jednou + **posunout dopředu** — když uprostřed cyklu den necvičíte, bere se to jako volno + a po posledním dni se pokračuje rovnou od začátku (Den 1, Den 2, volno, Den 3, + Den 1…). Další necvičený den trénink jen odsune. +- **Fixní plán** — pevné dny v týdnu (pondělí nohy, středa běh…), ostatní dny jsou volno. +- **Volný trénink** — seznam cviků na kdykoli (rozcvička, protažení). Jde ho vložit + do dne plánu i do jiného volného tréninku; vkládání je jen na jednu úroveň. + +Plán se založí jednou a **přehled dne** pak sám navrhne, co je ten den na řadě. +Po odcvičení stačí návrh potvrdit; co se nestihlo, se odškrtne a zapíše jako +vynechané. Nad rámec plánu se ručně přidávají jen „unikátnosti" — chůze, kroky, +jednorázový běh. Jiný den plánu, než který je na řadě, nebo volný trénink jde +založit tlačítkem *Z tréninků*. + +U plánu jde nastavit **Nahraditelné během N dní**: vynechaný cvik (odškrtnutý při +potvrzení nebo smazaný ze dne) se pak v následujících N dnech nabízí v přehledu +dne k náhradě. Ve statistikách výdeje se u takového dne místo vykřičníku ukáže +šipka na den náhrady. Dny volna ani necvičené dny se ve statistikách nijak +nezapisují — zobrazuje se jen to, co proběhlo. + +Plán i trénink jde kdykoli **vylepšit** — přidat cviky, dny, zvýšit zátěž nebo +počet opakování. Historie zůstává, jak byla: každé použití si pamatuje, co plán +v ten den sliboval. Založené položky jsou samostatné aktivity, takže je jde v dni doupravit, aniž by -se změnila šablona. +se změnil plán. Volný trénink jde vytvořit i **opačným směrem** — z toho, co +už máte v dni zapsané, tlačítkem *Uložit jako trénink* v záložce Výdej. -Š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. +Recepty (vařená jídla se surovinami a postupem) jsou rozšířením jídla v seznamu +jídel na stránce **Jídla**. ## Osobní údaje diff --git a/client/src/App.scss b/client/src/App.scss index a1b43bf..f34a4a2 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -796,6 +796,16 @@ body { } } +// Název plánu nad jeho dny ve výběru šablony +.workout-plan-title { + margin: 16px 0 2px; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ft-text-secondary); +} + // Ikona u skládané šablony ve výběru .workout-nested-icon { margin-left: 8px; diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index 35b1f6f..96ede9a 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -73,6 +73,11 @@ export function getDayOfWeek(date: string): string { return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date)); } +const SHORT_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' }); + +/** Vrátí krátký zápis data s dnem v týdnu, např. "po 3. 3." — pro odkazy mezi blízkými dny. */ +export const formatShortDate = (date: string): string => SHORT_DATE_FORMAT.format(parseIsoDate(date)); + /** Vrátí čitelný zápis data, např. "3. března 2025". */ export function getHumanDate(date: string): string { return HUMAN_DATE_FORMAT.format(parseIsoDate(date)); diff --git a/client/src/components/ActivityRow.tsx b/client/src/components/ActivityRow.tsx index eb2e37d..de82102 100644 --- a/client/src/components/ActivityRow.tsx +++ b/client/src/components/ActivityRow.tsx @@ -1,6 +1,6 @@ import { ActivityEntry } from "../../../types"; import { ACTIVITY_UNIT_SHORT } from "../enums"; -import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils"; +import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets, formatShortDate } from "../Utils"; import RowActions from "./RowActions"; import MultiplierControl from "./MultiplierControl"; @@ -24,7 +24,14 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChan {!!entry.extraWeightGrams && ( {formatExtraWeight(entry.extraWeightGrams)} )} - {entry.templateId && ze šablony} + {entry.replacesDate && ( + + náhrada za {formatShortDate(entry.replacesDate)} + + )} + {entry.templateId && !entry.replacesDate && ( + {entry.planDayId ? 'z plánu' : 'z tréninku'} + )} {entry.note &&
{entry.note}
} diff --git a/client/src/components/PendingReplacementsPanel.tsx b/client/src/components/PendingReplacementsPanel.tsx new file mode 100644 index 0000000..3229e58 --- /dev/null +++ b/client/src/components/PendingReplacementsPanel.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { Alert, Button } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faRotate } from "@fortawesome/free-solid-svg-icons"; +import { DayOverview, PendingReplacement, replaceSkippedExercise } from "../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../enums"; +import { formatCalories, formatQuantityWithSets, formatShortDate } from "../Utils"; + +type Props = { + replacements: PendingReplacement[], + date: string, + /** Zavolá se s aktualizovaným přehledem dne po náhradě. */ + onReplaced: (day: DayOverview) => void, +}; + +/** Klíč náhrady — záznam použití plánu a pořadí cviku v něm. */ +const getKey = (replacement: PendingReplacement): string => `${replacement.planId}:${replacement.itemIndex}`; + +/** + * Cviky vynechané v předchozích dnech plánu, které jde v tento den ještě + * nahradit. Náhrada založí cvik do dne a ve statistikách se u původního + * vynechání ukáže odkaz na ni. + */ +const PendingReplacementsPanel = ({ replacements, date, onReplaced }: Readonly) => { + const [busyKey, setBusyKey] = useState(); + const [error, setError] = useState(); + + if (!replacements.length) return null; + + const replace = async (replacement: PendingReplacement) => { + setBusyKey(getKey(replacement)); + setError(undefined); + const response = await replaceSkippedExercise({ + body: { + date, + sourceDate: replacement.sourceDate, + planId: replacement.planId, + itemIndex: replacement.itemIndex, + }, + }); + setBusyKey(undefined); + if (response.data) { + onReplaced(response.data); + } else { + setError((response.error as { error?: string })?.error ?? 'Cvik se nepodařilo nahradit'); + } + }; + + return ( +
+

+ Vynechané cviky k náhradě +

+ {error && {error}} + {replacements.map(replacement => ( +
+
+
{replacement.item.name}
+
+ {formatQuantityWithSets( + replacement.item.quantity, ACTIVITY_UNIT_SHORT[replacement.item.unit], replacement.item.sets)} + {' · '}vynecháno {formatShortDate(replacement.sourceDate)} + {' '}({replacement.templateName}{replacement.dayName ? ` · ${replacement.dayName}` : ''}) + {' · '}nahradit do {formatShortDate(replacement.deadline)} +
+
+ {formatCalories(replacement.item.calories)} + +
+ ))} +
+ ); +}; + +export default PendingReplacementsPanel; diff --git a/client/src/components/PlannedWorkoutCard.tsx b/client/src/components/PlannedWorkoutCard.tsx new file mode 100644 index 0000000..07f13b7 --- /dev/null +++ b/client/src/components/PlannedWorkoutCard.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { Alert, Button, Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBed, faCalendarCheck, faCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { + DayOverview, PlannedWorkout, PlannedWorkoutStatus, WorkoutTemplateType, applyWorkoutTemplate, +} from "../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../enums"; +import { formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils"; + +type Props = { + planned: PlannedWorkout, + date: string, + /** Zavolá se s aktualizovaným přehledem dne po potvrzení. */ + onConfirmed: (day: DayOverview) => void, +}; + +/** Co se stane, když se dnes necvičí — plovoucí plán volno posouvá, fixní ne. */ +const getSkipHint = (planned: PlannedWorkout): string => { + if (planned.type === WorkoutTemplateType.FIXNI) { + return 'Fixní plán se drží dnů v týdnu — necvičený den propadne.'; + } + return planned.restAvailable + ? 'Když dnes necvičíte, bere se to jako posunuté volno a cyklus pak pokračuje bez další mezery.' + : 'Když dnes necvičíte, trénink se jen odsune na další den.'; +}; + +/** + * Návrh tréninku z plánu na daný den. Cviky jsou předem zaškrtnuté — po + * odcvičení stačí potvrdit, co se nestihlo, se odškrtne a zapíše jako + * vynechané. Hotový den a den volna se ukážou jen jedním řádkem. + */ +const PlannedWorkoutCard = ({ planned, date, onConfirmed }: Readonly) => { + const [done, setDone] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + setDone(planned.items.map(() => true)); + setError(undefined); + }, [planned]); + + if (planned.status === PlannedWorkoutStatus.SPLNENO) { + return ( +
+ + {planned.templateName} · {planned.dayName} — splněno +
+ ); + } + + if (planned.status === PlannedWorkoutStatus.VOLNO) { + return ( +
+ + {planned.templateName} — podle plánu den volna +
+ ); + } + + const doneCount = done.filter(item => item).length; + const plannedCalories = planned.items.reduce((sum, item, index) => sum + (done[index] ? item.calories : 0), 0); + + const confirm = async () => { + setBusy(true); + setError(undefined); + const response = await applyWorkoutTemplate({ + body: { + id: planned.templateId, + date, + dayId: planned.dayId, + doneIndexes: done.flatMap((item, index) => (item ? [index] : [])), + }, + }); + setBusy(false); + if (response.data) { + onConfirmed(response.data); + } else { + setError((response.error as { error?: string })?.error ?? 'Trénink se nepodařilo potvrdit'); + } + }; + + const getConfirmLabel = (): string => { + if (!doneCount) return 'Vynechat celý den'; + if (doneCount === planned.items.length) return 'Splněno'; + return `Splněno ${doneCount} z ${planned.items.length}`; + }; + + return ( +
+
+ +
+ {planned.dayName} + {planned.templateName} +
+ {formatCalories(plannedCalories)} +
+ + {error && {error}} + + {planned.items.map((item, index) => ( + // Cviky jsou rozbalený seznam dne plánu, index je i to, co se posílá serveru +
+ setDone(current => current.map((value, i) => (i === index ? !value : value)))} + /> + {!!item.extraWeightGrams && ( + {formatExtraWeight(item.extraWeightGrams)} + )} + + {formatQuantityWithSets(item.quantity, ACTIVITY_UNIT_SHORT[item.unit], item.sets)} + + {formatCalories(item.calories)} +
+ ))} + +
+ + {doneCount < planned.items.length + ? 'Odškrtnuté cviky se zapíšou jako vynechané.' + : getSkipHint(planned)} + + +
+
+ ); +}; + +export default PlannedWorkoutCard; diff --git a/client/src/components/RowActions.tsx b/client/src/components/RowActions.tsx index 7cec17c..77773ee 100644 --- a/client/src/components/RowActions.tsx +++ b/client/src/components/RowActions.tsx @@ -19,8 +19,8 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readon diff --git a/client/src/components/WorkoutItemsEditor.tsx b/client/src/components/WorkoutItemsEditor.tsx new file mode 100644 index 0000000..f1a92f1 --- /dev/null +++ b/client/src/components/WorkoutItemsEditor.tsx @@ -0,0 +1,137 @@ +import { Button, Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faLayerGroup, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { ActivityUnit, WorkoutItemKind, WorkoutTemplate } from "../../../types"; +import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../enums"; +import { DraftItem, emptyExercise, emptyReference } from "../pages/workoutDraft"; + +/** Seznam známých cviků pro našeptávání názvu; v DOM je jednou pro celou stránku šablon. */ +export const ACTIVITY_PRESETS_LIST_ID = 'activity-presets-templates'; + +type Props = { + items: DraftItem[], + onChange: (items: DraftItem[]) => void, + /** Šablony, které lze zařadit jako vnořené. */ + nestable: WorkoutTemplate[], + /** Proč nejde vnořit šablonu, i když nějaké jsou — tlačítko se pak vypne. */ + nestBlockedReason?: string, +}; + +/** + * Úprava položek šablony nebo dne plánu — cviky a odkazy na volné šablony. + * Výběr známého cviku předvyplní jednotku i orientační sazbu. + */ +const WorkoutItemsEditor = ({ items, onChange, nestable, nestBlockedReason }: Readonly) => { + const updateItem = (index: number, changes: Partial) => + onChange(items.map((item, i) => (i === index ? { ...item, ...changes } : item))); + + 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 }); + }; + + const nestTitle = nestBlockedReason + ?? (nestable.length ? undefined : 'Není k dispozici žádný volný trénink, který by šlo vložit'); + + return ( + <> + {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, '') })} + /> + updateItem(index, { extraWeight: event.target.value })} + /> + + )} + +
+ ))} +
+ + +
+ + ); +}; + +export default WorkoutItemsEditor; diff --git a/client/src/components/WorkoutItemsList.tsx b/client/src/components/WorkoutItemsList.tsx new file mode 100644 index 0000000..67e0fc6 --- /dev/null +++ b/client/src/components/WorkoutItemsList.tsx @@ -0,0 +1,42 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; +import { WorkoutItemKind, WorkoutTemplateItem } from "../../../types"; +import { ACTIVITY_UNIT_SHORT } from "../enums"; +import { deriveActivityCalories, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils"; + +type Props = { + items: WorkoutTemplateItem[], +}; + +/** Výpis položek šablony nebo dne plánu — cviky s množstvím a odkazy na vnořené šablony. */ +const WorkoutItemsList = ({ items }: Readonly) => ( +
    + {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ý trénink'} + {formatCalories(item.templateRefCalories)} + + ) : ( + <> + {item.name} + + {formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)} + + {!!item.extraWeightGrams && ( + {formatExtraWeight(item.extraWeightGrams)} + )} + + {formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))} + + + )} +
  • + ))} +
+); + +export default WorkoutItemsList; diff --git a/client/src/components/WorkoutPlanDayEditor.tsx b/client/src/components/WorkoutPlanDayEditor.tsx new file mode 100644 index 0000000..e7d0583 --- /dev/null +++ b/client/src/components/WorkoutPlanDayEditor.tsx @@ -0,0 +1,102 @@ +import { Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowDown, faArrowUp, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { WorkoutTemplate } from "../../../types"; +import { WEEKDAY_NAMES } from "../enums"; +import { formatCalories } from "../Utils"; +import { DraftDay, getDraftCalories } from "../pages/workoutDraft"; +import WorkoutItemsEditor from "./WorkoutItemsEditor"; + +type Props = { + day: DraftDay, + /** Pořadí dne v plánu od nuly. */ + index: number, + dayCount: number, + /** Fixní plán vybírá den v týdnu, plovoucí řadí dny šipkami. */ + isFixed: boolean, + /** Dny v týdnu, které už mají jiné dny plánu. */ + takenWeekdays: number[], + nestable: WorkoutTemplate[], + templates: WorkoutTemplate[], + onChange: (day: DraftDay) => void, + onMove: (offset: number) => void, + onRemove: () => void, +}; + +/** Úprava jednoho tréninkového dne plánu — název, u fixního plánu den v týdnu, a cviky. */ +const WorkoutPlanDayEditor = ({ + day, index, dayCount, isFixed, takenWeekdays, nestable, templates, onChange, onMove, onRemove, +}: Readonly) => { + const calories = getDraftCalories(day.items, templates); + return ( +
+
+ {isFixed ? ( + onChange({ ...day, weekday: Number(event.target.value) })} + > + {WEEKDAY_NAMES.map((name, weekdayIndex) => ( + + ))} + + ) : ( + {index + 1}. + )} + onChange({ ...day, name: event.target.value })} + /> + {calories > 0 && {formatCalories(calories)}} + {!isFixed && ( + <> + + + + )} + +
+ onChange({ ...day, items })} + nestable={nestable} + /> +
+ ); +}; + +export default WorkoutPlanDayEditor; diff --git a/client/src/components/WorkoutTemplateCard.tsx b/client/src/components/WorkoutTemplateCard.tsx new file mode 100644 index 0000000..585dcd4 --- /dev/null +++ b/client/src/components/WorkoutTemplateCard.tsx @@ -0,0 +1,86 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBed, faLayerGroup, faPenToSquare, faRotate, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { WorkoutTemplate, WorkoutTemplateType } from "../../../types"; +import { WEEKDAY_NAMES, WORKOUT_TEMPLATE_TYPE_NAMES } from "../enums"; +import { formatCalories, formatDateString } from "../Utils"; +import WorkoutItemsList from "./WorkoutItemsList"; + +type Props = { + template: WorkoutTemplate, + busy: boolean, + onEdit: () => void, + onDelete: () => void, +}; + +/** + * Karta šablony tréninku. Volná šablona ukáže cviky, plán své dny — fixní + * s dnem v týdnu, plovoucí v pořadí a s dnem volna na konci cyklu. + */ +const WorkoutTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly) => { + const isPlan = template.type !== WorkoutTemplateType.VOLNY; + const usedCount = template.usedByCount + template.usedInPlanCount; + return ( +
+
+
+ {template.name} + {isPlan && {WORKOUT_TEMPLATE_TYPE_NAMES[template.type]}} + {template.hasNested && ( + + skládaný + + )} + {usedCount > 0 && součástí {usedCount}×} +
+ + {formatCalories(template.estimatedCalories)} + + + +
+ + {isPlan && ( +
+ {template.startDate && platí od {formatDateString(template.startDate)}} + {template.replaceWithinDays + ? nahraditelné během {template.replaceWithinDays} dní + : bez náhrad} +
+ )} + + {!isPlan && } + + {(template.days ?? []).map((day, index) => ( +
+
+ + {template.type === WorkoutTemplateType.FIXNI + ? WEEKDAY_NAMES[(day.weekday ?? 1) - 1] + : `${index + 1}.`} + + {day.name} + {formatCalories(day.estimatedCalories)} +
+ +
+ ))} + {template.type === WorkoutTemplateType.PLOVOUCI && ( +
+ Den volna, pak znovu od 1. dne +
+ )} +
+ ); +}; + +export default WorkoutTemplateCard; diff --git a/client/src/components/WorkoutTemplateForm.tsx b/client/src/components/WorkoutTemplateForm.tsx new file mode 100644 index 0000000..b3c1ef6 --- /dev/null +++ b/client/src/components/WorkoutTemplateForm.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { Alert, Button, Form } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBed, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { WorkoutTemplate, WorkoutTemplateType, saveWorkoutTemplate } from "../../../types"; +import { + ACTIVITY_PRESETS, WEEKDAY_NAMES, WORKOUT_TEMPLATE_TYPES_IN_ORDER, WORKOUT_TEMPLATE_TYPE_HINTS, + WORKOUT_TEMPLATE_TYPE_NAMES, +} from "../enums"; +import { formatCalories, formatDate, parseIntInput } from "../Utils"; +import { + DraftDay, emptyDay, emptyExercise, getDraftCalories, toDraftDays, toDraftItems, toTemplateItems, +} from "../pages/workoutDraft"; +import WorkoutItemsEditor, { ACTIVITY_PRESETS_LIST_ID } from "./WorkoutItemsEditor"; +import WorkoutPlanDayEditor from "./WorkoutPlanDayEditor"; + +type Props = { + /** Všechny šablony uživatele — kvůli vnořování a odhadu kalorií. */ + templates: WorkoutTemplate[], + /** Upravovaná šablona, nebo nic pro novou. */ + edited?: WorkoutTemplate, + /** Zavolá se s aktualizovaným seznamem šablon po uložení. */ + onSaved: (templates: WorkoutTemplate[]) => void, + onCancel: () => void, +}; + +/** Nejdelší lhůta pro náhradu, kterou přijme server. */ +const MAX_REPLACE_DAYS = 14; + +/** První den v týdnu, který v plánu ještě není. */ +const getFreeWeekday = (days: DraftDay[]): number => + [1, 2, 3, 4, 5, 6, 7].find(weekday => !days.some(day => day.weekday === weekday)) ?? 1; + +/** + * Formulář šablony tréninku. Volná šablona je seznam cviků; plán (fixní nebo + * plovoucí) má tréninkové dny, lhůtu pro náhradu vynechaných cviků a začátek + * platnosti. Rozpracované cviky volné šablony i dny plánu se drží zvlášť, + * takže přepnutí typu sem a zpět nic nesmaže. + */ +const WorkoutTemplateForm = ({ templates, edited, onSaved, onCancel }: Readonly) => { + const [name, setName] = useState(edited?.name ?? ''); + const [type, setType] = useState(edited?.type ?? WorkoutTemplateType.VOLNY); + const [items, setItems] = useState(() => (edited?.items.length ? toDraftItems(edited.items) : [emptyExercise()])); + const [days, setDays] = useState(() => (edited?.days?.length + ? toDraftDays(edited.days) + : [emptyDay('', 1)])); + const [replaceWithinDays, setReplaceWithinDays] = useState( + edited?.replaceWithinDays != null ? String(edited.replaceWithinDays) : ''); + const [startDate, setStartDate] = useState(edited?.startDate ?? formatDate(new Date())); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + const isPlan = type !== WorkoutTemplateType.VOLNY; + const isFixed = type === WorkoutTemplateType.FIXNI; + const isUsed = !!edited && (edited.usedByCount > 0 || edited.usedInPlanCount > 0); + + /** Volné šablony, které lze zařadit do dne plánu — plán zanoření nepřidává. */ + const planNestable = templates.filter(template => + template.type === WorkoutTemplateType.VOLNY && template.id !== edited?.id); + + /** + * Šablony, které lze vnořit do volné šablony. Vypadne ta upravovaná (odkaz + * sám na sebe) a všechny, které samy něco skládají — ty už by přinesly + * druhou úroveň zanoření. + */ + const freeNestable = planNestable.filter(template => !template.hasNested); + + const planDays = days.map(day => ({ ...day, templateItems: toTemplateItems(day.items) })); + const replaceDays = parseIntInput(replaceWithinDays); + const replaceValid = replaceDays == null || (replaceDays >= 1 && replaceDays <= MAX_REPLACE_DAYS); + const canSave = name.trim().length > 0 && !busy && (isPlan + ? planDays.every(day => day.templateItems.length > 0) && replaceValid && !!startDate + : toTemplateItems(items).length > 0); + + const changeDay = (index: number, day: DraftDay) => + setDays(current => current.map((item, i) => (i === index ? day : item))); + + const moveDay = (index: number, offset: number) => setDays(current => { + const result = [...current]; + [result[index], result[index + offset]] = [result[index + offset], result[index]]; + return result; + }); + + const addDay = () => setDays(current => [ + ...current, + emptyDay('', isFixed ? getFreeWeekday(current) : 1), + ]); + + /** U fixního plánu musí mít každý den jiný den v týdnu — po přepnutí typu se to srovná. */ + const changeType = (value: WorkoutTemplateType) => { + setType(value); + if (value === WorkoutTemplateType.FIXNI) { + setDays(current => current.reduce((result, day) => [ + ...result, + result.some(other => other.weekday === day.weekday) ? { ...day, weekday: getFreeWeekday(result) } : day, + ], []).slice(0, WEEKDAY_NAMES.length)); + } + }; + + const save = async () => { + if (!canSave) return; + setBusy(true); + setError(undefined); + const response = await saveWorkoutTemplate({ + body: { + id: edited?.id, + name: name.trim(), + type, + ...(isPlan + ? { + days: planDays.map(day => ({ + ...(day.id ? { id: day.id } : {}), + name: day.name.trim(), + ...(isFixed ? { weekday: day.weekday } : {}), + items: day.templateItems, + })), + ...(replaceDays ? { replaceWithinDays: replaceDays } : {}), + startDate, + } + : { items: toTemplateItems(items) }), + }, + }); + setBusy(false); + if (response.data) { + onSaved(response.data); + } else { + // Server odmítá typicky kvůli pravidlům zanoření nebo dnům plánu + setError((response.error as { error?: string })?.error ?? 'Nepodařilo se uložit'); + } + }; + + const totalCalories = isPlan + ? days.reduce((sum, day) => sum + getDraftCalories(day.items, templates), 0) + : getDraftCalories(items, templates); + + return ( +
+

{isPlan ? (edited ? 'Úprava plánu' : 'Nový plán') : (edited ? 'Úprava tréninku' : 'Nový trénink')}

+ + {error && {error}} + +
+ + Název + setName(event.target.value)} + /> + + + Typ + changeType(event.target.value as WorkoutTemplateType)} + > + {WORKOUT_TEMPLATE_TYPES_IN_ORDER.map(item => ( + + ))} + + +
+

+ {WORKOUT_TEMPLATE_TYPE_HINTS[type]} + {isUsed && ' Trénink je součástí jiného tréninku nebo plánu, plánem se proto stát nemůže.'} +

+ + {!isPlan && ( + 0 + ? 'Tento trénink už je vložený do jiného, další vkládání by bylo druhou úrovní' + : undefined} + /> + )} + + {isPlan && ( + <> +
+ + Platí od + setStartDate(event.target.value)} + /> + + + Nahraditelné během +
+ setReplaceWithinDays(event.target.value.replace(/\D/g, ''))} + /> + dní +
+ + Vynechaný cvik jde v této lhůtě dodělat jindy (nejvýš {MAX_REPLACE_DAYS} dní). + +
+
+ + {days.map((day, index) => ( + other.key !== day.key).map(other => other.weekday)} + nestable={planNestable} + templates={templates} + onChange={changed => changeDay(index, changed)} + onMove={offset => moveDay(index, offset)} + onRemove={() => setDays(current => current.filter(other => other.key !== day.key))} + /> + ))} + {type === WorkoutTemplateType.PLOVOUCI && ( +
+ Den volna — po posledním dni, pak se cyklus opakuje +
+ )} + + )} + + + {ACTIVITY_PRESETS.map(preset => ( + + +
+ {isPlan && ( + + )} + {totalCalories > 0 && ( + + {isPlan ? 'za cyklus ' : 'celkem '}{formatCalories(totalCalories)} + + )} + {edited && ( + + )} + +
+
+ ); +}; + +export default WorkoutTemplateForm; diff --git a/client/src/components/modals/ActivityModal.tsx b/client/src/components/modals/ActivityModal.tsx index a38ca3f..21b2539 100644 --- a/client/src/components/modals/ActivityModal.tsx +++ b/client/src/components/modals/ActivityModal.tsx @@ -273,7 +273,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: className="modal-template-check" id="activity-also-template" checked={alsoTemplate} - label="Uložit i jako šablonu" + label="Uložit i jako trénink" onChange={event => setAlsoTemplate(event.target.checked)} />
diff --git a/client/src/components/modals/ApplyWorkoutModal.tsx b/client/src/components/modals/ApplyWorkoutModal.tsx index ae3224f..71bc59e 100644 --- a/client/src/components/modals/ApplyWorkoutModal.tsx +++ b/client/src/components/modals/ApplyWorkoutModal.tsx @@ -3,7 +3,10 @@ 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 { + DayOverview, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, WorkoutTemplateType, applyWorkoutTemplate, + getWorkoutTemplates, +} from "../../../../types"; import { ACTIVITY_UNIT_SHORT } from "../../enums"; import { formatCalories, formatQuantityWithSets } from "../../Utils"; import { TRENINKY_URL } from "../../routes"; @@ -17,10 +20,10 @@ type Props = { 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') +/** Popíše položky šablony nebo dne plánu jednou řádkou. */ +const describeItems = (items: WorkoutTemplateItem[]): string => + items.map(item => item.kind === WorkoutItemKind.SABLONA + ? (item.templateRefName ?? 'trénink') : `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}` ).join(' · '); @@ -28,7 +31,8 @@ const describeItems = (template: WorkoutTemplate): string => * 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. + * vybrat hotovou šablonu a rozbalit ji do dne. Den plánu běžně navrhne přehled + * dne sám; tady jde založit i jiný den plánu, než který je na řadě. */ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly) => { const [templates, setTemplates] = useState([]); @@ -45,9 +49,9 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly }); }, [isOpen]); - const apply = async (id: string) => { + const apply = async (id: string, dayId?: string) => { setBusy(true); - const response = await applyWorkoutTemplate({ body: { id, date } }); + const response = await applyWorkoutTemplate({ body: { id, date, ...(dayId ? { dayId } : {}) } }); setBusy(false); if (response.data) { onApplied(response.data); @@ -57,7 +61,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly return ( - Použít šablonu tréninku + Přidat trénink {loading && ( @@ -69,21 +73,21 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly {!loading && !templates.length && (
-
Zatím nemáte žádnou šablonu
-

Vytvořit si ji můžete na stránce Tréninky.

+
Zatím nemáte žádný trénink ani plán
+

Vytvořit si ho můžete na stránce Tréninky.

)} - {!loading && templates.map(template => ( + {!loading && templates.filter(template => template.type === WorkoutTemplateType.VOLNY).map(template => (
{template.name} {template.hasNested && ( - + )}
-
{describeItems(template)}
+
{describeItems(template.items)}
{formatCalories(template.estimatedCalories)}
))} + + {!loading && templates.filter(template => template.type !== WorkoutTemplateType.VOLNY).map(plan => ( +
+

{plan.name}

+ {(plan.days ?? []).map(day => ( +
+
+
{day.name}
+
{describeItems(day.items)}
+
+ {formatCalories(day.estimatedCalories)} + +
+ ))} +
+ ))}
diff --git a/client/src/components/modals/SaveWorkoutTemplateModal.tsx b/client/src/components/modals/SaveWorkoutTemplateModal.tsx index 5509474..ad39fc6 100644 --- a/client/src/components/modals/SaveWorkoutTemplateModal.tsx +++ b/client/src/components/modals/SaveWorkoutTemplateModal.tsx @@ -58,20 +58,20 @@ const SaveWorkoutTemplateModal = ({ if (response.data) { onSaved(); } else { - setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit'); + setError((response.error as { error?: string })?.error ?? 'Trénink se nepodařilo vytvořit'); } }; return ( - Uložit jako šablonu tréninku + Uložit jako trénink {error && {error}} - Název šablony + Název tréninku diff --git a/client/src/enums.ts b/client/src/enums.ts index 54cf239..900ec27 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -1,5 +1,6 @@ import { ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor, + WorkoutTemplateType, } from "../../types"; /** Lidsky čitelné názvy typů jídel. */ @@ -80,6 +81,30 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10 { name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 }, ]; +/** Názvy typů šablon tréninků. */ +export const WORKOUT_TEMPLATE_TYPE_NAMES: Record = { + VOLNY: 'Volný trénink', + FIXNI: 'Fixní plán', + PLOVOUCI: 'Plovoucí plán', +}; + +/** Vysvětlení typu šablony pro formulář. */ +export const WORKOUT_TEMPLATE_TYPE_HINTS: Record = { + VOLNY: 'Cviky, které jde odcvičit kdykoli. Do dne se přidávají ručně tlačítkem Z tréninků.', + FIXNI: 'Pevně dané dny v týdnu. Přehled dne sám navrhne trénink podle dne, ostatní dny jsou volno.', + PLOVOUCI: 'Sekvence dnů zakončená dnem volna. Další den přijde na řadu, až se splní předchozí; ' + + 'volno jde v cyklu jednou posunout dopředu — prostě ten den necvičíte.', +}; + +export const WORKOUT_TEMPLATE_TYPES_IN_ORDER: WorkoutTemplateType[] = [ + WorkoutTemplateType.VOLNY, + WorkoutTemplateType.PLOVOUCI, + WorkoutTemplateType.FIXNI, +]; + +/** Dny v týdnu podle ISO (index 0 = pondělí = 1) pro fixní plán. */ +export const WEEKDAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle']; + /** Záložky příjem/výdej v přehledu dne. */ export enum DayTab { PRIJEM = 'PRIJEM', diff --git a/client/src/pages/ActivityStatsPanel.tsx b/client/src/pages/ActivityStatsPanel.tsx index f16f9ab..57a87c7 100644 --- a/client/src/pages/ActivityStatsPanel.tsx +++ b/client/src/pages/ActivityStatsPanel.tsx @@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react"; import { Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { - faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking, - faTriangleExclamation, + faArrowRight, faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking, + faRotateLeft, faTriangleExclamation, } from "@fortawesome/free-solid-svg-icons"; import { Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis, } from "recharts"; import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types"; import { StatsRange } from "../enums"; -import { formatBalance, formatCalories } from "../Utils"; +import { formatBalance, formatCalories, formatShortDate } from "../Utils"; import { StatsBounds, TOOLTIP_STYLE } from "./statsShared"; import { - ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, + ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, getSeriesId, getUsageName, + hasUnresolvedNote, listSeriesIds, } from "./activityChartSeries"; type Props = { @@ -28,16 +29,28 @@ function deviationClass(deviation: number): string { return ''; } -/** Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. */ +/** + * Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. + * Nahrazený cvik nese šipku na den, kdy se dodělal. + */ function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) { + const unresolved = note.missing.length > 0 || note.changed.length > 0; return (
- +
{note.date && {note.date} } {note.templateName} {!!note.missing.length &&
nesplněno: {note.missing.join(', ')}
} {!!note.changed.length &&
upraveno: {note.changed.join(', ')}
} + {note.replaced.map((item, index) => ( +
+ nahrazeno: {item.name} {formatShortDate(item.replacementDate)} +
+ ))}
); @@ -62,11 +75,20 @@ function ActivityTooltip({ active, payload, label }: Readonly 0 && (
- Plán šablon + Plán {formatCalories(row.planned)}
)} {row.notes.map((note, index) => )} + {row.replacements.map((item, index) => ( +
+ +
+ náhrada za {formatShortDate(item.sourceDate)}: {item.name} +
{item.templateName}
+
+
+ ))}
); } @@ -76,6 +98,7 @@ type DotProps = { cx?: number, cy?: number, payload?: ActivitySeriesRow, index?: /** * Bod plánu. Kde se ze šablony něco nesplnilo, je výraznější a nese "!" — * sedí na úrovni plánu, tedy přesně nad mezerou, kterou nesplněné cviky nechaly. + * Když se všechno vynechané později nahradilo, nese místo "!" šipku k náhradě. */ function PlanDot({ cx, cy, payload, index }: Readonly) { // Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama @@ -83,6 +106,14 @@ function PlanDot({ cx, cy, payload, index }: Readonly) { if (!payload.notes.length) { return ; } + if (!hasUnresolvedNote(payload)) { + return ( + + + → + + ); + } return ( @@ -114,12 +145,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) { // Pořadí všech šablon dává barvy, které se nemění podle zobrazeného období useEffect(() => { - getWorkoutTemplates().then(response => setTemplateIds((response.data ?? []).map(template => template.id))); + getWorkoutTemplates().then(response => setTemplateIds(listSeriesIds(response.data ?? []))); }, []); const { series, templateSeries } = useMemo(() => { if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] }; - const used = new Map(stats.byTemplate.map(usage => [usage.templateId, usage.name])); + const used = new Map(stats.byTemplate.map(usage => [getSeriesId(usage), getUsageName(usage)])); const built = buildTemplateSeries(templateIds, used); return { series: buildActivitySeries(stats.days, range, mode, built.keyOf), @@ -141,7 +172,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) {
Za zvolené období nemáte žádný pohyb

- Aktivity přidáte v přehledu dne na záložce Výdej, ručně nebo ze šablony tréninku. + Aktivity přidáte v přehledu dne na záložce Výdej, ručně, z plánu nebo z tréninku.

); @@ -162,14 +193,14 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) {
za {stats.dayCount} dní s pohybem
-
Ze šablon
+
Z plánů a tréninků
{formatCalories(stats.templateCalories)}
- {fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'} + {fulfilment != null ? `${fulfilment} % plánu` : 'žádný plán ani trénink'}
-
Odchylka od šablon
+
Odchylka od plánu
{deviation ? formatBalance(deviation) : '—'}
@@ -178,7 +209,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) {
-
Mimo šablonu
+
Ostatní pohyb
{formatCalories(stats.extraCalories)}
z {extraCount} aktivit
@@ -207,7 +238,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) { ))} ) { {/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */} ) {

u bodu plánu - znamená, že se ze šablony něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec. + znamená, že se z plánu něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec. + {' '} znamená, že se + vynechané nahradilo jiný den; bublina ukáže kdy. Dny volna a necvičené dny plánu se nezapisují.

-

Plnění šablon

+

Plnění plánů a tréninků

- + @@ -250,8 +283,8 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) { {stats.byTemplate.map(usage => { const usageDeviation = usage.actualCalories - usage.plannedCalories; return ( - - + + @@ -266,7 +299,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) { setExtrasOpen(open => !open)}>
ŠablonaTrénink Použito Cviky Plán
{usage.name}
{getUsageName(usage)} {usage.dayCount}× {usage.doneItemCount}/{usage.plannedItemCount} {formatCalories(usage.plannedCalories)}
- {' '}Mimo šablonu + {' '}Ostatní pohyb {extraCount}× @@ -288,12 +321,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly) {
{!stats.byTemplate.length && ( -

V tomto období jste žádnou šablonu tréninku nepoužili.

+

V tomto období jste nepoužili žádný plán ani trénink.

)}

- Plán je to, co šablona slibovala v den použití. Smazaný nebo zkrácený cvik se - projeví jako odchylka; když smažete všechny cviky šablony, bere se to jako - vrácené použití. + Plán je to, co trénink sliboval v den použití. Smazaný nebo zkrácený cvik se + projeví jako odchylka, nahrazený jiný den se počítá jako splněný; když smažete + všechny cviky tréninku, bere se to jako vrácené použití.

diff --git a/client/src/pages/DayPage.scss b/client/src/pages/DayPage.scss index 57dbf88..0136203 100644 --- a/client/src/pages/DayPage.scss +++ b/client/src/pages/DayPage.scss @@ -262,3 +262,121 @@ color: var(--ft-text-secondary); } } + +// Návrh tréninku z plánu a cviky k náhradě nad seznamem aktivit +.planned-workout-card { + margin-bottom: 14px; + padding: 14px 18px; + border-left: 4px solid var(--ft-primary); + + .planned-workout-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + } + + .planned-workout-icon { + color: var(--ft-primary); + } + + .planned-workout-title { + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + color: var(--ft-text); + } + + .planned-workout-plan { + font-size: 0.82rem; + color: var(--ft-text-muted); + } + + .planned-workout-calories { + color: var(--ft-warning); + font-weight: 700; + white-space: nowrap; + } + + .planned-workout-footer { + display: flex; + align-items: center; + gap: 12px; + margin-top: 10px; + } + + .planned-workout-hint { + flex: 1; + font-size: 0.8rem; + color: var(--ft-text-muted); + } +} + +.planned-workout { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding: 8px 14px; + border-radius: var(--ft-radius); + background: var(--ft-bg-card); + font-size: 0.88rem; + color: var(--ft-text-secondary); +} + +.planned-workout-done svg { + color: var(--ft-success); +} + +.planned-workout-rest svg { + color: var(--ft-text-muted); +} + +.pending-replacements { + margin-bottom: 14px; + padding: 14px 18px; + + .pending-replacements-title { + margin: 0 0 6px; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ft-text-secondary); + } + + .pending-replacement-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--ft-border-light); + + &:last-child { + border-bottom: none; + } + } + + .pending-replacement-main { + flex: 1; + min-width: 0; + } + + .pending-replacement-name { + font-weight: 600; + color: var(--ft-text); + } + + .pending-replacement-meta { + font-size: 0.8rem; + color: var(--ft-text-muted); + } + + .template-pick-calories { + color: var(--ft-warning); + font-weight: 600; + white-space: nowrap; + } +} diff --git a/client/src/pages/DayPage.tsx b/client/src/pages/DayPage.tsx index 324308a..8b12ddd 100644 --- a/client/src/pages/DayPage.tsx +++ b/client/src/pages/DayPage.tsx @@ -8,7 +8,7 @@ import { faWeightScale, } from "@fortawesome/free-solid-svg-icons"; import { - ActivityEntry, DayOverview, MealEntry, + ActivityEntry, DayOverview, MealEntry, PlannedWorkoutStatus, deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier, } from "../../../types"; import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; @@ -27,6 +27,8 @@ import ApplyMealModal from "../components/modals/ApplyMealModal"; import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal"; import MealRow from "../components/MealRow"; import ActivityRow from "../components/ActivityRow"; +import PlannedWorkoutCard from "../components/PlannedWorkoutCard"; +import PendingReplacementsPanel from "../components/PendingReplacementsPanel"; import { DAY_DATE_PARAM } from "../routes"; import "./DayPage.scss"; @@ -37,6 +39,8 @@ function emptyOverview(date: string): DayOverview { meals: { date, entries: [], totalPrice: 0, totalCalories: 0 }, activities: { date, entries: [], totalCalories: 0 }, energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false }, + plannedWorkouts: [], + pendingReplacements: [], }; } @@ -140,6 +144,9 @@ export default function DayPage() { setSaveWorkoutOpen(true); }; + /** Plán, který na dnešek ještě čeká na potvrzení — pak se nehlásí prázdný den. */ + const hasProposal = day.plannedWorkouts.some(planned => planned.status === PlannedWorkoutStatus.NAVRH); + const removeActivity = async (entry: ActivityEntry) => { const response = await deleteActivity({ body: { id: entry.id, date: entry.date } }); if (response.data) setDay(response.data); @@ -162,11 +169,11 @@ export default function DayPage() {
{!!day.activities.entries.length && ( )} - -
-
    - {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)} - - {!!item.extraWeightGrams && ( - - {formatExtraWeight(item.extraWeightGrams)} - - )} - - {formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))} - - - )} -
  • - ))} -
- + {!loading && sortTemplates(templates).map(template => ( + edit(template)} + onDelete={() => remove(template.id)} + /> ))} -
-

{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, '') })} - /> - updateItem(index, { extraWeight: event.target.value })} - /> - - )} - -
- ))} - - {ACTIVITY_PRESETS.map(preset => ( - - -
- - - {draftCalories > 0 && ( - celkem {formatCalories(draftCalories)} - )} - {editedId && ( - - )} - -
-
+ { + setTemplates(saved); + closeForm(); + }} + onCancel={() => closeForm()} + /> ); }; diff --git a/client/src/pages/activityChartSeries.ts b/client/src/pages/activityChartSeries.ts index 85e482f..505a4a1 100644 --- a/client/src/pages/activityChartSeries.ts +++ b/client/src/pages/activityChartSeries.ts @@ -1,4 +1,4 @@ -import { ActivityDayStats, PlanDeviationKind } from "../../../types"; +import { ActivityDayStats, PlanDeviationKind, WorkoutTemplate } from "../../../types"; import { StatsRange } from "../enums"; import { formatDateString } from "../Utils"; import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared"; @@ -9,6 +9,9 @@ export const SERIES_COLOR_COUNT = 8; /** Klíč řady pro šablony, které se do palety nevešly. */ export const OTHER_TEMPLATES_KEY = 'ostatni'; +/** Vynechaný cvik, který se později nahradil, a kdy. */ +export type ReplacedNote = { name: string, replacementDate: string }; + /** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */ export type PlanNote = { /** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */ @@ -16,6 +19,14 @@ export type PlanNote = { templateName: string; missing: string[]; changed: string[]; + replaced: ReplacedNote[]; +}; + +/** Náhrada cviku vynechaného dřív, odcvičená v období sloupce. */ +export type ReplacementNote = { + name: string; + templateName: string; + sourceDate: string; }; /** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:`. */ @@ -28,12 +39,34 @@ export type ActivitySeriesRow = { */ planned: number; notes: PlanNote[]; - [templateKey: string]: number | string | null | PlanNote[]; + replacements: ReplacementNote[]; + [templateKey: string]: number | string | null | PlanNote[] | ReplacementNote[]; }; /** Šablona jako řada grafu. */ export type TemplateSeries = { key: string, name: string, color: string }; +/** Název použití šablony — u plánu s názvem dne. */ +export const getUsageName = (usage: { name: string, dayName?: string }): string => + usage.dayName ? `${usage.name} · ${usage.dayName}` : usage.name; + +/** Vrátí true, pokud se v období sloupce ze šablony něco nesplnilo a nenahradilo. */ +export const hasUnresolvedNote = (row: ActivitySeriesRow): boolean => + row.notes.some(note => note.missing.length > 0 || note.changed.length > 0); + +/** + * Identifikátor řady grafu. Plán má řadu za každý svůj den — celý plán jednou + * barvou by neukázal, který den se plní a který ne. + */ +export const getSeriesId = (usage: { templateId: string, planDayId?: string }): string => + usage.planDayId ? `${usage.templateId}:${usage.planDayId}` : usage.templateId; + +/** Identifikátory všech řad v pořadí, které určuje barvy — volné tréninky a dny plánů. */ +export const listSeriesIds = (templates: WorkoutTemplate[]): string[] => templates.flatMap(template => + template.days?.length + ? template.days.map(day => getSeriesId({ templateId: template.id, planDayId: day.id })) + : [template.id]); + /** Klíč řady šablony v řádku grafu. */ export const templateKey = (templateId: string): string => `t:${templateId}`; @@ -44,8 +77,8 @@ export const templateKey = (templateId: string): string => `t:${templateId}`; * co se v zobrazeném období zrovna použilo — jinak by šablona měnila barvu podle * zvoleného měsíce. Smazané šablony a ty nad počet barev jdou do "Ostatní". * - * @param allTemplateIds identifikátory všech šablon uživatele seřazené dle názvu - * @param used použité šablony v období (id → název) + * @param allTemplateIds identifikátory všech řad (viz {@link listSeriesIds}) + * @param used použité řady v období (id → název) */ export function buildTemplateSeries(allTemplateIds: string[], used: Map): { series: TemplateSeries[], keyOf: (templateId: string) => string, @@ -60,7 +93,7 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map colorIndex.get(a)! - colorIndex.get(b)!) .map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` })); if ([...used.keys()].some(id => keyOf(id) === OTHER_TEMPLATES_KEY)) { - series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní šablony', color: 'var(--ft-series-other-template)' }); + series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní tréninky', color: 'var(--ft-series-other-template)' }); } return { series, keyOf }; } @@ -72,13 +105,13 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map string, ): ActivitySeriesRow[] { const rows = new Map(listPeriods(range, mode, days[0]?.date).map(period => [period, { - label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [], + label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [], replacements: [], }])); for (const day of days) { @@ -86,20 +119,32 @@ export function buildActivitySeries( if (!row) continue; row.extra += day.extraCalories; for (const usage of day.templates) { - const key = keyOf(usage.templateId); + const key = keyOf(getSeriesId(usage)); row[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories; row.planned += usage.plannedCalories; const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO); const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO); - if (missing.length || changed.length) { + const replaced = usage.deviations.filter(item => item.kind === PlanDeviationKind.NAHRAZENO); + if (missing.length || changed.length || replaced.length) { row.notes.push({ ...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }), - templateName: usage.name, + templateName: getUsageName(usage), missing: missing.map(item => item.planned.name), changed: changed.map(item => item.planned.name), + replaced: replaced.map(item => ({ name: item.planned.name, replacementDate: item.replacementDate! })), }); } } + // Náhrada se kreslí do dílu plánu, ze kterého cvik pochází, plán dne nezvyšuje + for (const replacement of day.replacements) { + const key = keyOf(getSeriesId(replacement)); + row[key] = ((row[key] as number | undefined) ?? 0) + replacement.item.calories; + row.replacements.push({ + name: replacement.item.name, + templateName: getUsageName(replacement), + sourceDate: replacement.sourceDate, + }); + } } return [...rows.values()]; } diff --git a/client/src/pages/templates.scss b/client/src/pages/templates.scss index 9e18201..a740f32 100644 --- a/client/src/pages/templates.scss +++ b/client/src/pages/templates.scss @@ -309,3 +309,145 @@ border: none; } } + +// ============================================ +// PLÁNY TRÉNINKŮ +// ============================================ + +.tag-plan { + background: var(--ft-primary-light); + color: var(--ft-primary); +} + +.template-card { + .template-plan-meta { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 4px; + font-size: 0.8rem; + color: var(--ft-text-muted); + } + + .template-plan-day { + margin-top: 12px; + } + + // Hlavička dne je podbarvený pruh, cviky pod ní odsazené s linkou — na první + // pohled je vidět, kde den začíná a co k němu patří + .template-plan-day-head { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 10px; + border-left: 3px solid var(--ft-primary); + border-radius: var(--ft-radius-sm); + background: var(--ft-primary-light); + font-size: 0.9rem; + } + + .template-plan-day-label { + color: var(--ft-primary); + font-weight: 700; + min-width: 20px; + } + + .template-plan-day-name { + flex: 1; + font-weight: 700; + color: var(--ft-text); + } + + .template-plan-day .template-items { + margin: 2px 0 0 12px; + padding-left: 12px; + border-left: 2px solid var(--ft-border); + + li:first-child { + border-top: none; + } + } + + // Den volna není záznam, jen připomínka konce cyklu + .template-plan-rest { + padding: 6px 10px; + border: 1px dashed var(--ft-border); + border-radius: var(--ft-radius-sm); + font-size: 0.85rem; + color: var(--ft-text-muted); + font-style: italic; + } +} + +.template-form { + .template-form-head { + display: flex; + flex-wrap: wrap; + gap: 12px; + + > * { + flex: 1 1 180px; + } + + .template-form-name { + flex: 3 1 260px; + } + } + + .input-with-unit { + display: flex; + align-items: center; + gap: 8px; + + span { + color: var(--ft-text-secondary); + font-size: 0.88rem; + } + } +} + +.workout-items-actions { + display: flex; + gap: 8px; +} + +.plan-day-editor { + margin-bottom: 14px; + padding: 12px; + border: 1px solid var(--ft-border); + border-radius: var(--ft-radius); + + .plan-day-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + } + + .plan-day-weekday { + width: 140px; + flex: 0 0 auto; + } + + .plan-day-order { + font-weight: 700; + color: var(--ft-text-secondary); + min-width: 22px; + } + + .plan-day-calories { + color: var(--ft-warning); + font-weight: 600; + font-size: 0.85rem; + white-space: nowrap; + } +} + +.plan-rest-day { + margin-bottom: 14px; + padding: 10px 12px; + border: 1px dashed var(--ft-border); + border-radius: var(--ft-radius); + font-size: 0.87rem; + color: var(--ft-text-muted); +} diff --git a/client/src/pages/workoutDraft.ts b/client/src/pages/workoutDraft.ts new file mode 100644 index 0000000..63d3724 --- /dev/null +++ b/client/src/pages/workoutDraft.ts @@ -0,0 +1,111 @@ +import { ActivityUnit, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate, WorkoutTemplateItem } from "../../../types"; +import { deriveActivityCalories, formatKgInput, parseIntInput, parseWeightKgInput } from "../Utils"; + +/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */ +export type DraftItem = { + kind: WorkoutItemKind, + name: string, + unit: ActivityUnit, + quantity: string, + sets: string, + caloriesPer100Units: string, + /** Dodatečná zátěž v kg, jak ji uživatel píše */ + extraWeight: string, + templateRefId: string, +}; + +/** + * Rozpracovaný den plánu. `key` drží identitu v seznamu i u dne, který ještě + * nemá id ze serveru; `id` se posílá zpět, aby den zůstal tím samým dnem. + */ +export type DraftDay = { + key: string, + id?: string, + name: string, + weekday: number, + items: DraftItem[], +}; + +export const emptyExercise = (): DraftItem => ({ + kind: WorkoutItemKind.CVIK, + name: '', + unit: ActivityUnit.OPAKOVANI, + quantity: '', + sets: '', + caloriesPer100Units: '', + extraWeight: '', + templateRefId: '', +}); + +export const emptyReference = (templateRefId: string): DraftItem => ({ + ...emptyExercise(), + kind: WorkoutItemKind.SABLONA, + templateRefId, +}); + +let dayKeySequence = 0; + +/** Nový den plánu s jedním prázdným cvikem. */ +export const emptyDay = (name: string, weekday: number): DraftDay => ({ + key: `novy-${++dayKeySequence}`, + name, + weekday, + items: [emptyExercise()], +}); + +/** Převede uložené položky šablony do rozpracované podoby. */ +export const toDraftItems = (items: WorkoutTemplateItem[]): DraftItem[] => 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) : '', + extraWeight: formatKgInput(item.extraWeightGrams), + templateRefId: '', + }); + +/** Převede uložené dny plánu do rozpracované podoby. */ +export const toDraftDays = (days: WorkoutPlanDay[]): DraftDay[] => days.map(day => ({ + key: day.id, + id: day.id, + name: day.name, + weekday: day.weekday ?? 1, + items: toDraftItems(day.items), +})); + +/** Převede rozpracované položky na položky pro server; nedopsané se vynechají. */ +export const toTemplateItems = (items: DraftItem[]): WorkoutTemplateItem[] => items + .filter(item => item.kind === WorkoutItemKind.SABLONA + ? item.templateRefId.length > 0 + : item.name.trim().length > 0 && parseIntInput(item.quantity) != null) + .map(item => { + if (item.kind === WorkoutItemKind.SABLONA) { + return { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId }; + } + const sets = parseIntInput(item.sets); + const rate = parseIntInput(item.caloriesPer100Units); + const extraWeight = parseWeightKgInput(item.extraWeight); + return { + kind: WorkoutItemKind.CVIK, + name: item.name.trim(), + unit: item.unit, + quantity: parseIntInput(item.quantity)!, + ...(sets != null && sets > 1 ? { sets } : {}), + ...(rate != null ? { caloriesPer100Units: rate } : {}), + ...(extraWeight != null ? { extraWeightGrams: extraWeight } : {}), + }; + }); + +/** Průběžný odhad kalorií rozpracovaných položek, včetně vnořených šablon. */ +export const getDraftCalories = (items: DraftItem[], templates: WorkoutTemplate[]): number => + items.reduce((sum, item) => { + if (item.kind === WorkoutItemKind.SABLONA) { + return sum + (templates.find(template => template.id === item.templateRefId)?.estimatedCalories ?? 0); + } + return sum + (deriveActivityCalories( + parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0); + }, 0); diff --git a/server/src/activities.ts b/server/src/activities.ts index 03a8914..0c6ab52 100644 --- a/server/src/activities.ts +++ b/server/src/activities.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils"; -import { dropOrphanedPlans } from "./workoutPlans"; +import { dropOrphanedPlans, updateReplacementLink } from "./workoutPlans"; const storage = getStorage(); @@ -230,6 +230,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown) ); if (originalDate !== normalized.date) { await dropOrphanedPlans(login, originalDate, remaining); + if (original.replacesDate) { + await updateReplacementLink(login, original.replacesDate, id, normalized.date); + } } return normalized.date; } @@ -248,15 +251,19 @@ export async function deleteActivity(login: string, id: unknown, date: unknown): if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); } - let found = false; + let deleted: ActivityEntry | undefined; const remaining = await storage.updateData(getDayKey(login, date), current => { - found = (current ?? []).some(entry => entry.id === id); + deleted = (current ?? []).find(entry => entry.id === id); return (current ?? []).filter(entry => entry.id !== id); }); - if (!found) { + if (!deleted) { throw new NotFoundError("Aktivita nebyla nalezena"); } await dropOrphanedPlans(login, date, remaining); + // Smazaná náhrada vrací cvik v původním dni mezi vynechané + if (deleted.replacesDate) { + await updateReplacementLink(login, deleted.replacesDate, id); + } return date; } diff --git a/server/src/dayOverview.ts b/server/src/dayOverview.ts index 71fff6d..c0b9cfc 100644 --- a/server/src/dayOverview.ts +++ b/server/src/dayOverview.ts @@ -6,6 +6,7 @@ import { getSleep } from "./sleep"; import { findWeightOn, getWeights } from "./weights"; import { computeBasalCalories } from "./profile"; import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics"; +import { getPendingReplacements, getPlannedWorkouts } from "./workoutSchedule"; /** * Spočítá energetickou bilanci dne. @@ -56,7 +57,8 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da settings.basalCalories ?? computeBasalCalories(settings.profile, getWeightOn(settings, weightList, date)); /** - * Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci. + * Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení, energetickou bilanci + * a co na den navrhují plány tréninků včetně cviků, které jde ještě nahradit. * * Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto * dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím @@ -66,12 +68,14 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da * @param date datum ve formátu YYYY-MM-DD */ export const getDayOverview = async (login: string, date: string): Promise => { - const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([ + const [meals, activities, settings, sleepMinutes, weightList, plannedWorkouts, pendingReplacements] = await Promise.all([ getDay(login, date), getActivityDay(login, date), getSettings(login), getSleep(login, date), getWeights(login), + getPlannedWorkouts(login, date), + getPendingReplacements(login, date), ]); const weightGrams = getWeightOn(settings, weightList, date); const basal = getBasalOn(settings, weightList, date); @@ -83,6 +87,8 @@ export const getDayOverview = async (login: string, date: string): Promise { router.post("/save", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); - const { id, name, items } = req.body ?? {}; - res.status(200).json(await saveWorkoutTemplate(login, id, name, items)); + const { id, name, items, ...plan } = req.body ?? {}; + res.status(200).json(await saveWorkoutTemplate(login, id, name, items, plan)); } catch (e) { next(e); } }); @@ -34,12 +35,22 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) = } catch (e) { next(e); } }); -/** Založí do zvoleného dne všechny položky šablony. */ +/** Založí do zvoleného dne cviky šablony, u plánu jednoho jeho dne (i jako potvrzení návrhu). */ router.post("/apply", async (req: Request, res: Response, next: NextFunction) => { try { const login = getLogin(parseToken(req)); - const { id, date } = req.body ?? {}; - const day = await applyWorkoutTemplate(login, id, date); + const { id, date, dayId, doneIndexes } = req.body ?? {}; + const day = await applyWorkoutTemplate(login, id, date, dayId, doneIndexes); + res.status(200).json(await getDayOverview(login, day)); + } catch (e) { next(e); } +}); + +/** Nahradí cvik vynechaný v předchozím dni plánu. */ +router.post("/replace", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const { date, sourceDate, planId, itemIndex } = req.body ?? {}; + const day = await replaceSkippedExercise(login, date, sourceDate, planId, itemIndex); res.status(200).json(await getDayOverview(login, day)); } catch (e) { next(e); } }); diff --git a/server/src/statsService.ts b/server/src/statsService.ts index 8718d39..1b606e9 100644 --- a/server/src/statsService.ts +++ b/server/src/statsService.ts @@ -1,7 +1,7 @@ import { ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem, ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients, - PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal, + PeriodTotal, PlanDeviationKind, ReplacementUsage, TemplateDayUsage, TemplateUsageTotal, } from "../../types/gen/types.gen"; import { getRange } from "./meals"; import { getActivityDay } from "./activities"; @@ -267,41 +267,75 @@ const differsFromPlan = (entry: ActivityEntry, item: WorkoutPlanItem, calories: /** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */ type UsageCounts = { planned: number, removed: number }; +/** Současný název šablony a jejích dnů — pro aktivity, ke kterým se plán neuložil. */ +type TemplateInfo = { name: string, dayNames: Map }; + +/** Klíč použití šablony; plán se ve statistikách dělí po dnech. */ +const getUsageKey = (templateId: string, planDayId?: string): string => + planDayId ? `${templateId}:${planDayId}` : templateId; + /** - * Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu - * a aktivity mimo šablony vrátí zvlášť. + * Rozebere jeden den: ke každé použité šabloně (u plánu ke každému jeho dni) + * dohledá odchylky od plánu, náhrady cviků vynechaných dřív a aktivity mimo + * šablony vrátí zvlášť. + * + * Vynechaný cvik, který se později nahradil, není odchylka typu ODEBRANO, ale + * NAHRAZENO s datem náhrady — počítá se jako provedený. Náhrada sama se + * počítá do dne, kdy se odcvičila, bez plánu (plán nese původní den). * * Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se * berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde. */ function analyzeDay( - entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map, date: string, + entries: ActivityEntry[], plans: WorkoutPlan[], templateInfo: Map, date: string, ): { day: ActivityDayStats, counts: Map } { const byId = new Map(entries.map(entry => [entry.id, entry])); const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId))); const usages = new Map(); const counts = new Map(); - const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => { - const usage = usages.get(templateId) ?? { - templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [], + const usageFor = ( + templateId: string, planDayId: string | undefined, name: string, dayName: string | undefined, hasPlan: boolean, + ): { usage: TemplateDayUsage, count: UsageCounts } => { + const key = getUsageKey(templateId, planDayId); + const usage = usages.get(key) ?? { + templateId, + name, + ...(planDayId ? { planDayId } : {}), + ...(dayName ? { dayName } : {}), + plannedCalories: 0, + actualCalories: 0, + hasPlan, + deviations: [], }; - usages.set(templateId, usage); - if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 }); - return usage; + usages.set(key, usage); + const count = counts.get(key) ?? { planned: 0, removed: 0 }; + counts.set(key, count); + return { usage, count }; }; + /** Současný název dne plánu, pokud den v šabloně pořád je. */ + const currentDayName = (templateId: string, planDayId?: string): string | undefined => + planDayId ? templateInfo.get(templateId)?.dayNames.get(planDayId) : undefined; + for (const plan of plans) { // Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se - const usage = usageFor(plan.templateId, plan.templateName, true); - const count = counts.get(plan.templateId)!; + const { usage, count } = usageFor(plan.templateId, plan.planDayId, plan.templateName, plan.planDayName, true); for (const item of plan.items) { usage.plannedCalories += item.calories; count.planned++; const entry = item.entryId ? byId.get(item.entryId) : undefined; if (!entry) { - usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) }); - count.removed++; + if (item.replacement) { + usage.deviations.push({ + kind: PlanDeviationKind.NAHRAZENO, + planned: summarize(item, item.calories), + replacementDate: item.replacement.date, + }); + } else { + usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) }); + count.removed++; + } continue; } const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; @@ -317,6 +351,7 @@ function analyzeDay( } const extras: ActivitySummaryItem[] = []; + const replacements: ReplacementUsage[] = []; for (const entry of entries) { if (planned.has(entry.id)) continue; const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; @@ -324,19 +359,35 @@ function analyzeDay( extras.push(summarize(entry, calories)); continue; } - const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false); + const name = templateInfo.get(entry.templateId)?.name ?? DELETED_TEMPLATE; + const dayName = currentDayName(entry.templateId, entry.planDayId); + if (entry.replacesDate) { + replacements.push({ + templateId: entry.templateId, + name, + ...(entry.planDayId ? { planDayId: entry.planDayId } : {}), + ...(dayName ? { dayName } : {}), + sourceDate: entry.replacesDate, + item: summarize(entry, calories), + }); + continue; + } + const { usage, count } = usageFor(entry.templateId, entry.planDayId, name, dayName, false); usage.plannedCalories += calories; usage.actualCalories += calories; - counts.get(entry.templateId)!.planned++; + count.planned++; } const templates = Array.from(usages.values()); const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0); + const replacementCalories = replacements.reduce((sum, item) => sum + item.item.calories, 0); return { day: { date, - totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories, + totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + + replacementCalories + extraCalories, templates, + replacements, extraCalories, extras, }, @@ -349,7 +400,9 @@ function analyzeDay( * * Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při * použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla - * mezitím změnit. + * mezitím změnit. Plány (fixní a plovoucí šablony) se dělí po dnech. Dny, + * kdy podle plánu bylo volno nebo se necvičilo, se nijak nezapisují — jsou + * to mezery, ne záznamy. * * @param login přihlašovací jméno uživatele * @param from první den období (včetně), YYYY-MM-DD @@ -365,7 +418,10 @@ export async function getActivityStats(login: string, from: string, to: string): Promise.all(dates.map(date => getWorkoutPlans(login, date))), getWorkoutTemplates(login), ]); - const templateNames = new Map(templates.map(template => [template.id, template.name])); + const templateInfo = new Map(templates.map(template => [template.id, { + name: template.name, + dayNames: new Map((template.days ?? []).map(day => [day.id, day.name])), + }])); const byDay = new Map(); const byMonth = new Map(); @@ -382,28 +438,44 @@ export async function getActivityStats(login: string, from: string, to: string): activityDays.forEach((activityDay, index) => { if (!activityDay.entries.length) return; entryCount += activityDay.entries.length; - const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date); + const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateInfo, activityDay.date); days.push(day); + /** Součet použití šablony (u plánu jejího dne) za období. */ + const totalFor = (usage: { templateId: string, name: string, planDayId?: string }): TemplateUsageTotal => { + const key = getUsageKey(usage.templateId, usage.planDayId); + const total = byTemplate.get(key) ?? { + templateId: usage.templateId, name: usage.name, + ...(usage.planDayId ? { planDayId: usage.planDayId } : {}), + dayCount: 0, plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0, + }; + byTemplate.set(key, total); + return total; + }; + let dayTemplate = 0; let dayPlanned = 0; for (const usage of day.templates) { - const count = counts.get(usage.templateId)!; - const total = byTemplate.get(usage.templateId) ?? { - templateId: usage.templateId, name: usage.name, dayCount: 0, - plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0, - }; + const count = counts.get(getUsageKey(usage.templateId, usage.planDayId))!; + const total = totalFor(usage); // Dny jdou vzestupně, takže zůstane název z posledního použití total.name = usage.name; + if (usage.dayName) total.dayName = usage.dayName; total.dayCount++; total.plannedCalories += usage.plannedCalories; total.actualCalories += usage.actualCalories; total.plannedItemCount += count.planned; total.doneItemCount += count.planned - count.removed; - byTemplate.set(usage.templateId, total); dayTemplate += usage.actualCalories; dayPlanned += usage.plannedCalories; } + // Náhrada patří ke dni plánu, ve kterém se cvik vynechal — jeho plán už je započtený tam + for (const replacement of day.replacements) { + const total = totalFor(replacement); + if (!total.dayName && replacement.dayName) total.dayName = replacement.dayName; + total.actualCalories += replacement.item.calories; + dayTemplate += replacement.item.calories; + } for (const item of day.extras) { const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 }; group.calories += item.calories; @@ -432,7 +504,8 @@ export async function getActivityStats(login: string, from: string, to: string): byMonth: sortPeriods(byMonth), byYear: sortPeriods(byYear), byTemplate: Array.from(byTemplate.values()) - .sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')), + .sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs') + || (a.dayName ?? '').localeCompare(b.dayName ?? '', 'cs')), extras: Array.from(extras.values()) .sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')), days, diff --git a/server/src/tests/activities.test.ts b/server/src/tests/activities.test.ts index e68a74d..745e316 100644 --- a/server/src/tests/activities.test.ts +++ b/server/src/tests/activities.test.ts @@ -157,13 +157,13 @@ describe('šablony tréninků', () => { }); it('odmítne šablonu bez názvu nebo bez položek', async () => { - await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název šablony/); + await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název tréninku/); await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/); }); it('hlásí chybu u neexistující šablony', async () => { - await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/); - await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/); + await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/); + await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/); }); it('šablony jsou oddělené mezi uživateli', async () => { diff --git a/server/src/tests/toTemplate.test.ts b/server/src/tests/toTemplate.test.ts index 3c4692b..00a8f7a 100644 --- a/server/src/tests/toTemplate.test.ts +++ b/server/src/tests/toTemplate.test.ts @@ -79,7 +79,7 @@ describe('překlopení aktivit do šablony tréninku', () => { await addActivity(LOGIN, kliky); await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' ')) - .rejects.toThrow(/název šablony/); + .rejects.toThrow(/název tréninku/); }); it('vytvořená šablona jde rovnou použít', async () => { diff --git a/server/src/tests/workoutComposition.test.ts b/server/src/tests/workoutComposition.test.ts index b2577d3..eb8ac0c 100644 --- a/server/src/tests/workoutComposition.test.ts +++ b/server/src/tests/workoutComposition.test.ts @@ -102,19 +102,19 @@ describe('skládání šablon z jiných šablon', () => { 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/); + .rejects.toThrow(/sám sebe/); }); it('nedovolí odkaz na neexistující šablonu', async () => { await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')])) - .rejects.toThrow(/nebyla nalezena/); + .rejects.toThrow(/nebyl nalezen/); }); 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/); + await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/Nelze smazat/); }); it('šablonu bez vazeb smazat lze', async () => { diff --git a/server/src/tests/workoutPlanSchedule.test.ts b/server/src/tests/workoutPlanSchedule.test.ts new file mode 100644 index 0000000..6683c93 --- /dev/null +++ b/server/src/tests/workoutPlanSchedule.test.ts @@ -0,0 +1,409 @@ +process.env.CALORIE_PROVIDER = 'none'; + +import { addActivity, deleteActivity, getActivityDay, updateActivity } from "../activities"; +import { + applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate, +} from "../workouts"; +import { getPendingReplacements, getPlannedWorkouts, replaceSkippedExercise } from "../workoutSchedule"; +import { getWorkoutPlans } from "../workoutPlans"; +import { getActivityStats } from "../statsService"; +import { getDayOverview } from "../dayOverview"; +import { resetMemoryStorage } from "../storage/memory"; +import getStorage from "../storage"; +import { formatDate, shiftIsoDate } from "../utils"; +import { + ActivityUnit, PlanDeviationKind, PlannedWorkoutStatus, WorkoutItemKind, WorkoutTemplateType, +} from "../../../types/gen/types.gen"; + +const LOGIN = 'tester'; +/** Pondělí — fixní plán se řídí dny v týdnu. */ +const START = '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, +}); + +/** Datum posunuté o daný počet dní od začátku plánu. */ +const day = (offset: number): string => shiftIsoDate(START, offset); + +/** + * Založí plovoucí plán se třemi dny (po dvou cvicích) a lhůtou náhrady 2 dny. + * Den 2 má Kliky 100 × 0,5 kcal a Nohy 100 × 0,3 kcal. + */ +async function createFloating(startDate = START, replaceWithinDays: number | null = 2) { + const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, { + type: WorkoutTemplateType.PLOVOUCI, + startDate, + replaceWithinDays, + days: [ + { name: 'Den 1', items: [cvik('Hrudník', 40, 40), cvik('Biceps', 20, 20)] }, + { name: 'Den 2', items: [cvik('Kliky', 100, 50), cvik('Nohy', 100, 30)] }, + { name: 'Den 3', items: [cvik('Ramena', 40, 40), cvik('Břicho', 20, 25)] }, + ], + }); + return templates.find(template => template.name === 'Plán')!; +} + +/** Potvrdí den plánu (podle pořadí dne v plánu) v zadaném datu. */ +async function confirm(planId: string, dayIndex: number, date: string, doneIndexes?: number[]) { + const plan = (await getWorkoutTemplates(LOGIN)).find(template => template.id === planId)!; + await applyWorkoutTemplate(LOGIN, planId, date, plan.days![dayIndex].id, doneIndexes); +} + +/** Co plán navrhuje v daný den. */ +const proposal = async (date: string) => (await getPlannedWorkouts(LOGIN, date))[0]; + +beforeEach(() => resetMemoryStorage()); + +describe('typy šablon', () => { + + it('šablona uložená bez typu je volná', async () => { + await getStorage().setData(`workouts:${LOGIN}`, [{ id: 'stara', name: 'Stará', items: [cvik('Kliky', 10, 50)] }]); + const [template] = await getWorkoutTemplates(LOGIN); + expect(template.type).toBe(WorkoutTemplateType.VOLNY); + expect(template.days).toBeUndefined(); + }); + + it('plán nemá položky, dny si nechá v pořadí a doplní jim id a odhad', async () => { + const plan = await createFloating(); + expect(plan.type).toBe(WorkoutTemplateType.PLOVOUCI); + expect(plan.items).toEqual([]); + expect(plan.days!.map(item => item.name)).toEqual(['Den 1', 'Den 2', 'Den 3']); + expect(plan.days!.every(item => item.id.length > 0)).toBe(true); + // Den 2: 100 × 0,5 + 100 × 0,3 + expect(plan.days![1].estimatedCalories).toBe(80); + expect(plan.replaceWithinDays).toBe(2); + expect(plan.startDate).toBe(START); + }); + + it('úprava plánu zachová id dnů i začátek platnosti', async () => { + const plan = await createFloating(); + const [updated] = await saveWorkoutTemplate(LOGIN, plan.id, 'Plán', undefined, { + type: WorkoutTemplateType.PLOVOUCI, + days: plan.days!.map(item => ({ id: item.id, name: item.name, items: item.items })), + }); + expect(updated.days!.map(item => item.id)).toEqual(plan.days!.map(item => item.id)); + expect(updated.startDate).toBe(START); + expect(updated.replaceWithinDays).toBeUndefined(); + }); + + it('fixní plán seřadí dny od pondělí a pojmenuje je podle dne v týdnu', async () => { + const [plan] = await saveWorkoutTemplate(LOGIN, undefined, 'Týden', undefined, { + type: WorkoutTemplateType.FIXNI, + days: [ + { name: '', weekday: 5, items: [cvik('Běh', 30, 50)] }, + { name: 'Nohy', weekday: 1, items: [cvik('Dřepy', 50, 40)] }, + ], + }); + expect(plan.days!.map(item => [item.weekday, item.name])).toEqual([[1, 'Nohy'], [5, 'Pátek']]); + }); + + it('odmítne nesmyslný plán', async () => { + const save = (plan: unknown) => saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, plan); + await expect(save({ type: WorkoutTemplateType.PLOVOUCI, days: [] })).rejects.toThrow(/alespoň jeden tréninkový den/); + await expect(save({ type: WorkoutTemplateType.PLOVOUCI, days: [{ name: 'A', items: [] }] })) + .rejects.toThrow(/alespoň jeden cvik/); + await expect(save({ type: WorkoutTemplateType.FIXNI, days: [{ name: 'A', items: [cvik('X', 1, 1)] }] })) + .rejects.toThrow(/den v týdnu/); + await expect(save({ + type: WorkoutTemplateType.FIXNI, + days: [{ name: 'A', weekday: 2, items: [cvik('X', 1, 1)] }, { name: 'B', weekday: 2, items: [cvik('Y', 1, 1)] }], + })).rejects.toThrow(/nejvýše jednou/); + await expect(save({ + type: WorkoutTemplateType.PLOVOUCI, replaceWithinDays: 30, days: [{ name: 'A', items: [cvik('X', 1, 1)] }], + })).rejects.toThrow(/Lhůta/); + await expect(save({ type: 'NEZNAMY', days: [] })).rejects.toThrow(/typ tréninku/); + }); + + it('den plánu může zařadit volnou šablonu, ta pak nejde smazat ani změnit na plán', async () => { + const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); + await saveWorkoutTemplate(LOGIN, undefined, 'Plán', undefined, { + type: WorkoutTemplateType.PLOVOUCI, + days: [{ name: 'A', items: [{ kind: WorkoutItemKind.SABLONA, templateRefId: rozcvicka.id }, cvik('Kliky', 10, 50)] }], + }); + + const templates = await getWorkoutTemplates(LOGIN); + expect(templates.find(t => t.id === rozcvicka.id)!.usedInPlanCount).toBe(1); + expect(templates.find(t => t.name === 'Plán')!.days![0].estimatedCalories).toBe(21); + await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/součástí 'Plán'/); + await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', undefined, { + type: WorkoutTemplateType.PLOVOUCI, days: [{ name: 'A', items: [cvik('X', 1, 1)] }], + })).rejects.toThrow(/nejde udělat plán/); + }); + + it('plán do jiné šablony vnořit nelze', async () => { + const plan = await createFloating(); + await expect(saveWorkoutTemplate(LOGIN, undefined, 'Volná', [{ kind: WorkoutItemKind.SABLONA, templateRefId: plan.id }])) + .rejects.toThrow(/je plán/); + }); +}); + +describe('plovoucí plán', () => { + + it('jde den po dni a po posledním dni navrhne volno', async () => { + const plan = await createFloating(); + expect(await proposal(day(0))).toEqual(expect.objectContaining({ + status: PlannedWorkoutStatus.NAVRH, dayName: 'Den 1', restAvailable: false, + })); + await confirm(plan.id, 0, day(0)); + await confirm(plan.id, 1, day(1)); + expect((await proposal(day(2))).dayName).toBe('Den 3'); + await confirm(plan.id, 2, day(2)); + + expect((await proposal(day(3))).status).toBe(PlannedWorkoutStatus.VOLNO); + expect(await proposal(day(4))).toEqual(expect.objectContaining({ status: PlannedWorkoutStatus.NAVRH, dayName: 'Den 1' })); + }); + + it('navrhne cviky dne s dopočtenými kaloriemi a potvrzený den ukáže jako splněný', async () => { + const plan = await createFloating(); + expect((await proposal(day(0))).items).toEqual([ + expect.objectContaining({ name: 'Hrudník', quantity: 40, calories: 16 }), + expect.objectContaining({ name: 'Biceps', quantity: 20, calories: 4 }), + ]); + await confirm(plan.id, 0, day(0)); + expect(await proposal(day(0))).toEqual(expect.objectContaining({ + status: PlannedWorkoutStatus.SPLNENO, dayName: 'Den 1', items: [], + })); + }); + + it('den bez tréninku uprostřed cyklu je posunuté volno, cyklus pak pokračuje bez mezery', async () => { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0)); + await confirm(plan.id, 1, day(1)); + expect((await proposal(day(2))).restAvailable).toBe(true); + // day(2) volno posunuté dopředu + expect(await proposal(day(3))).toEqual(expect.objectContaining({ dayName: 'Den 3', restAvailable: false })); + await confirm(plan.id, 2, day(3)); + expect((await proposal(day(4))).dayName).toBe('Den 1'); + }); + + it('druhý den bez tréninku v cyklu trénink jen odsune', async () => { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0)); + // day(1) posunuté volno, day(2) odsunutí + expect((await proposal(day(3))).dayName).toBe('Den 2'); + }); + + it('před začátkem cyklu se den bez tréninku jen odsouvá', async () => { + await createFloating(); + expect((await proposal(day(5))).dayName).toBe('Den 1'); + }); + + it('vynechání celého dne plán posune na další den', async () => { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0), []); + expect((await getActivityDay(LOGIN, day(0))).entries).toEqual([]); + expect((await proposal(day(1))).dayName).toBe('Den 2'); + }); + + it('po skutečné historii (D1 D2 D3 volno, posunuté volno, náhrady) navrhne správný den', async () => { + const plan = await createFloating('2026-09-21'); + // Data z provozu: 24. a 25. 9. bez tréninku, 2. 10. posunuté volno, 21. 10. Den 2 + const history = [ + '2026-09-21:0', '2026-09-22:1', '2026-09-23:2', '2026-09-26:0', '2026-09-27:1', '2026-09-28:2', + '2026-09-30:0', '2026-10-01:1', '2026-10-03:2', '2026-10-04:0', '2026-10-05:1', '2026-10-06:2', + '2026-10-08:0', '2026-10-09:1', '2026-10-10:2', '2026-10-12:0', '2026-10-13:1', '2026-10-14:2', + '2026-10-16:0', '2026-10-17:1', '2026-10-18:2', '2026-10-20:0', '2026-10-21:1', + ]; + for (const record of history) { + const [date, index] = record.split(':'); + await confirm(plan.id, Number(index), date); + } + expect((await proposal('2026-10-02')).dayName).toBe('Den 3'); + expect((await proposal('2026-10-07')).status).toBe(PlannedWorkoutStatus.VOLNO); + expect((await proposal('2026-10-22')).dayName).toBe('Den 3'); + }); + + it('budoucí den počítá s tím, že se dnešní návrh splní', async () => { + const today = formatDate(new Date()); + await createFloating(today); + expect((await proposal(shiftIsoDate(today, 1))).dayName).toBe('Den 2'); + expect((await proposal(shiftIsoDate(today, 3))).status).toBe(PlannedWorkoutStatus.VOLNO); + }); + + it('nový den přidaný do plánu historii nepřepíše a sekvence pokračuje podle id dnů', async () => { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0)); + await saveWorkoutTemplate(LOGIN, plan.id, 'Plán', undefined, { + type: WorkoutTemplateType.PLOVOUCI, + days: [ + { id: plan.days![0].id, name: 'Den 1', items: [cvik('Hrudník', 60, 40)] }, + { name: 'Den 1b', items: [cvik('Shyby', 10, 100)] }, + ...plan.days!.slice(1).map(item => ({ id: item.id, name: item.name, items: item.items })), + ], + }); + expect((await proposal(day(1))).dayName).toBe('Den 1b'); + expect((await getActivityStats(LOGIN, day(0), day(0))).plannedCalories).toBe(20); + }); + + it('plán, který ještě neplatí, se v přehledu dne nenabízí', async () => { + await createFloating(day(10)); + expect(await getPlannedWorkouts(LOGIN, day(9))).toEqual([]); + }); +}); + +describe('fixní plán', () => { + + it('navrhuje podle dne v týdnu, ostatní dny jsou volno', async () => { + const [plan] = await saveWorkoutTemplate(LOGIN, undefined, 'Týden', undefined, { + type: WorkoutTemplateType.FIXNI, + startDate: START, + days: [ + { name: 'Nohy', weekday: 1, items: [cvik('Dřepy', 50, 40)] }, + { name: 'Běh', weekday: 3, items: [cvik('Běh', 30, 50)] }, + ], + }); + expect((await proposal(day(0))).dayName).toBe('Nohy'); + expect((await proposal(day(1))).status).toBe(PlannedWorkoutStatus.VOLNO); + expect((await proposal(day(2))).dayName).toBe('Běh'); + // Pondělí příští týden zase nohy, i když se tento týden necvičilo + expect((await proposal(day(7))).dayName).toBe('Nohy'); + + await applyWorkoutTemplate(LOGIN, plan.id, day(0), plan.days![0].id); + expect((await proposal(day(0))).status).toBe(PlannedWorkoutStatus.SPLNENO); + }); +}); + +describe('vynechané cviky a jejich náhrada', () => { + + /** Den 2 potvrzený bez nohou. */ + async function skipNohy() { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0)); + await confirm(plan.id, 1, day(1), [0]); + return plan; + } + + it('potvrzení založí jen odcvičené cviky a vynechaný ukáže jako odchylku', async () => { + await skipNohy(); + expect((await getActivityDay(LOGIN, day(1))).entries.map(entry => entry.name)).toEqual(['Kliky']); + + const stats = await getActivityStats(LOGIN, day(1), day(1)); + expect(stats.days[0].templates[0]).toEqual(expect.objectContaining({ + name: 'Plán', dayName: 'Den 2', plannedCalories: 80, actualCalories: 50, + deviations: [expect.objectContaining({ kind: PlanDeviationKind.ODEBRANO })], + })); + }); + + it('vynechaný cvik se nabízí k náhradě jen v lhůtě plánu', async () => { + await skipNohy(); + const [pending] = await getPendingReplacements(LOGIN, day(2)); + expect(pending).toEqual(expect.objectContaining({ + sourceDate: day(1), templateName: 'Plán', dayName: 'Den 2', deadline: day(3), + item: expect.objectContaining({ name: 'Nohy', calories: 30 }), + })); + expect(await getPendingReplacements(LOGIN, day(3))).toHaveLength(1); + expect(await getPendingReplacements(LOGIN, day(4))).toEqual([]); + expect(await getPendingReplacements(LOGIN, day(1))).toEqual([]); + }); + + it('plán bez lhůty náhrady nic nenabízí', async () => { + const plan = await createFloating(START, null); + await confirm(plan.id, 1, day(0), [0]); + expect(await getPendingReplacements(LOGIN, day(1))).toEqual([]); + }); + + it('smazaný cvik z dne plánu jde nahradit také', async () => { + const plan = await createFloating(); + await confirm(plan.id, 1, day(0)); + const nohy = (await getActivityDay(LOGIN, day(0))).entries.find(entry => entry.name === 'Nohy')!; + await deleteActivity(LOGIN, nohy.id, day(0)); + expect((await getPendingReplacements(LOGIN, day(1))).map(item => item.item.name)).toEqual(['Nohy']); + }); + + it('náhrada založí aktivitu, odebere cvik z nabídky a ve statistikách odkáže na den náhrady', async () => { + await skipNohy(); + const [pending] = await getPendingReplacements(LOGIN, day(3)); + await replaceSkippedExercise(LOGIN, day(3), pending.sourceDate, pending.planId, pending.itemIndex); + + const [entry] = (await getActivityDay(LOGIN, day(3))).entries; + expect(entry).toEqual(expect.objectContaining({ name: 'Nohy', calories: 30, replacesDate: day(1) })); + expect(await getPendingReplacements(LOGIN, day(3))).toEqual([]); + + const stats = await getActivityStats(LOGIN, day(0), day(3)); + const source = stats.days.find(item => item.date === day(1))!; + expect(source.templates[0].deviations).toEqual([expect.objectContaining({ + kind: PlanDeviationKind.NAHRAZENO, replacementDate: day(3), + })]); + const target = stats.days.find(item => item.date === day(3))!; + expect(target.replacements).toEqual([expect.objectContaining({ + name: 'Plán', dayName: 'Den 2', sourceDate: day(1), item: expect.objectContaining({ name: 'Nohy', calories: 30 }), + })]); + expect(target.totalCalories).toBe(30); + expect(stats.templateCalories).toBe(stats.plannedCalories); + expect(stats.byTemplate.find(item => item.dayName === 'Den 2')).toEqual(expect.objectContaining({ + plannedItemCount: 2, doneItemCount: 2, plannedCalories: 80, actualCalories: 80, + })); + }); + + it('náhrada v den volna plovoucí plán neposune', async () => { + const plan = await skipNohy(); + await confirm(plan.id, 2, day(2)); + const [pending] = await getPendingReplacements(LOGIN, day(3)); + await replaceSkippedExercise(LOGIN, day(3), pending.sourceDate, pending.planId, pending.itemIndex); + expect((await proposal(day(4))).dayName).toBe('Den 1'); + }); + + it('odmítne náhradu po lhůtě, dvojí náhradu i náhradu odcvičeného cviku', async () => { + await skipNohy(); + const [pending] = await getPendingReplacements(LOGIN, day(2)); + const replace = (date: string, itemIndex = pending.itemIndex) => + replaceSkippedExercise(LOGIN, date, pending.sourceDate, pending.planId, itemIndex); + + await expect(replace(day(4))).rejects.toThrow(/Lhůta/); + await expect(replace(day(1))).rejects.toThrow(/až po dni/); + await expect(replace(day(2), 0)).rejects.toThrow(/odcvičil/); + await replace(day(2)); + await expect(replace(day(3))).rejects.toThrow(/už je nahrazený/); + }); + + it('smazáním náhrady se cvik vrátí mezi vynechané, přesunem se změní datum náhrady', async () => { + await skipNohy(); + const [pending] = await getPendingReplacements(LOGIN, day(2)); + await replaceSkippedExercise(LOGIN, day(2), pending.sourceDate, pending.planId, pending.itemIndex); + const [entry] = (await getActivityDay(LOGIN, day(2))).entries; + + await updateActivity(LOGIN, entry.id, { ...entry, date: day(3) }); + const [plan] = await getWorkoutPlans(LOGIN, day(1)); + expect(plan.items[1].replacement).toEqual({ date: day(3), entryId: entry.id }); + + await deleteActivity(LOGIN, entry.id, day(3)); + expect((await getWorkoutPlans(LOGIN, day(1)))[0].items[1].replacement).toBeUndefined(); + expect(await getPendingReplacements(LOGIN, day(3))).toHaveLength(1); + }); + + it('den plánu vynechaný celý zůstane zaznamenaný i po smazání jiné aktivity dne', async () => { + const plan = await createFloating(); + await confirm(plan.id, 0, day(0), []); + await addActivity(LOGIN, { date: day(0), name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 }); + const [walk] = (await getActivityDay(LOGIN, day(0))).entries; + await deleteActivity(LOGIN, walk.id, day(0)); + + expect(await getWorkoutPlans(LOGIN, day(0))).toHaveLength(1); + expect(await getPendingReplacements(LOGIN, day(1))).toHaveLength(2); + }); + + it('přehled dne nese návrh plánu i cviky k náhradě', async () => { + await skipNohy(); + const overview = await getDayOverview(LOGIN, day(2)); + expect(overview.plannedWorkouts).toEqual([expect.objectContaining({ dayName: 'Den 3' })]); + expect(overview.pendingReplacements.map(item => item.item.name)).toEqual(['Nohy']); + }); + + it('volná šablona potřebuje aspoň jeden vybraný cvik', async () => { + const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Volná', [cvik('Kliky', 10, 50)]); + await expect(applyWorkoutTemplate(LOGIN, template.id, START, undefined, [])).rejects.toThrow(/žádný cvik/); + await expect(applyWorkoutTemplate(LOGIN, template.id, START, undefined, [5])).rejects.toThrow(/neodpovídá/); + }); + + it('u plánu je den povinný', async () => { + const plan = await createFloating(); + await expect(applyWorkoutTemplate(LOGIN, plan.id, START)).rejects.toThrow(/den plánu/); + await expect(applyWorkoutTemplate(LOGIN, plan.id, START, 'neexistuje')).rejects.toThrow(/Den plánu/); + }); +}); diff --git a/server/src/utils.ts b/server/src/utils.ts index 107ac12..db10a87 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -74,6 +74,31 @@ export function listDatesInRange(from: string, to: string): string[] { return dates; } +/** Převede datum YYYY-MM-DD na Date v lokální časové zóně. */ +const parseIsoDate = (date: string): Date => { + const [year, month, day] = date.split('-').map(Number); + return new Date(year, month - 1, day); +}; + +/** + * Posune datum o zadaný počet dní. + * + * @param date datum ve formátu YYYY-MM-DD + * @param days počet dní, záporný posouvá zpět + */ +export const shiftIsoDate = (date: string, days: number): string => { + const result = parseIsoDate(date); + result.setDate(result.getDate() + days); + return formatDate(result); +}; + +/** Vrátí počet dní od `from` do `to`; kladný, pokud je `to` později. */ +export const daysBetween = (from: string, to: string): number => + Math.round((parseIsoDate(to).getTime() - parseIsoDate(from).getTime()) / 86_400_000); + +/** Vrátí den v týdnu podle ISO, 1 = pondělí až 7 = neděle. */ +export const getIsoWeekday = (date: string): number => parseIsoDate(date).getDay() || 7; + /** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */ const MAX_MULTIPLIER = 100; diff --git a/server/src/workoutPlans.ts b/server/src/workoutPlans.ts index e62811e..295cf21 100644 --- a/server/src/workoutPlans.ts +++ b/server/src/workoutPlans.ts @@ -1,22 +1,33 @@ import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen"; import getStorage from "./storage"; +import { NotFoundError } from "./utils"; const storage = getStorage(); +/** Kde a kterou aktivitou se vynechaný cvik nahradil. */ +export type WorkoutPlanReplacement = { + date: string; + entryId: string; +}; + /** Jeden cvik plánu — jak ho šablona založila. */ export type WorkoutPlanItem = { /** * Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni * zůstal a jestli se upravil — podle názvu by to nešlo, šablona může mít - * stejný cvik dvakrát. Chybí jen u plánů dopočtených zpětně z dnešní podoby - * šablony, kde se cvik ve dni nenašel. + * stejný cvik dvakrát. Chybí u cviku, který se při potvrzení dne plánu + * odškrtl jako vynechaný. */ entryId?: string; name: string; unit: ActivityUnit; quantity: number; sets?: number; + caloriesPer100Units?: number; + extraWeightGrams?: number; calories: number; + /** Náhrada vynechaného cviku v některém z dalších dnů. */ + replacement?: WorkoutPlanReplacement; }; /** @@ -24,12 +35,23 @@ export type WorkoutPlanItem = { * * Bez něj by nešlo poznat odchylku od šablony: smazaná aktivita beze stopy zmizí * a šablona se mezitím mohla upravit, takže porovnání s její současnou podobou - * by lhalo. + * by lhalo. U plánů (fixních a plovoucích) je to zároveň záznam, že se daný den + * plánu odcvičil — podle něj se určuje, co je na řadě dál. */ export type WorkoutPlan = { + /** + * Identifikátor záznamu. Starší záznamy ho nemají; {@link getWorkoutPlans} + * jim místo něj doplní čas použití, který se už nemění. + */ + id: string; templateId: string; /** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */ templateName: string; + /** Den plánu; u volné šablony chybí. */ + planDayId?: string; + planDayName?: string; + /** Lhůta pro náhradu vynechaných cviků, jak ji plán měl v den použití. */ + replaceWithinDays?: number; appliedAt: string; items: WorkoutPlanItem[]; }; @@ -37,6 +59,16 @@ export type WorkoutPlan = { /** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */ const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`; +/** Vrátí cvik s novou náhradou, nebo bez náhrady. */ +const withReplacement = (item: WorkoutPlanItem, replacement?: WorkoutPlanReplacement): WorkoutPlanItem => { + const result = { ...item }; + delete result.replacement; + return replacement ? { ...result, replacement } : result; +}; + +/** Doplní záznamu identifikátor, pokud ho ze starší verze nemá. */ +const withId = (plan: WorkoutPlan): WorkoutPlan => ({ ...plan, id: plan.id ?? plan.appliedAt }); + /** * Vrátí plány šablon použitých v daném dni. * @@ -44,7 +76,23 @@ const getKey = (login: string, date: string): string => `workoutPlans:${login}:$ * @param date datum ve formátu YYYY-MM-DD */ export const getWorkoutPlans = async (login: string, date: string): Promise => - await storage.getData(getKey(login, date)) ?? []; + (await storage.getData(getKey(login, date)) ?? []).map(withId); + +/** + * Vrátí data všech dnů, ve kterých má uživatel nějaký plán šablony, vzestupně. + * + * Výpočet plovoucího plánu prochází historii od začátku platnosti plánu — + * dny bez záznamu se tak nemusí načítat jeden po druhém. + * + * @param login přihlašovací jméno uživatele + */ +export const listWorkoutPlanDates = async (login: string): Promise => { + const prefix = `workoutPlans:${login}:`; + return (await storage.listKeys(prefix)) + .filter(key => key.startsWith(prefix)) + .map(key => key.substring(prefix.length)) + .sort((a, b) => a.localeCompare(b)); +}; /** * Zapamatuje si plán použité šablony. @@ -57,6 +105,56 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko await storage.updateData(getKey(login, date), current => [...(current ?? []), plan]); }; +/** + * Zapíše k cviku záznamu, čím se nahradil, nebo náhradu zruší. + * + * @param login přihlašovací jméno uživatele + * @param date den záznamu, ve kterém se cvik vynechal + * @param planId identifikátor záznamu + * @param itemIndex pořadí cviku v záznamu + * @param replacement náhrada, nebo nic pro její zrušení + */ +export const setPlanItemReplacement = async ( + login: string, date: string, planId: string, itemIndex: number, replacement?: WorkoutPlanReplacement, +): Promise => { + let found = false; + await storage.updateData(getKey(login, date), current => (current ?? []).map(plan => { + if (withId(plan).id !== planId || !plan.items[itemIndex]) return plan; + found = true; + return { + ...plan, + items: plan.items.map((item, index) => (index === itemIndex ? withReplacement(item, replacement) : item)), + }; + })); + if (!found) { + throw new NotFoundError("Záznam o použití plánu nebyl nalezen"); + } +}; + +/** + * Upraví vazbu na náhradu, když se náhradní aktivita smaže nebo přesune. + * + * Smazáním náhrady se cvik v původním dni znovu stane vynechaným (a v lhůtě + * se zase nabídne k náhradě); přesunem se jen přepíše datum náhrady. + * + * @param login přihlašovací jméno uživatele + * @param sourceDate den, ve kterém se nahrazený cvik vynechal + * @param entryId náhradní aktivita + * @param newDate nové datum náhradní aktivity, nebo nic, pokud se smazala + */ +export const updateReplacementLink = async ( + login: string, sourceDate: string, entryId: string, newDate?: string, +): Promise => { + const key = getKey(login, sourceDate); + if (!await storage.hasData(key)) return; + await storage.updateData(key, current => (current ?? []).map(plan => ({ + ...plan, + items: plan.items.map(item => (item.replacement?.entryId === entryId + ? withReplacement(item, newDate ? { entryId, date: newDate } : undefined) + : item)), + }))); +}; + /** * Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita. * @@ -64,6 +162,10 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko * neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve * statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá. * + * Zůstává záznam dne plánu, který se potvrdil celý jako vynechaný (žádný cvik + * z něj aktivitu nezaložil), a záznam, jehož některý cvik se už nahradil — + * na ten odkazuje náhradní aktivita. + * * @param login přihlašovací jméno uživatele * @param date datum dne * @param remaining aktivity, které ve dni zůstaly @@ -72,6 +174,7 @@ export const dropOrphanedPlans = async (login: string, date: string, remaining: const key = getKey(login, date); if (!await storage.hasData(key)) return; const ids = new Set(remaining.map(entry => entry.id)); - await storage.updateData(key, - current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId)))); + await storage.updateData(key, current => (current ?? []).filter(plan => + plan.items.some(item => (item.entryId && ids.has(item.entryId)) || item.replacement) + || plan.items.every(item => !item.entryId))); }; diff --git a/server/src/workoutSchedule.ts b/server/src/workoutSchedule.ts new file mode 100644 index 0000000..e4a1f71 --- /dev/null +++ b/server/src/workoutSchedule.ts @@ -0,0 +1,264 @@ +import { + PendingReplacement, PlannedWorkout, PlannedWorkoutStatus, WorkoutTemplateType, +} from "../../types/gen/types.gen"; +import { addActivities, getActivityDay } from "./activities"; +import { + MAX_REPLACE_DAYS, StoredPlanDay, StoredTemplate, createActivityFromExercise, expandExercises, getTemplateType, + isPlan, loadTemplates, toPlannedItem, +} from "./workouts"; +import { WorkoutPlan, getWorkoutPlans, listWorkoutPlanDates, setPlanItemReplacement } from "./workoutPlans"; +import { + BadRequestError, NotFoundError, daysBetween, formatDate, getIsoWeekday, getToday, isValidIsoDate, + listDatesInRange, shiftIsoDate, +} from "./utils"; + +/** + * Kde se plovoucí plán nachází. + * + * `position` je pořadí dne, který je na řadě; hodnota rovná počtu dnů znamená, + * že je na řadě den volna na konci cyklu. `restUsed` říká, že volno tohoto + * cyklu už proběhlo dřív (posunuté) — po posledním dni pak cyklus začne hned. + */ +type CycleState = { position: number, restUsed: boolean }; + +/** + * Posune plovoucí plán po odcvičeném dni sekvence. + * + * Návrat na dřívější den (třeba začátek cyklu místo volna) zahájí nový cyklus. + * Pokud cyklus skončil a volno už v něm bylo, další den začne rovnou od začátku. + */ +const advanceDone = (state: CycleState, dayIndex: number, dayCount: number): CycleState => { + const restUsed = dayIndex < state.position ? false : state.restUsed; + const position = dayIndex + 1; + return position === dayCount && restUsed ? { position: 0, restUsed: false } : { position, restUsed }; +}; + +/** + * Posune plovoucí plán po dni bez tréninku. + * + * Když je na řadě volno, proběhlo podle plánu. Uprostřed rozběhnutého cyklu + * se den bez tréninku bere jako volno posunuté dopředu, jednou za cyklus. + * Jinak (cyklus ještě nezačal, nebo volno už bylo) se trénink jen odsouvá — + * plovoucí plán na kalendář navázaný není. + */ +const advanceGap = (state: CycleState, dayCount: number): CycleState => { + if (state.position === dayCount) return { position: 0, restUsed: false }; + if (state.position > 0 && !state.restUsed) return { ...state, restUsed: true }; + return state; +}; + +/** Záznamy použití daného plánu v jednom dni (jen potvrzené dny plánu). */ +const getPlanRecords = (records: WorkoutPlan[] | undefined, templateId: string): WorkoutPlan[] => + (records ?? []).filter(record => record.templateId === templateId && !!record.planDayId); + +/** Navržený den plánu s rozbalenými cviky. */ +const proposeDay = ( + template: StoredTemplate, day: StoredPlanDay, byId: Map, +): Pick => ({ + status: PlannedWorkoutStatus.NAVRH, + dayId: day.id, + dayName: day.name, + items: expandExercises(day.items, byId).map(toPlannedItem), +}); + +/** + * Spočítá, kde je plovoucí plán na začátku zadaného dne. + * + * Prochází dny od začátku platnosti plánu. Odcvičené dny posouvají sekvenci, + * dny bez tréninku se vyhodnotí jako volno, nebo odsunutí. Budoucí dny (od + * dneška dál) bez záznamu se berou, jako by se navržený trénink odcvičil — + * přehled zítřka tak počítá s tím, že se dnešek splní. + */ +const getCycleState = ( + template: StoredTemplate, date: string, recordsByDate: Map, today: string, +): CycleState => { + const days = template.days ?? []; + let state: CycleState = { position: 0, restUsed: false }; + if (template.startDate! >= date) return state; + + for (const current of listDatesInRange(template.startDate!, shiftIsoDate(date, -1))) { + const records = getPlanRecords(recordsByDate.get(current), template.id); + if (records.length) { + for (const record of records) { + const index = days.findIndex(day => day.id === record.planDayId); + // Den, který se mezitím z plánu odebral, sekvenci neposouvá + if (index >= 0) state = advanceDone(state, index, days.length); + } + } else if (current >= today && state.position < days.length) { + state = advanceDone(state, state.position, days.length); + } else { + state = advanceGap(state, days.length); + } + } + return state; +}; + +/** Sestaví návrh jednoho plánu na zadaný den. */ +const planWorkout = ( + template: StoredTemplate, + date: string, + recordsByDate: Map, + today: string, + byId: Map, +): PlannedWorkout => { + const type = getTemplateType(template); + const base = { templateId: template.id, templateName: template.name, type }; + const days = template.days ?? []; + + const done = getPlanRecords(recordsByDate.get(date), template.id); + if (done.length) { + return { + ...base, + status: PlannedWorkoutStatus.SPLNENO, + dayId: done[0].planDayId, + dayName: done.map(record => record.planDayName).filter(name => !!name).join(', '), + items: [], + }; + } + + if (type === WorkoutTemplateType.FIXNI) { + const day = days.find(item => item.weekday === getIsoWeekday(date)); + return day + ? { ...base, ...proposeDay(template, day, byId) } + : { ...base, status: PlannedWorkoutStatus.VOLNO, items: [] }; + } + + const state = getCycleState(template, date, recordsByDate, today); + if (state.position >= days.length) { + return { ...base, status: PlannedWorkoutStatus.VOLNO, items: [] }; + } + return { + ...base, + ...proposeDay(template, days[state.position], byId), + restAvailable: state.position > 0 && !state.restUsed, + }; +}; + +/** + * Vrátí, co na zadaný den navrhují plány (fixní a plovoucí šablony), které + * už platí. U dne, ve kterém se plán potvrdil, vrátí jeho splnění. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +export const getPlannedWorkouts = async (login: string, date: string): Promise => { + const all = await loadTemplates(login); + const plans = all + .filter(template => isPlan(template) && template.startDate! <= date) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + if (!plans.length) return []; + + const earliest = plans.reduce((min, plan) => (plan.startDate! < min ? plan.startDate! : min), date); + const recordDates = (await listWorkoutPlanDates(login)).filter(day => day >= earliest && day <= date); + const recordsByDate = new Map(await Promise.all( + recordDates.map(async day => [day, await getWorkoutPlans(login, day)] as const))); + const byId = new Map(all.map(template => [template.id, template])); + const today = formatDate(getToday()); + return plans.map(plan => planWorkout(plan, date, recordsByDate, today, byId)); +}; + +/** + * Vrátí cviky vynechané v předchozích dnech, které jde v zadaný den ještě nahradit. + * + * Vynechaný je cvik dne plánu, který se při potvrzení odškrtl, nebo jehož + * aktivita se ze dne smazala, a ještě se nenahradil. Lhůta se bere ze + * záznamu — jaká platila v den, kdy se cvik vynechal. Nejstarší první, ty mají + * lhůtu nejkratší. + * + * @param login přihlašovací jméno uživatele + * @param date datum ve formátu YYYY-MM-DD + */ +export const getPendingReplacements = async (login: string, date: string): Promise => { + const from = shiftIsoDate(date, -MAX_REPLACE_DAYS); + const recordDates = (await listWorkoutPlanDates(login)).filter(day => day >= from && day < date); + const result: PendingReplacement[] = []; + + for (const sourceDate of recordDates) { + const offset = daysBetween(sourceDate, date); + const plans = (await getWorkoutPlans(login, sourceDate)) + .filter(plan => plan.replaceWithinDays && offset <= plan.replaceWithinDays); + if (!plans.length) continue; + const entryIds = new Set((await getActivityDay(login, sourceDate)).entries.map(entry => entry.id)); + + for (const plan of plans) { + plan.items.forEach((item, itemIndex) => { + if (item.replacement || (item.entryId && entryIds.has(item.entryId))) return; + result.push({ + sourceDate, + planId: plan.id, + itemIndex, + templateId: plan.templateId, + templateName: plan.templateName, + ...(plan.planDayName ? { dayName: plan.planDayName } : {}), + deadline: shiftIsoDate(sourceDate, plan.replaceWithinDays!), + item: { + name: item.name, + unit: item.unit, + quantity: item.quantity, + ...(item.sets != null ? { sets: item.sets } : {}), + ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), + ...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}), + calories: item.calories, + }, + }); + }); + } + } + return result; +}; + +/** + * Nahradí cvik vynechaný v předchozím dni plánu. + * + * Do zvoleného dne založí aktivitu podle cviku z plánu (s vazbou na plán + * a na nahrazený den) a v záznamu původního dne cvik označí jako nahrazený. + * Ve statistikách se pak u původního dne místo vynechání ukáže odkaz na náhradu. + * + * @param login přihlašovací jméno uživatele + * @param date den, do kterého se náhrada založí + * @param sourceDate den, ve kterém se cvik vynechal + * @param planId identifikátor záznamu o použití plánu + * @param itemIndex pořadí cviku v záznamu + * @returns datum, do kterého se náhrada založila + */ +export const replaceSkippedExercise = async ( + login: string, date: unknown, sourceDate: unknown, planId: unknown, itemIndex: unknown, +): Promise => { + if (!isValidIsoDate(date) || !isValidIsoDate(sourceDate)) { + throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); + } + if (typeof planId !== 'string' || !planId.length || !Number.isInteger(itemIndex)) { + throw new BadRequestError("Nebyl předán nahrazovaný cvik"); + } + const offset = daysBetween(sourceDate, date); + if (offset <= 0) { + throw new BadRequestError("Náhrada musí být až po dni, ve kterém se cvik vynechal"); + } + + const plan = (await getWorkoutPlans(login, sourceDate)).find(item => item.id === planId); + const item = plan?.items[itemIndex as number]; + if (!plan || !item) { + throw new NotFoundError("Vynechaný cvik nebyl nalezen"); + } + if (!plan.replaceWithinDays) { + throw new BadRequestError("Tento plán náhrady vynechaných cviků nenabízí"); + } + if (offset > plan.replaceWithinDays) { + throw new BadRequestError(`Lhůta pro náhradu (${plan.replaceWithinDays} dní) už uplynula`); + } + if (item.replacement) { + throw new BadRequestError("Cvik už je nahrazený"); + } + if (item.entryId && (await getActivityDay(login, sourceDate)).entries.some(entry => entry.id === item.entryId)) { + throw new BadRequestError("Cvik se v původním dni odcvičil, není co nahrazovat"); + } + + const entry = createActivityFromExercise(item, date, new Date().toISOString(), { + templateId: plan.templateId, + ...(plan.planDayId ? { planDayId: plan.planDayId } : {}), + replacesDate: sourceDate, + }); + await addActivities(login, date, [entry]); + await setPlanItemReplacement(login, sourceDate, plan.id, itemIndex as number, { date, entryId: entry.id }); + return date; +}; diff --git a/server/src/workouts.ts b/server/src/workouts.ts index 5fe3563..9047923 100644 --- a/server/src/workouts.ts +++ b/server/src/workouts.ts @@ -1,85 +1,172 @@ import { randomUUID } from 'crypto'; import { - ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, + ActivityEntry, ActivityUnit, PlannedWorkoutItem, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate, + WorkoutTemplateItem, WorkoutTemplateType, } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities"; -import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils"; +import { BadRequestError, NotFoundError, formatDate, getToday, isValidIsoDate } from "./utils"; import { recordWorkoutPlan } from "./workoutPlans"; const storage = getStorage(); -/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */ +/** Maximální počet položek jedné šablony nebo dne plánu — pojistka proti nesmyslnému vstupu. */ const MAX_ITEMS = 50; +/** Maximální počet dnů plovoucího plánu. Fixní jich má nejvýš sedm, jeden na den v týdnu. */ +const MAX_PLAN_DAYS = 14; + +/** Nejdelší povolená lhůta pro náhradu vynechaného cviku ve dnech. */ +export const MAX_REPLACE_DAYS = 14; + +/** Názvy dnů v týdnu pro výchozí název dne fixního plánu, od pondělí. */ +const WEEKDAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle']; + /** Klíč úložiště se šablonami tréninků jednoho uživatele. */ const getKey = (login: string): string => `workouts:${login}`; -/** Uložená podoba šablony — dopočítávaná pole se neukládají. */ -type StoredTemplate = { +/** Uložený den plánu — dopočítávaná pole se neukládají. */ +export type StoredPlanDay = { id: string; name: string; + weekday?: number; items: WorkoutTemplateItem[]; }; +/** + * Uložená podoba šablony — dopočítávaná pole se neukládají. + * + * Volný typ je výchozí a nezapisuje se, takže šablony uložené dřív, než typy + * přibyly, jsou volné. Plán položky nemá, cviky má ve dnech. + */ +export type StoredTemplate = { + id: string; + name: string; + type?: WorkoutTemplateType; + items: WorkoutTemplateItem[]; + days?: StoredPlanDay[]; + replaceWithinDays?: number; + startDate?: string; +}; + /** Vrátí true, pokud je položka odkazem na jinou šablonu. */ const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA; +/** Vrátí typ šablony; starší šablony typ nemají a jsou volné. */ +export const getTemplateType = (template: StoredTemplate): WorkoutTemplateType => + template.type ?? WorkoutTemplateType.VOLNY; + +/** Vrátí true, pokud je šablona plánem (fixním nebo plovoucím), ne volnou šablonou. */ +export const isPlan = (template: StoredTemplate): boolean => getTemplateType(template) !== WorkoutTemplateType.VOLNY; + /** * 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 => ({ +const withKind = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => + items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })); + +/** Znormalizuje šablonu načtenou z úložiště. */ +const fromStorage = (template: StoredTemplate): StoredTemplate => ({ ...template, - items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })), + items: withKind(template.items ?? []), + ...(template.days ? { days: template.days.map(day => ({ ...day, items: withKind(day.items) })) } : {}), }); /** 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)); +/** + * Rozbalí odkazy na jiné šablony na jejich cviky. + * + * Volné šablony se zanořují nejvýš o jednu úroveň a den plánu může odkázat + * na volnou šablonu, která sama něco skládá — hloubka je tak nejvýš dvě. + * Pojistka hloubky chrání jen před poškozenými daty. + * + * @param items položky šablony nebo dne plánu + * @param byId všechny šablony uživatele podle id + */ +export const expandExercises = ( + items: WorkoutTemplateItem[], byId: Map, depth = 0, +): WorkoutTemplateItem[] => items.flatMap(item => { + if (!isTemplateRef(item)) return [item]; + const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined; + return nested && depth < 2 ? expandExercises(nested.items, byId, depth + 1) : []; +}); + +/** Sečte odhad kalorií položek včetně vnořených šablon. */ +const sumCalories = (items: WorkoutTemplateItem[], byId: Map): number => + expandExercises(items, byId).reduce((sum, item) => sum + itemCalories(item), 0); + +/** Převede cvik šablony na položku navrženého dne s dopočtenými kaloriemi. */ +export const toPlannedItem = (item: WorkoutTemplateItem): PlannedWorkoutItem => ({ + name: item.name!, + unit: item.unit!, + quantity: item.quantity!, + ...(item.sets != null ? { sets: item.sets } : {}), + ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), + ...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}), + calories: itemCalories(item), +}); + +/** Vrátí true, pokud položky odkazují na šablonu s daným id. */ +const refersTo = (items: WorkoutTemplateItem[], id: string): boolean => + items.some(item => isTemplateRef(item) && item.templateRefId === id); + +/** Volné šablony, které danou šablonu skládají. */ +const findNestingTemplates = (id: string, all: StoredTemplate[]): StoredTemplate[] => + all.filter(template => template.id !== id && !isPlan(template) && refersTo(template.items, id)); + +/** Plány, které danou šablonu zařazují do některého dne. */ +const findUsingPlans = (id: string, all: StoredTemplate[]): StoredTemplate[] => + all.filter(template => isPlan(template) && (template.days ?? []).some(day => refersTo(day.items, id))); + /** * 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 deriveItems = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => items.map(item => { + if (!isTemplateRef(item)) return item; 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, + templateRefCalories: nested ? sumCalories(nested.items, byId) : 0, }; }); + const days: WorkoutPlanDay[] | undefined = template.days?.map(day => ({ + ...day, + items: deriveItems(day.items), + estimatedCalories: sumCalories(day.items, byId), + })); + 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, + type: getTemplateType(template), + items: deriveItems(template.items), + ...(days ? { days } : {}), + ...(template.replaceWithinDays != null ? { replaceWithinDays: template.replaceWithinDays } : {}), + ...(template.startDate ? { startDate: template.startDate } : {}), + estimatedCalories: days + ? days.reduce((sum, day) => sum + day.estimatedCalories, 0) + : sumCalories(template.items, byId), + hasNested: template.items.some(isTemplateRef), + usedByCount: findNestingTemplates(template.id, all).length, + usedInPlanCount: findUsingPlans(template.id, all).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); +export const loadTemplates = async (login: string): Promise => + (await storage.getData(getKey(login)) ?? []).map(fromStorage); /** * Vrátí šablony tréninků uživatele seřazené podle názvu. @@ -100,7 +187,7 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => { if (kind === WorkoutItemKind.SABLONA) { if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) { - throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`); + throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádný trénink`); } // Název a odhad kalorií vnořené šablony si server dopočítá sám return { kind, templateRefId: value.templateRefId }; @@ -137,98 +224,204 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => { }; }; +/** Ověří a znormalizuje seznam položek šablony nebo dne plánu. */ +const normalizeItems = (items: unknown, emptyMessage: string): WorkoutTemplateItem[] => { + if (!Array.isArray(items) || !items.length) { + throw new BadRequestError(emptyMessage); + } + if (items.length > MAX_ITEMS) { + throw new BadRequestError(`Trénink může mít nejvýše ${MAX_ITEMS} položek`); + } + return items.map(normalizeItem); +}; + +/** Ověří typ šablony; nevyplněný znamená volnou. */ +const normalizeType = (value: unknown): WorkoutTemplateType => { + if (value == null) return WorkoutTemplateType.VOLNY; + if (!(Object.values(WorkoutTemplateType) as unknown[]).includes(value)) { + throw new BadRequestError("Neplatný typ tréninku"); + } + return value as WorkoutTemplateType; +}; + /** - * Ověří, že se zanořením šablon nepřekročí jedna úroveň. + * Ověří a znormalizuje dny plánu. * + * Fixní plán má u každého dne den v týdnu (každý nejvýš jednou) a dny řadí + * od pondělí. Plovoucí je bez dnů v týdnu a zachová pořadí, ve kterém se cvičí. + * Den bez názvu dostane výchozí — pořadí, nebo název dne v týdnu. + */ +const normalizeDays = (raw: unknown, type: WorkoutTemplateType): StoredPlanDay[] => { + if (!Array.isArray(raw) || !raw.length) { + throw new BadRequestError("Plán musí mít alespoň jeden tréninkový den"); + } + const isFixed = type === WorkoutTemplateType.FIXNI; + if (raw.length > (isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS)) { + throw new BadRequestError(`Plán může mít nejvýše ${isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS} dnů`); + } + + const days = raw.map((rawDay, index): StoredPlanDay => { + const value = (rawDay ?? {}) as Record; + const weekday = value.weekday; + if (isFixed && (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 1 || weekday > 7)) { + throw new BadRequestError(`Den ${index + 1} nemá platný den v týdnu`); + } + const name = typeof value.name === 'string' ? value.name.trim() : ''; + const defaultName = isFixed ? WEEKDAY_NAMES[(weekday as number) - 1] : `Den ${index + 1}`; + return { + id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(), + name: name || defaultName, + ...(isFixed ? { weekday: weekday as number } : {}), + items: normalizeItems(value.items, `Den '${name || defaultName}' musí mít alespoň jeden cvik`), + }; + }); + + if (new Set(days.map(day => day.id)).size !== days.length) { + throw new BadRequestError("Dny plánu mají duplicitní identifikátor"); + } + if (isFixed) { + if (new Set(days.map(day => day.weekday)).size !== days.length) { + throw new BadRequestError("Každý den v týdnu může být v plánu nejvýše jednou"); + } + days.sort((a, b) => a.weekday! - b.weekday!); + } + return days; +}; + +/** Ověří lhůtu pro náhradu vynechaných cviků; nevyplněná znamená, že se náhrady nenabízejí. */ +const normalizeReplaceWithinDays = (value: unknown): number | undefined => { + if (value == null || value === 0) return undefined; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > MAX_REPLACE_DAYS) { + throw new BadRequestError(`Lhůta pro náhradu musí být 1 až ${MAX_REPLACE_DAYS} dní`); + } + return value; +}; + +/** + * Ověří odkazy šablony na jiné šablony. + * + * Odkazovat jde jen na volnou šablonu — plán do jiné šablony vnořit nelze. + * U volné šablony se navíc hlídá, aby zanoření nepřekročilo jednu ú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á. + * nesmí sama začít vnořovat, pokud ji už jiná volná šablona používá. + * Plán úroveň zanoření nepřidává, den plánu tak smí odkázat na libovolnou + * volnou šablonu. */ -const checkNesting = (id: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => { - const byId = new Map(all.map(template => [template.id, template])); - const nestedRefs = items.filter(isTemplateRef); +const checkReferences = (template: StoredTemplate, all: StoredTemplate[]): void => { + const byId = new Map(all.map(item => [item.id, item])); + const plan = isPlan(template); + const refs = [...template.items, ...(template.days ?? []).flatMap(day => day.items)].filter(isTemplateRef); - for (const item of nestedRefs) { - if (item.templateRefId === id) { - throw new BadRequestError("Šablona nemůže odkazovat sama na sebe"); + for (const item of refs) { + if (item.templateRefId === template.id) { + throw new BadRequestError("Trénink nemůže obsahovat sám sebe"); } const nested = byId.get(item.templateRefId!); if (!nested) { - throw new NotFoundError("Vnořená šablona nebyla nalezena"); + throw new NotFoundError("Vložený trénink nebyl nalezen"); } - if (nested.items.some(isTemplateRef)) { + if (isPlan(nested)) { + throw new BadRequestError(`'${nested.name}' je plán, do jiného tréninku ho vložit nelze`); + } + if (!plan && 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ň`); + `Trénink '${nested.name}' už sám obsahuje jiné tréninky, nelze ho vložit — vkládání 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ň`); - } + const [usedBy] = findNestingTemplates(template.id, all); + const [usedInPlan] = findUsingPlans(template.id, all); + if (plan && (usedBy || usedInPlan)) { + throw new BadRequestError( + `Trénink je součástí '${(usedBy ?? usedInPlan).name}', takže z něj nejde udělat plán`); + } + if (!plan && refs.length && usedBy) { + throw new BadRequestError( + `Tento trénink už je vložený do '${usedBy.name}', takže do něj nelze vložit další — vkládání je povolené jen na jednu úroveň`); } }; /** * Založí novou šablonu, nebo přepíše existující podle předaného id. * + * Volná šablona má položky, plán (fixní nebo plovoucí) dny. Úprava plánu + * historii nemění — co se kdy odcvičilo, drží záznamy použití (`workoutPlans.ts`). + * * @param login přihlašovací jméno uživatele * @param id identifikátor upravované šablony, nebo nic pro založení nové * @param name název šablony - * @param items položky šablony + * @param items položky volné šablony + * @param plan typ a u plánu jeho dny, lhůta pro náhradu a začátek platnosti */ export const saveWorkoutTemplate = async ( login: string, id: unknown, name: unknown, items: unknown, + plan?: unknown, ): Promise => { if (typeof name !== 'string' || !name.trim().length) { - throw new BadRequestError("Nebyl předán název šablony"); - } - if (!Array.isArray(items) || !items.length) { - throw new BadRequestError("Šablona musí mít alespoň jednu položku"); - } - if (items.length > MAX_ITEMS) { - throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`); + throw new BadRequestError("Nebyl předán název tréninku"); } + const options = (plan ?? {}) as Record; + const type = normalizeType(options.type); + const all = await loadTemplates(login); + const templateId = typeof id === 'string' && id.length ? id : randomUUID(); + const existing = all.find(template => template.id === templateId); - 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)); + let normalized: StoredTemplate; + if (type === WorkoutTemplateType.VOLNY) { + normalized = { + id: templateId, + name: name.trim(), + items: normalizeItems(items, "Trénink musí mít alespoň jednu položku"), + }; + } else { + if (options.startDate != null && !isValidIsoDate(options.startDate)) { + throw new BadRequestError("Začátek platnosti plánu není platné datum ve formátu YYYY-MM-DD"); + } + const replaceWithinDays = normalizeReplaceWithinDays(options.replaceWithinDays); + normalized = { + id: templateId, + name: name.trim(), + type, + items: [], + days: normalizeDays(options.days, type), + ...(replaceWithinDays ? { replaceWithinDays } : {}), + startDate: (options.startDate as string | undefined) ?? existing?.startDate ?? formatDate(getToday()), + }; + } + checkReferences(normalized, all); + // Plán položky nemá, prázdný seznam se do úložiště nezapisuje + const toStore: Partial = { ...normalized }; + if (isPlan(normalized)) delete toStore.items; await storage.updateData(getKey(login), current => { const others = (current ?? []).filter(template => template.id !== normalized.id); - return [...others, normalized]; + return [...others, toStore as StoredTemplate]; }); return getWorkoutTemplates(login); }; /** - * Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze — - * jinak by ve složené šabloně zůstal odkaz do prázdna. + * Smaže šablonu tréninku. Šablonu, kterou skládá jiná nebo kterou má plán + * v některém dni, smazat nelze — jinak by zůstal odkaz do prázdna. * * @param login přihlašovací jméno uživatele * @param id identifikátor šablony */ 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"); + throw new BadRequestError("Nebyl předán identifikátor tréninku"); } const all = await loadTemplates(login); if (!all.some(template => template.id === id)) { - throw new NotFoundError("Šablona nebyla nalezena"); + throw new NotFoundError("Trénink nebyl nalezen"); } - const usedBy = all.find(template => template.id !== id - && template.items.some(item => isTemplateRef(item) && item.templateRefId === id)); + const [usedBy] = [...findNestingTemplates(id, all), ...findUsingPlans(id, all)]; if (usedBy) { - throw new BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`); + throw new BadRequestError(`Nelze smazat, je součástí '${usedBy.name}'`); } await storage.updateData(getKey(login), @@ -236,20 +429,65 @@ export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise return getWorkoutTemplates(login); }; +/** Ověří výčet odcvičených cviků; bez něj se berou všechny. */ +const normalizeDoneIndexes = (value: unknown, count: number): Set => { + if (value == null) return new Set(Array.from({ length: count }, (_, index) => index)); + if (!Array.isArray(value) || value.some(index => !Number.isInteger(index) || index < 0 || index >= count)) { + throw new BadRequestError("Výčet odcvičených cviků neodpovídá tréninku"); + } + return new Set(value as number[]); +}; + /** - * Založí do zvoleného dne všechny cviky šablony, včetně těch z vnořených šablon. + * Vytvoří aktivitu z cviku šablony. + * + * @param item cvik (položka druhu CVIK) + * @param date den aktivity + * @param createdAt čas založení + * @param link vazba na šablonu, den plánu a případně nahrazený den + */ +export const createActivityFromExercise = ( + item: PlannedWorkoutItem, + date: string, + createdAt: string, + link: Pick, +): ActivityEntry => ({ + id: randomUUID(), + date, + name: item.name, + unit: item.unit, + quantity: item.quantity, + ...(item.sets != null ? { sets: item.sets } : {}), + ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), + ...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}), + calories: item.calories, + ...link, + createdAt, +}); + +/** + * Založí do zvoleného dne cviky šablony, včetně těch z vnořených šablon. * * Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat, * aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace, * odkud přišly. * + * U plánu se zakládá jeden jeho den a slouží to i k potvrzení navrženého + * tréninku: cviky mimo `doneIndexes` se zaznamenají jako vynechané (bez + * aktivity) a v lhůtě plánu je jde nahradit. Den plánu jde potvrdit i celý + * jako vynechaný — u plovoucího plánu se tím posune na další den sekvence. + * * @param login přihlašovací jméno uživatele * @param id identifikátor šablony * @param date den, do kterého se položky založí + * @param dayId den plánu, povinný u plánu + * @param doneIndexes pořadí odcvičených cviků v rozbaleném seznamu; bez něj všechny */ -export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise => { +export const applyWorkoutTemplate = async ( + login: string, id: unknown, date: unknown, dayId?: unknown, doneIndexes?: unknown, +): Promise => { if (typeof id !== 'string' || !id.length) { - throw new BadRequestError("Nebyl předán identifikátor šablony"); + throw new BadRequestError("Nebyl předán identifikátor tréninku"); } if (!isValidIsoDate(date)) { throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); @@ -257,48 +495,50 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk const all = await loadTemplates(login); const template = all.find(item => item.id === id); if (!template) { - throw new NotFoundError("Šablona nebyla nalezena"); + throw new NotFoundError("Trénink nebyl nalezen"); + } + + let day: StoredPlanDay | undefined; + if (isPlan(template)) { + if (typeof dayId !== 'string' || !dayId.length) { + throw new BadRequestError("Nebyl předán den plánu"); + } + day = template.days?.find(item => item.id === dayId); + if (!day) { + throw new NotFoundError("Den plánu nebyl nalezen"); + } } - // 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 exercises = expandExercises(day ? day.items : template.items, byId).map(toPlannedItem); + const done = normalizeDoneIndexes(doneIndexes, exercises.length); + if (!day && !done.size) { + throw new BadRequestError("Nebyl vybrán žádný cvik"); + } const now = new Date().toISOString(); - const entries: ActivityEntry[] = exercises.map(item => ({ - id: randomUUID(), - date, - name: item.name!, - unit: item.unit!, - quantity: item.quantity!, - ...(item.sets != null ? { sets: item.sets } : {}), - ...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}), - ...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}), - calories: itemCalories(item), - templateId: template.id, - createdAt: now, - })); + const link = { templateId: template.id, ...(day ? { planDayId: day.id } : {}) }; + const entries = exercises.map((item, index) => + done.has(index) ? createActivityFromExercise(item, date, now, link) : undefined); - await addActivities(login, date, entries); + await addActivities(login, date, entries.filter(entry => !!entry)); await recordWorkoutPlan(login, date, { + id: randomUUID(), templateId: template.id, templateName: template.name, + ...(day ? { planDayId: day.id, planDayName: day.name } : {}), + ...(day && template.replaceWithinDays ? { replaceWithinDays: template.replaceWithinDays } : {}), appliedAt: now, - items: entries.map(entry => ({ - entryId: entry.id, - name: entry.name, - unit: entry.unit, - quantity: entry.quantity, - ...(entry.sets != null ? { sets: entry.sets } : {}), - calories: entry.calories ?? 0, + items: exercises.map((item, index) => ({ + ...(entries[index] ? { entryId: entries[index].id } : {}), + ...item, })), }); return date; }; /** - * Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne. + * Vytvoří volnou š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. @@ -325,7 +565,7 @@ export const createWorkoutTemplateFromActivities = async ( 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"); + throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šel trénink vytvořit"); } const items = activities.map(entry => ({ diff --git a/types/api.yml b/types/api.yml index 5fa82ee..e06e590 100644 --- a/types/api.yml +++ b/types/api.yml @@ -84,6 +84,8 @@ paths: $ref: "./paths/workouts/apply.yml" /workouts/fromActivities: $ref: "./paths/workouts/fromActivities.yml" + /workouts/replace: + $ref: "./paths/workouts/replace.yml" # Recepty (/api/mealTemplates) /mealTemplates: diff --git a/types/paths/workouts/apply.yml b/types/paths/workouts/apply.yml index c7be797..d401c7c 100644 --- a/types/paths/workouts/apply.yml +++ b/types/paths/workouts/apply.yml @@ -1,6 +1,9 @@ post: operationId: applyWorkoutTemplate - summary: Založí do zvoleného dne všechny položky šablony tréninku. + summary: | + Založí do zvoleného dne cviky šablony tréninku. U plánu se zakládá jeden + jeho den a jde tak i potvrdit navržený trénink — cviky mimo doneIndexes + se zaznamenají jako vynechané a v okně replaceWithinDays je jde nahradit. requestBody: required: true content: @@ -16,6 +19,17 @@ post: description: Den, do kterého se položky založí type: string format: date + dayId: + description: Den plánu. Povinný u šablony typu FIXNI a PLOVOUCI. + type: string + doneIndexes: + description: | + Pořadí odcvičených cviků v rozbaleném seznamu (jak ho vrací + PlannedWorkout.items). Bez něj se založí všechny; u dne plánu + může být prázdné — celý den se pak zaznamená jako vynechaný. + type: array + items: + type: integer responses: "200": description: Aktualizovaný přehled dne diff --git a/types/paths/workouts/replace.yml b/types/paths/workouts/replace.yml new file mode 100644 index 0000000..c012b04 --- /dev/null +++ b/types/paths/workouts/replace.yml @@ -0,0 +1,40 @@ +post: + operationId: replaceSkippedExercise + summary: | + Nahradí cvik vynechaný v předchozím dni plánu — založí ho jako aktivitu + do zvoleného dne a v záznamu původního dne ho označí jako nahrazený. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [date, sourceDate, planId, itemIndex] + properties: + date: + description: Den, do kterého se náhrada založí + type: string + format: date + sourceDate: + description: Den, ve kterém se cvik vynechal + type: string + format: date + planId: + description: Identifikátor záznamu o použití plánu (PendingReplacement.planId) + type: string + itemIndex: + description: Pořadí cviku v záznamu (PendingReplacement.itemIndex) + type: integer + responses: + "200": + description: Aktualizovaný přehled dne + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/DayOverview" + "400": + description: Cvik nejde nahradit (není vynechaný, už je nahrazený, nebo uplynula lhůta) + "401": + description: Neautentizovaný uživatel + "404": + description: Záznam o použití plánu neexistuje diff --git a/types/paths/workouts/save.yml b/types/paths/workouts/save.yml index 8bfea62..1de052d 100644 --- a/types/paths/workouts/save.yml +++ b/types/paths/workouts/save.yml @@ -1,23 +1,12 @@ post: operationId: saveWorkoutTemplate - summary: Založí novou šablonu tréninku, nebo upraví existující. + summary: Založí novou šablonu tréninku (volnou nebo plán), nebo upraví existující. requestBody: required: true content: application/json: schema: - type: object - required: [name, items] - properties: - id: - description: Identifikátor upravované šablony. Bez něj se založí nová. - type: string - name: - type: string - items: - type: array - items: - $ref: "../../schemas/_index.yml#/WorkoutTemplateItem" + $ref: "../../schemas/_index.yml#/WorkoutTemplateInput" responses: "200": description: Aktualizovaný seznam šablon diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index ef10a41..77042d8 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -884,6 +884,15 @@ ActivityEntry: templateId: description: Identifikátor šablony, ze které aktivita vznikla type: string + planDayId: + description: Den plánu (šablony typu FIXNI nebo PLOVOUCI), ze kterého aktivita vznikla + type: string + replacesDate: + description: | + Datum dne, ve kterém se cvik z plánu vynechal a tato aktivita ho nahrazuje. + Nastavuje jen server při náhradě. + type: string + format: date createdAt: description: Čas vzniku záznamu type: string @@ -992,10 +1001,22 @@ WorkoutTemplateItem: description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje. type: integer -WorkoutTemplate: +WorkoutTemplateType: description: | - Předpřipravený trénink. Použitím se jeho položky založí do zvoleného dne, - takže opakovaný trénink není potřeba zadávat cvik po cviku znovu. + Typ šablony tréninku. + VOLNY — cviky, které jde odcvičit kdykoli; do dne se zakládají ručně. + FIXNI — plán s pevnými dny v týdnu; dny bez tréninku jsou volno. + PLOVOUCI — plán jako sekvence dnů zakončená dnem volna. Na kalendář navázaný + není: další den sekvence přijde na řadu, až se splní předchozí, a volno jde + v rámci cyklu posunout dopředu. + type: string + enum: + - VOLNY + - FIXNI + - PLOVOUCI + +WorkoutPlanDay: + description: Jeden tréninkový den plánu (šablony typu FIXNI nebo PLOVOUCI) type: object additionalProperties: false required: @@ -1003,20 +1024,95 @@ WorkoutTemplate: - name - items - estimatedCalories + properties: + id: + description: | + Stálý identifikátor dne. Podle něj se v historii pozná, který den plánu + se kdy cvičil, i když se plán mezitím upraví. + type: string + name: + description: Název dne, např. "Workout Day 1" + type: string + weekday: + description: Den v týdnu u fixního plánu, 1 = pondělí až 7 = neděle + type: integer + items: + description: Cviky dne; odkazem lze zařadit i volnou šablonu + type: array + items: + $ref: "#/WorkoutTemplateItem" + estimatedCalories: + description: Odhad spálených kcal za den. Doplňuje server. + type: integer + +WorkoutPlanDayInput: + description: Den plánu při ukládání šablony. Bez id se založí nový den. + type: object + additionalProperties: false + required: + - name + - items + properties: + id: + type: string + name: + type: string + weekday: + type: integer + items: + type: array + items: + $ref: "#/WorkoutTemplateItem" + +WorkoutTemplate: + description: | + Předpřipravený trénink. Volná šablona je seznam cviků, který se použitím + založí do zvoleného dne. Plán (fixní nebo plovoucí) má tréninkové dny + a přehled dne z něj sám navrhne, co je ten den na řadě. + type: object + additionalProperties: false + required: + - id + - name + - type + - items + - estimatedCalories - hasNested - usedByCount + - usedInPlanCount properties: id: type: string name: description: Název šablony, např. "Workout day 1" type: string + type: + $ref: "#/WorkoutTemplateType" items: + description: Položky volné šablony; u plánu je seznam prázdný a cviky jsou ve dnech type: array items: $ref: "#/WorkoutTemplateItem" + days: + description: | + Tréninkové dny plánu. U plovoucího v pořadí, ve kterém se cvičí — + den volna za posledním se nezapisuje, plyne z typu. + type: array + items: + $ref: "#/WorkoutPlanDay" + replaceWithinDays: + description: | + Do kolika dní jde cvik vynechaný v plánu nahradit. Chybí-li, náhrady + se nenabízejí. + type: integer + startDate: + description: Od kterého dne plán platí a přehled dne z něj navrhuje trénink + type: string + format: date estimatedCalories: - description: Odhad spálených kcal za celou šablonu včetně vnořených + description: | + Odhad spálených kcal za celou šablonu včetně vnořených, u plánu + za celý cyklus (týden) type: integer hasNested: description: | @@ -1024,8 +1120,155 @@ WorkoutTemplate: 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á + description: Kolik jiných volných šablon tuto šablonu skládá type: integer + usedInPlanCount: + description: V kolika plánech je šablona zařazená do některého dne + type: integer + +WorkoutTemplateInput: + description: Data pro založení nebo úpravu šablony tréninku + type: object + additionalProperties: false + required: + - name + properties: + id: + description: Identifikátor upravované šablony. Bez něj se založí nová. + type: string + name: + type: string + type: + $ref: "#/WorkoutTemplateType" + items: + description: Položky volné šablony + type: array + items: + $ref: "#/WorkoutTemplateItem" + days: + description: Dny plánu (FIXNI, PLOVOUCI) + type: array + items: + $ref: "#/WorkoutPlanDayInput" + replaceWithinDays: + type: integer + startDate: + type: string + format: date + +PlannedWorkoutStatus: + description: | + Stav plánu v konkrétní den. + NAVRH — den plánu je na řadě a čeká na potvrzení. + SPLNENO — den plánu se ten den už potvrdil. + VOLNO — podle plánu je den volna. + type: string + enum: + - NAVRH + - SPLNENO + - VOLNO + +PlannedWorkoutItem: + description: Jeden cvik navrženého dne plánu, po rozbalení vnořených šablon + type: object + additionalProperties: false + required: + - name + - unit + - quantity + - calories + properties: + name: + type: string + unit: + $ref: "#/ActivityUnit" + quantity: + description: Množství na jednu sérii + type: integer + sets: + type: integer + caloriesPer100Units: + type: integer + extraWeightGrams: + $ref: "#/ExtraWeightGrams" + calories: + description: Spálené kcal za všechny série + type: integer + +PlannedWorkout: + description: Co plán (fixní nebo plovoucí šablona) navrhuje na konkrétní den + type: object + additionalProperties: false + required: + - templateId + - templateName + - type + - status + - items + properties: + templateId: + type: string + templateName: + type: string + type: + $ref: "#/WorkoutTemplateType" + status: + $ref: "#/PlannedWorkoutStatus" + dayId: + description: Navržený den plánu; u stavu VOLNO chybí + type: string + dayName: + description: | + Název navrženého dne, u stavu SPLNENO dnů potvrzených v tento den + (při dvou potvrzeních jsou názvy spojené) + type: string + items: + description: Cviky navrženého dne; mimo stav NAVRH je seznam prázdný + type: array + items: + $ref: "#/PlannedWorkoutItem" + restAvailable: + description: | + Jen plovoucí plán: true, pokud v tomto cyklu ještě nebyl den volna, + takže když se dnes necvičí, bere se to jako posunuté volno a sekvence + pak pokračuje bez další mezery. + type: boolean + +PendingReplacement: + description: Cvik vynechaný v některém z předchozích dnů, který jde ještě nahradit + type: object + additionalProperties: false + required: + - sourceDate + - planId + - itemIndex + - templateId + - templateName + - deadline + - item + properties: + sourceDate: + description: Den, ve kterém se cvik vynechal + type: string + format: date + planId: + description: Identifikátor záznamu o použití plánu v daném dni + type: string + itemIndex: + description: Pořadí cviku v záznamu o použití plánu + type: integer + templateId: + type: string + templateName: + type: string + dayName: + type: string + deadline: + description: Poslední den, kdy jde cvik ještě nahradit + type: string + format: date + item: + $ref: "#/PlannedWorkoutItem" EnergyBalance: description: Energetická bilance dne — kolik přišlo jídlem a kolik se vydalo @@ -1078,6 +1321,8 @@ DayOverview: - meals - activities - energy + - plannedWorkouts + - pendingReplacements properties: date: type: string @@ -1086,6 +1331,16 @@ DayOverview: $ref: "#/DayRecord" activities: $ref: "#/ActivityDay" + plannedWorkouts: + description: Co na tento den navrhují plány tréninků, které už platí + type: array + items: + $ref: "#/PlannedWorkout" + pendingReplacements: + description: Cviky vynechané v předchozích dnech, které jde v tento den ještě nahradit + type: array + items: + $ref: "#/PendingReplacement" energy: $ref: "#/EnergyBalance" sleepMinutes: @@ -1389,6 +1644,12 @@ TemplateUsageTotal: name: description: Název šablony v době použití type: string + planDayId: + description: Den plánu; plán se ve statistikách dělí po dnech + type: string + dayName: + description: Název dne plánu v době použití + type: string dayCount: description: Počet dní, ve kterých se šablona použila type: integer @@ -1402,7 +1663,9 @@ TemplateUsageTotal: description: Kolik cviků šablona založila type: integer doneItemCount: - description: Kolik z nich ve dni zůstalo (smazané se berou jako neprovedené) + description: | + Kolik z nich se provedlo — ve dni zůstalo, nebo se později nahradilo + (smazané a nenahrazené se berou jako neprovedené) type: integer ActivityGroupTotal: @@ -1513,6 +1776,7 @@ ActivityDayStats: - date - totalCalories - templates + - replacements - extraCalories - extras properties: @@ -1527,6 +1791,11 @@ ActivityDayStats: type: array items: $ref: "#/TemplateDayUsage" + replacements: + description: Náhrady cviků vynechaných v předchozích dnech, odcvičené v tento den + type: array + items: + $ref: "#/ReplacementUsage" extraCalories: description: Součet kcal aktivit mimo šablony type: integer @@ -1536,8 +1805,36 @@ ActivityDayStats: items: $ref: "#/ActivitySummaryItem" +ReplacementUsage: + description: Jedna náhrada vynechaného cviku z plánu + type: object + additionalProperties: false + required: + - templateId + - name + - sourceDate + - item + properties: + templateId: + type: string + name: + description: Název šablony (plánu) + type: string + planDayId: + description: Den plánu, ve kterém se cvik vynechal + type: string + dayName: + description: Název dne plánu, ve kterém se cvik vynechal + type: string + sourceDate: + description: Datum, kdy se cvik vynechal + type: string + format: date + item: + $ref: "#/ActivitySummaryItem" + TemplateDayUsage: - description: Použití jedné šablony v konkrétní den + description: Použití jedné šablony (u plánu jednoho jeho dne) v konkrétní den type: object additionalProperties: false required: @@ -1553,6 +1850,11 @@ TemplateDayUsage: name: description: Název šablony v době použití type: string + planDayId: + type: string + dayName: + description: Název dne plánu v době použití + type: string plannedCalories: type: integer actualCalories: @@ -1572,10 +1874,12 @@ PlanDeviationKind: description: | ODEBRANO — cvik ze šablony ve dni chybí. UPRAVENO — cvik je, ale s jiným množstvím, počtem sérií nebo násobkem. + NAHRAZENO — cvik ve dni chybí, ale později se nahradil (replacementDate). type: string enum: - ODEBRANO - UPRAVENO + - NAHRAZENO PlanDeviation: description: Odchylka jednoho cviku od plánu šablony @@ -1593,6 +1897,10 @@ PlanDeviation: description: Skutečné provedení; u odebraného cviku chybí allOf: - $ref: "#/ActivitySummaryItem" + replacementDate: + description: U nahrazeného cviku den, kdy se nahradil + type: string + format: date ActivitySummaryItem: description: Stručný popis jedné aktivity pro statistiky