chore: graf db

feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
Ondřej Anděl committed 2026-10-22 12:11:05 +02:00
1 parent 865c2c3b22
commit 7641e26c9f
43 files changed
+3452 -592

No files matched your search

+57 -15
View File
@@ -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 — - **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 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 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), (tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle),
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts` `luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
@@ -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), - **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, `MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
hromadným doplněním údajů a přejmenováním), 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), 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 `StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`) `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 — a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
výběr z historie a receptů, SettingsModal, ImportModal, SpinWheelModal — kolo štěstí) 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ů, - **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. výchozí stav a neukládá se.
Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` — Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` —
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. 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ů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). 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 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. ji do sazby na 100 jednotek.
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď `WorkoutTemplate` (`workouts.ts`) má typ (`WorkoutTemplateType`):
cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou
samostatné, takže úprava založené položky šablonu nemění.
**Zanoření je povolené jen na jednu úroveň** a `checkNesting` to hlídá z obou stran: - **VOLNY** — pojmenovaný seznam položek, do dne se zakládá ručně. Výchozí typ,
šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít nezapisuje se; šablony uložené dřív, než typy přibyly, jsou volné.
skládat, pokud ji už někdo používá. Bez druhé kontroly by druhá úroveň vznikla - **FIXNI** — plán s dny (`days`) navázanými na den v týdnu (`weekday` 1–7).
oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — zůstal by odkaz - **PLOVOUCI** — plán jako sekvence dnů zakončená dnem volna (volno se jako den
do prázdna. Šablony uložené dřív, než skládání přibylo, nemají `kind`; `withKind` nezapisuje, plyne z typu).
jim ho při načtení doplní na `CVIK`.
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á: `buildEnergyBalance` v `dayOverview.ts` počítá:
`bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit. `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ý** `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 (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` 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. 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 **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 výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej
+218 -15
View File
@@ -59,6 +59,192 @@ JWT_SECRET=... docker compose up --build
Produkčně běží aplikace nad Redisem, ve vývoji nad JSON souborem Produkčně běží aplikace nad Redisem, ve vývoji nad JSON souborem
(`server/data/db.json`). Viz `server/.env.template`. (`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:<login>:<datum>` | pole jídel dne (`MealEntry`) |
| `mealTemplates:<login>` | pole receptů (`MealTemplate`) |
| `mealPhotos:<login>` | rejstřík fotek: klíč jídla (název + zdroj) → pole náhledů |
| `mealPhoto:<login>:<id>` | plný obrázek jedné fotky (data URL) |
| `calorieLibrary:<login>` | vlastní knihovna kalorií: znormalizovaný název → záznam |
| `settings:<login>` | nastavení, tělesné údaje a cíle |
| `weights:<login>` | vážení: datum → hmotnost v gramech |
| `sleep:<login>:<datum>` | délka spánku v minutách |
| `activities:<login>:<datum>` | pole aktivit dne (`ActivityEntry`) |
| `workouts:<login>` | pole šablon tréninků — volné šablony i plány |
| `workoutPlans:<login>:<datum>` | 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 ## Den: příjem a výdej
Přehled dne má dvě záložky: 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)`. 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 Na stránce **Tréninky** jsou tři typy:
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 - **Plovoucí plán** — sekvence tréninkových dnů (Den 1, Den 2, Den 3…), po které
tréninku; při použití se její cviky rozbalí. Zanoření je kvůli jednoduchosti jen následuje den volna a cyklus se opakuje. Na kalendář navázaný není: další den
na jednu úroveň a aplikace to hlídá. 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 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é. Recepty (vařená jídla se surovinami a postupem) jsou rozšířením jídla v seznamu
U jídla ikonou záložky v řádku, u tréninku tlačítkem *Uložit jako šablonu* v záložce jídel na stránce **Jídla**.
Výdej (vybere se, které cviky zahrnout). Při zakládání jídla nebo aktivity je
v dialogu i zaškrtávátko *Uložit i jako šablonu*.
Na druhé záložce jsou **šablony jídel** — opakovaně jedené jídlo (název, zdroj,
kalorie, gramáž, cena), které se v přehledu dne založí jedním kliknutím. Ty se
neskládají z jiných. U domácích jídel jde do poznámky zapsat postup přípravy.
## Osobní údaje ## Osobní údaje
+10
View File
@@ -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 // Ikona u skládané šablony ve výběru
.workout-nested-icon { .workout-nested-icon {
margin-left: 8px; margin-left: 8px;
+5
View File
@@ -73,6 +73,11 @@ export function getDayOfWeek(date: string): string {
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date)); 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". */ /** Vrátí čitelný zápis data, např. "3. března 2025". */
export function getHumanDate(date: string): string { export function getHumanDate(date: string): string {
return HUMAN_DATE_FORMAT.format(parseIsoDate(date)); return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
+9 -2
View File
@@ -1,6 +1,6 @@
import { ActivityEntry } from "../../../types"; import { ActivityEntry } from "../../../types";
import { ACTIVITY_UNIT_SHORT } from "../enums"; 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 RowActions from "./RowActions";
import MultiplierControl from "./MultiplierControl"; import MultiplierControl from "./MultiplierControl";
@@ -24,7 +24,14 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChan
{!!entry.extraWeightGrams && ( {!!entry.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(entry.extraWeightGrams)}</span> <span className="tag" title="Dodatečná zátěž">{formatExtraWeight(entry.extraWeightGrams)}</span>
)} )}
{entry.templateId && <span className="tag tag-import">ze šablony</span>} {entry.replacesDate && (
<span className="tag tag-import" title="Náhrada cviku vynechaného v plánu">
náhrada za {formatShortDate(entry.replacesDate)}
</span>
)}
{entry.templateId && !entry.replacesDate && (
<span className="tag tag-import">{entry.planDayId ? 'z plánu' : 'z tréninku'}</span>
)}
</div> </div>
{entry.note && <div className="meal-note">{entry.note}</div>} {entry.note && <div className="meal-note">{entry.note}</div>}
</div> </div>
@@ -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<Props>) => {
const [busyKey, setBusyKey] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
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 (
<div className="card-panel pending-replacements">
<h2 className="pending-replacements-title">
<FontAwesomeIcon icon={faRotate} /> Vynechané cviky k náhradě
</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{replacements.map(replacement => (
<div className="pending-replacement-row" key={getKey(replacement)}>
<div className="pending-replacement-main">
<div className="pending-replacement-name">{replacement.item.name}</div>
<div className="pending-replacement-meta">
{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)}
</div>
</div>
<span className="template-pick-calories">{formatCalories(replacement.item.calories)}</span>
<Button
size="sm"
variant="outline-primary"
onClick={() => replace(replacement)}
disabled={!!busyKey}
>
Nahradit
</Button>
</div>
))}
</div>
);
};
export default PendingReplacementsPanel;
@@ -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<Props>) => {
const [done, setDone] = useState<boolean[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
setDone(planned.items.map(() => true));
setError(undefined);
}, [planned]);
if (planned.status === PlannedWorkoutStatus.SPLNENO) {
return (
<div className="planned-workout planned-workout-done">
<FontAwesomeIcon icon={faCircleCheck} />
<span><strong>{planned.templateName}</strong> · {planned.dayName} — splněno</span>
</div>
);
}
if (planned.status === PlannedWorkoutStatus.VOLNO) {
return (
<div className="planned-workout planned-workout-rest">
<FontAwesomeIcon icon={faBed} />
<span><strong>{planned.templateName}</strong> — podle plánu den volna</span>
</div>
);
}
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 (
<div className="card-panel planned-workout-card">
<div className="planned-workout-head">
<FontAwesomeIcon icon={faCalendarCheck} className="planned-workout-icon" />
<div className="planned-workout-title">
<strong>{planned.dayName}</strong>
<span className="planned-workout-plan">{planned.templateName}</span>
</div>
<span className="planned-workout-calories">{formatCalories(plannedCalories)}</span>
</div>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{planned.items.map((item, index) => (
// Cviky jsou rozbalený seznam dne plánu, index je i to, co se posílá serveru
<div className="template-pick-row" key={index}>
<Form.Check
id={`plan-${planned.templateId}-${index}`}
checked={done[index] ?? true}
label={item.name}
onChange={() => setDone(current => current.map((value, i) => (i === index ? !value : value)))}
/>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(item.extraWeightGrams)}</span>
)}
<span className="template-pick-quantity">
{formatQuantityWithSets(item.quantity, ACTIVITY_UNIT_SHORT[item.unit], item.sets)}
</span>
<span className="template-pick-calories">{formatCalories(item.calories)}</span>
</div>
))}
<div className="planned-workout-footer">
<span className="planned-workout-hint">
{doneCount < planned.items.length
? 'Odškrtnuté cviky se zapíšou jako vynechané.'
: getSkipHint(planned)}
</span>
<Button
size="sm"
variant={doneCount ? 'primary' : 'outline-secondary'}
onClick={() => confirm()}
disabled={busy}
>
{getConfirmLabel()}
</Button>
</div>
</div>
);
};
export default PlannedWorkoutCard;
+2 -2
View File
@@ -19,8 +19,8 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readon
<button <button
className="btn-icon" className="btn-icon"
onClick={() => onSaveTemplate()} onClick={() => onSaveTemplate()}
title="Uložit jako šablonu" title="Uložit jako trénink"
aria-label={`Uložit ${label} jako šablonu`} aria-label={`Uložit ${label} jako trénink`}
> >
<FontAwesomeIcon icon={faBookmark} /> <FontAwesomeIcon icon={faBookmark} />
</button> </button>
@@ -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<Props>) => {
const updateItem = (index: number, changes: Partial<DraftItem>) =>
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íč
<div className="workout-item-row" key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="workout-item-nested-icon" />
<Form.Select
value={item.templateRefId}
onChange={event => updateItem(index, { templateRefId: event.target.value })}
>
<option value="">— vyberte trénink —</option>
{nestable.map(template => (
<option key={template.id} value={template.id}>
{template.name} ({template.estimatedCalories} kcal)
</option>
))}
</Form.Select>
</>
) : (
<>
<Form.Control
list={ACTIVITY_PRESETS_LIST_ID}
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-sets"
inputMode="numeric"
value={item.sets}
placeholder="série"
title="Počet sérií"
onChange={event => updateItem(index, { sets: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
title="Množství v jedné sérii"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-weight"
inputMode="decimal"
value={item.extraWeight}
placeholder="zátěž kg"
title="Dodatečná zátěž v kg — činky, vesta. Kalorie nemění."
onChange={event => updateItem(index, { extraWeight: event.target.value })}
/>
</>
)}
<button
className="btn-icon danger"
onClick={() => onChange(items.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<div className="workout-items-actions">
<Button variant="outline-secondary" size="sm" onClick={() => onChange([...items, emptyExercise()])}>
<FontAwesomeIcon icon={faPlus} /> Cvik
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => onChange([...items, emptyReference('')])}
disabled={!nestable.length || !!nestBlockedReason}
title={nestTitle}
>
<FontAwesomeIcon icon={faLayerGroup} /> Vložit trénink
</Button>
</div>
</>
);
};
export default WorkoutItemsEditor;
@@ -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<Props>) => (
<ul className="template-items">
{items.map((item, index) => (
// Položky nemají vlastní id, v rámci jedné šablony je index stabilní
<li key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="template-item-icon" />
<span className="template-item-name">{item.templateRefName ?? 'neznámý trénink'}</span>
<span className="template-item-calories">{formatCalories(item.templateRefCalories)}</span>
</>
) : (
<>
<span className="template-item-name">{item.name}</span>
<span className="template-item-quantity">
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
</span>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(item.extraWeightGrams)}</span>
)}
<span className="template-item-calories">
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
</span>
</>
)}
</li>
))}
</ul>
);
export default WorkoutItemsList;
@@ -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<Props>) => {
const calories = getDraftCalories(day.items, templates);
return (
<div className="plan-day-editor">
<div className="plan-day-head">
{isFixed ? (
<Form.Select
className="plan-day-weekday"
value={day.weekday}
aria-label="Den v týdnu"
onChange={event => onChange({ ...day, weekday: Number(event.target.value) })}
>
{WEEKDAY_NAMES.map((name, weekdayIndex) => (
<option
key={name}
value={weekdayIndex + 1}
disabled={takenWeekdays.includes(weekdayIndex + 1)}
>
{name}
</option>
))}
</Form.Select>
) : (
<span className="plan-day-order">{index + 1}.</span>
)}
<Form.Control
value={day.name}
placeholder={isFixed ? WEEKDAY_NAMES[day.weekday - 1] : `Den ${index + 1}`}
aria-label="Název dne"
onChange={event => onChange({ ...day, name: event.target.value })}
/>
{calories > 0 && <span className="plan-day-calories">{formatCalories(calories)}</span>}
{!isFixed && (
<>
<button
className="btn-icon"
onClick={() => onMove(-1)}
disabled={index === 0}
title="Posunout dřív"
aria-label="Posunout den dřív"
>
<FontAwesomeIcon icon={faArrowUp} />
</button>
<button
className="btn-icon"
onClick={() => onMove(1)}
disabled={index === dayCount - 1}
title="Posunout později"
aria-label="Posunout den později"
>
<FontAwesomeIcon icon={faArrowDown} />
</button>
</>
)}
<button
className="btn-icon danger"
onClick={() => onRemove()}
disabled={dayCount === 1}
title="Odebrat den"
aria-label="Odebrat den"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
<WorkoutItemsEditor
items={day.items}
onChange={items => onChange({ ...day, items })}
nestable={nestable}
/>
</div>
);
};
export default WorkoutPlanDayEditor;
@@ -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<Props>) => {
const isPlan = template.type !== WorkoutTemplateType.VOLNY;
const usedCount = template.usedByCount + template.usedInPlanCount;
return (
<div className="card-panel template-card">
<div className="template-head">
<div className="template-title">
{template.name}
{isPlan && <span className="tag tag-plan">{WORKOUT_TEMPLATE_TYPE_NAMES[template.type]}</span>}
{template.hasNested && (
<span className="tag tag-import">
<FontAwesomeIcon icon={faLayerGroup} /> skládaný
</span>
)}
{usedCount > 0 && <span className="tag">součástí {usedCount}×</span>}
</div>
<span className="template-calories" title={isPlan ? 'Odhad za celý cyklus' : undefined}>
{formatCalories(template.estimatedCalories)}
</span>
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => onDelete()}
disabled={busy}
title={usedCount > 0 ? 'Nelze smazat, je součástí jiného tréninku nebo plánu' : 'Smazat'}
aria-label="Smazat"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
{isPlan && (
<div className="template-plan-meta">
{template.startDate && <span>platí od {formatDateString(template.startDate)}</span>}
{template.replaceWithinDays
? <span><FontAwesomeIcon icon={faRotate} /> nahraditelné během {template.replaceWithinDays} dní</span>
: <span>bez náhrad</span>}
</div>
)}
{!isPlan && <WorkoutItemsList items={template.items} />}
{(template.days ?? []).map((day, index) => (
<div className="template-plan-day" key={day.id}>
<div className="template-plan-day-head">
<span className="template-plan-day-label">
{template.type === WorkoutTemplateType.FIXNI
? WEEKDAY_NAMES[(day.weekday ?? 1) - 1]
: `${index + 1}.`}
</span>
<span className="template-plan-day-name">{day.name}</span>
<span className="template-item-calories">{formatCalories(day.estimatedCalories)}</span>
</div>
<WorkoutItemsList items={day.items} />
</div>
))}
{template.type === WorkoutTemplateType.PLOVOUCI && (
<div className="template-plan-day template-plan-rest">
<FontAwesomeIcon icon={faBed} /> Den volna, pak znovu od 1. dne
</div>
)}
</div>
);
};
export default WorkoutTemplateCard;
@@ -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<Props>) => {
const [name, setName] = useState(edited?.name ?? '');
const [type, setType] = useState<WorkoutTemplateType>(edited?.type ?? WorkoutTemplateType.VOLNY);
const [items, setItems] = useState(() => (edited?.items.length ? toDraftItems(edited.items) : [emptyExercise()]));
const [days, setDays] = useState<DraftDay[]>(() => (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<string | undefined>();
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<DraftDay[]>((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 (
<div className="card-panel template-form">
<h2 className="chart-title">{isPlan ? (edited ? 'Úprava plánu' : 'Nový plán') : (edited ? 'Úprava tréninku' : 'Nový trénink')}</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
<div className="template-form-head">
<Form.Group className="mb-3 template-form-name">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder={isPlan ? 'Např. Můj plán' : 'Např. Rozcvička'}
onChange={event => setName(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ</Form.Label>
<Form.Select
value={type}
onChange={event => changeType(event.target.value as WorkoutTemplateType)}
>
{WORKOUT_TEMPLATE_TYPES_IN_ORDER.map(item => (
<option
key={item}
value={item}
disabled={isUsed && item !== WorkoutTemplateType.VOLNY}
>
{WORKOUT_TEMPLATE_TYPE_NAMES[item]}
</option>
))}
</Form.Select>
</Form.Group>
</div>
<p className="text-body-secondary small">
{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.'}
</p>
{!isPlan && (
<WorkoutItemsEditor
items={items}
onChange={setItems}
nestable={freeNestable}
nestBlockedReason={edited && edited.usedByCount > 0
? 'Tento trénink už je vložený do jiného, další vkládání by bylo druhou úrovní'
: undefined}
/>
)}
{isPlan && (
<>
<div className="template-form-head">
<Form.Group className="mb-3">
<Form.Label>Platí od</Form.Label>
<Form.Control
type="date"
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Nahraditelné během</Form.Label>
<div className="input-with-unit">
<Form.Control
inputMode="numeric"
value={replaceWithinDays}
placeholder="bez náhrad"
isInvalid={!replaceValid}
onChange={event => setReplaceWithinDays(event.target.value.replace(/\D/g, ''))}
/>
<span>dní</span>
</div>
<Form.Text>
Vynechaný cvik jde v této lhůtě dodělat jindy (nejvýš {MAX_REPLACE_DAYS} dní).
</Form.Text>
</Form.Group>
</div>
{days.map((day, index) => (
<WorkoutPlanDayEditor
key={day.key}
day={day}
index={index}
dayCount={days.length}
isFixed={isFixed}
takenWeekdays={days.filter(other => 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 && (
<div className="plan-rest-day">
<FontAwesomeIcon icon={faBed} /> Den volna — po posledním dni, pak se cyklus opakuje
</div>
)}
</>
)}
<datalist id={ACTIVITY_PRESETS_LIST_ID}>
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
{isPlan && (
<Button
variant="outline-secondary"
size="sm"
onClick={() => addDay()}
disabled={isFixed && days.length >= WEEKDAY_NAMES.length}
>
<FontAwesomeIcon icon={faPlus} /> Den
</Button>
)}
{totalCalories > 0 && (
<span className="workout-draft-calories">
{isPlan ? 'za cyklus ' : 'celkem '}{formatCalories(totalCalories)}
</span>
)}
{edited && (
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={() => save()} disabled={!canSave}>
{edited ? 'Uložit změny' : (isPlan ? 'Vytvořit plán' : 'Vytvořit trénink')}
</Button>
</div>
</div>
);
};
export default WorkoutTemplateForm;
@@ -273,7 +273,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
className="modal-template-check" className="modal-template-check"
id="activity-also-template" id="activity-also-template"
checked={alsoTemplate} checked={alsoTemplate}
label="Uložit i jako šablonu" label="Uložit i jako trénink"
onChange={event => setAlsoTemplate(event.target.checked)} onChange={event => setAlsoTemplate(event.target.checked)}
/> />
<div className="meal-modal-summary"> <div className="meal-modal-summary">
@@ -3,7 +3,10 @@ import { Button, Modal, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons"; 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 { ACTIVITY_UNIT_SHORT } from "../../enums";
import { formatCalories, formatQuantityWithSets } from "../../Utils"; import { formatCalories, formatQuantityWithSets } from "../../Utils";
import { TRENINKY_URL } from "../../routes"; import { TRENINKY_URL } from "../../routes";
@@ -17,10 +20,10 @@ type Props = {
onApplied: (day: DayOverview) => void, onApplied: (day: DayOverview) => void,
}; };
/** Popíše položky šablony jednou řádkou. */ /** Popíše položky šablony nebo dne plánu jednou řádkou. */
const describeItems = (template: WorkoutTemplate): string => const describeItems = (items: WorkoutTemplateItem[]): string =>
template.items.map(item => item.kind === WorkoutItemKind.SABLONA items.map(item => item.kind === WorkoutItemKind.SABLONA
? (item.templateRefName ?? 'šablona') ? (item.templateRefName ?? 'trénink')
: `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}` : `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}`
).join(' · '); ).join(' · ');
@@ -28,7 +31,8 @@ const describeItems = (template: WorkoutTemplate): string =>
* Výběr šablony tréninku k založení do dne. * 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 * 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<Props>) => { const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]); const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
@@ -45,9 +49,9 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
}); });
}, [isOpen]); }, [isOpen]);
const apply = async (id: string) => { const apply = async (id: string, dayId?: string) => {
setBusy(true); setBusy(true);
const response = await applyWorkoutTemplate({ body: { id, date } }); const response = await applyWorkoutTemplate({ body: { id, date, ...(dayId ? { dayId } : {}) } });
setBusy(false); setBusy(false);
if (response.data) { if (response.data) {
onApplied(response.data); onApplied(response.data);
@@ -57,7 +61,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
return ( return (
<Modal show={isOpen} onHide={onClose} centered size="lg"> <Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Použít šablonu tréninku</Modal.Title> <Modal.Title>Přidat trénink</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{loading && ( {loading && (
@@ -69,21 +73,21 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
{!loading && !templates.length && ( {!loading && !templates.length && (
<div className="empty-state"> <div className="empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div> <div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu</div> <div className="empty-title">Zatím nemáte žádný trénink ani plán</div>
<p className="empty-description">Vytvořit si ji můžete na stránce Tréninky.</p> <p className="empty-description">Vytvořit si ho můžete na stránce Tréninky.</p>
</div> </div>
)} )}
{!loading && templates.map(template => ( {!loading && templates.filter(template => template.type === WorkoutTemplateType.VOLNY).map(template => (
<div className="workout-row" key={template.id}> <div className="workout-row" key={template.id}>
<div className="workout-info"> <div className="workout-info">
<div className="workout-name"> <div className="workout-name">
{template.name} {template.name}
{template.hasNested && ( {template.hasNested && (
<FontAwesomeIcon icon={faLayerGroup} className="workout-nested-icon" title="skládaná šablona" /> <FontAwesomeIcon icon={faLayerGroup} className="workout-nested-icon" title="skládaný trénink" />
)} )}
</div> </div>
<div className="workout-items">{describeItems(template)}</div> <div className="workout-items">{describeItems(template.items)}</div>
</div> </div>
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span> <span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}> <Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
@@ -91,13 +95,31 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
</Button> </Button>
</div> </div>
))} ))}
{!loading && templates.filter(template => template.type !== WorkoutTemplateType.VOLNY).map(plan => (
<div key={plan.id}>
<h3 className="workout-plan-title">{plan.name}</h3>
{(plan.days ?? []).map(day => (
<div className="workout-row" key={day.id}>
<div className="workout-info">
<div className="workout-name">{day.name}</div>
<div className="workout-items">{describeItems(day.items)}</div>
</div>
<span className="workout-calories">{formatCalories(day.estimatedCalories)}</span>
<Button size="sm" variant="outline-primary" onClick={() => apply(plan.id, day.id)} disabled={busy}>
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
</div>
))}
</div>
))}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button <Button
variant="outline-secondary" variant="outline-secondary"
onClick={() => { onClose(); navigate(TRENINKY_URL); }} onClick={() => { onClose(); navigate(TRENINKY_URL); }}
> >
Spravovat šablony Spravovat tréninky
</Button> </Button>
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button> <Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
</Modal.Footer> </Modal.Footer>
@@ -58,20 +58,20 @@ const SaveWorkoutTemplateModal = ({
if (response.data) { if (response.data) {
onSaved(); onSaved();
} else { } 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 ( return (
<Modal show={isOpen} onHide={onClose} centered> <Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>Uložit jako šablonu tréninku</Modal.Title> <Modal.Title>Uložit jako trénink</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>} {error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Název šablony</Form.Label> <Form.Label>Název tréninku</Form.Label>
<Form.Control <Form.Control
autoFocus autoFocus
value={name} value={name}
@@ -99,7 +99,7 @@ const SaveWorkoutTemplateModal = ({
<Modal.Footer> <Modal.Footer>
<Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button> <Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button>
<Button onClick={() => save()} disabled={!canSave}> <Button onClick={() => save()} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Vytvořit šablonu'} {saving ? 'Ukládám…' : 'Vytvořit trénink'}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
+25
View File
@@ -1,5 +1,6 @@
import { import {
ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor, ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor,
WorkoutTemplateType,
} from "../../types"; } from "../../types";
/** Lidsky čitelné názvy typů jídel. */ /** 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 }, { name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
]; ];
/** Názvy typů šablon tréninků. */
export const WORKOUT_TEMPLATE_TYPE_NAMES: Record<WorkoutTemplateType, string> = {
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<WorkoutTemplateType, string> = {
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. */ /** Záložky příjem/výdej v přehledu dne. */
export enum DayTab { export enum DayTab {
PRIJEM = 'PRIJEM', PRIJEM = 'PRIJEM',
+59 -26
View File
@@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react";
import { Spinner } from "react-bootstrap"; import { Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking, faArrowRight, faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
faTriangleExclamation, faRotateLeft, faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { import {
Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis, Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis,
} from "recharts"; } from "recharts";
import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types"; import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types";
import { StatsRange } from "../enums"; import { StatsRange } from "../enums";
import { formatBalance, formatCalories } from "../Utils"; import { formatBalance, formatCalories, formatShortDate } from "../Utils";
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared"; import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
import { import {
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, getSeriesId, getUsageName,
hasUnresolvedNote, listSeriesIds,
} from "./activityChartSeries"; } from "./activityChartSeries";
type Props = { type Props = {
@@ -28,16 +29,28 @@ function deviationClass(deviation: number): string {
return ''; 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 }>) { function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) {
const unresolved = note.missing.length > 0 || note.changed.length > 0;
return ( return (
<div className="plan-note"> <div className="plan-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" /> <FontAwesomeIcon
icon={unresolved ? faTriangleExclamation : faArrowRight}
className={unresolved ? 'plan-note-icon' : 'plan-note-icon replaced'}
/>
<div> <div>
{note.date && <span className="plan-note-date">{note.date} </span>} {note.date && <span className="plan-note-date">{note.date} </span>}
<strong>{note.templateName}</strong> <strong>{note.templateName}</strong>
{!!note.missing.length && <div>nesplněno: {note.missing.join(', ')}</div>} {!!note.missing.length && <div>nesplněno: {note.missing.join(', ')}</div>}
{!!note.changed.length && <div>upraveno: {note.changed.join(', ')}</div>} {!!note.changed.length && <div>upraveno: {note.changed.join(', ')}</div>}
{note.replaced.map((item, index) => (
<div key={index} className="plan-note-replaced">
nahrazeno: {item.name} <FontAwesomeIcon icon={faArrowRight} /> {formatShortDate(item.replacementDate)}
</div>
))}
</div> </div>
</div> </div>
); );
@@ -62,11 +75,20 @@ function ActivityTooltip({ active, payload, label }: Readonly<Partial<TooltipCon
{row.planned > 0 && ( {row.planned > 0 && (
<div className="activity-tooltip-row activity-tooltip-plan"> <div className="activity-tooltip-row activity-tooltip-plan">
<span className="activity-tooltip-swatch plan" /> <span className="activity-tooltip-swatch plan" />
<span>Plán šablon</span> <span>Plán</span>
<span className="activity-tooltip-value">{formatCalories(row.planned)}</span> <span className="activity-tooltip-value">{formatCalories(row.planned)}</span>
</div> </div>
)} )}
{row.notes.map((note, index) => <PlanNoteLine key={index} note={note} />)} {row.notes.map((note, index) => <PlanNoteLine key={index} note={note} />)}
{row.replacements.map((item, index) => (
<div key={`r${index}`} className="plan-note">
<FontAwesomeIcon icon={faRotateLeft} className="plan-note-icon replaced" />
<div>
náhrada za {formatShortDate(item.sourceDate)}: {item.name}
<div className="plan-note-date">{item.templateName}</div>
</div>
</div>
))}
</div> </div>
); );
} }
@@ -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 "!" — * 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. * 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<DotProps>) { function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
// Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama // 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<DotProps>) {
if (!payload.notes.length) { if (!payload.notes.length) {
return <circle key={index} cx={cx} cy={cy} r={3} fill="var(--ft-text-secondary)" />; return <circle key={index} cx={cx} cy={cy} r={3} fill="var(--ft-text-secondary)" />;
} }
if (!hasUnresolvedNote(payload)) {
return (
<g key={index} className="plan-dot-replaced">
<circle cx={cx} cy={cy} r={8} fill="var(--ft-info)" stroke="var(--ft-bg-card)" strokeWidth={2} />
<text x={cx} y={cy} dy="0.35em" textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--ft-bg-card)">→</text>
</g>
);
}
return ( return (
<g key={index} className="plan-dot-warning"> <g key={index} className="plan-dot-warning">
<circle cx={cx} cy={cy} r={8} fill="var(--ft-warning)" stroke="var(--ft-bg-card)" strokeWidth={2} /> <circle cx={cx} cy={cy} r={8} fill="var(--ft-warning)" stroke="var(--ft-bg-card)" strokeWidth={2} />
@@ -114,12 +145,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
// Pořadí všech šablon dává barvy, které se nemění podle zobrazeného období // Pořadí všech šablon dává barvy, které se nemění podle zobrazeného období
useEffect(() => { useEffect(() => {
getWorkoutTemplates().then(response => setTemplateIds((response.data ?? []).map(template => template.id))); getWorkoutTemplates().then(response => setTemplateIds(listSeriesIds(response.data ?? [])));
}, []); }, []);
const { series, templateSeries } = useMemo(() => { const { series, templateSeries } = useMemo(() => {
if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] }; 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); const built = buildTemplateSeries(templateIds, used);
return { return {
series: buildActivitySeries(stats.days, range, mode, built.keyOf), series: buildActivitySeries(stats.days, range, mode, built.keyOf),
@@ -141,7 +172,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div> <div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádný pohyb</div> <div className="empty-title">Za zvolené období nemáte žádný pohyb</div>
<p className="empty-description"> <p className="empty-description">
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.
</p> </p>
</div> </div>
); );
@@ -162,14 +193,14 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<div className="summary-hint">za {stats.dayCount} dní s pohybem</div> <div className="summary-hint">za {stats.dayCount} dní s pohybem</div>
</div> </div>
<div className="summary-tile"> <div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faBookmark} /> Ze šablon</div> <div className="summary-label"><FontAwesomeIcon icon={faBookmark} /> Z plánů a tréninků</div>
<div className="summary-value">{formatCalories(stats.templateCalories)}</div> <div className="summary-value">{formatCalories(stats.templateCalories)}</div>
<div className="summary-hint"> <div className="summary-hint">
{fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'} {fulfilment != null ? `${fulfilment} % plánu` : 'žádný plán ani trénink'}
</div> </div>
</div> </div>
<div className="summary-tile"> <div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od šablon</div> <div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od plánu</div>
<div className={`summary-value ${deviationClass(deviation)}`}> <div className={`summary-value ${deviationClass(deviation)}`}>
{deviation ? formatBalance(deviation) : '—'} {deviation ? formatBalance(deviation) : '—'}
</div> </div>
@@ -178,7 +209,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</div> </div>
</div> </div>
<div className="summary-tile"> <div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Mimo šablonu</div> <div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Ostatní pohyb</div>
<div className="summary-value">{formatCalories(stats.extraCalories)}</div> <div className="summary-value">{formatCalories(stats.extraCalories)}</div>
<div className="summary-hint">z {extraCount} aktivit</div> <div className="summary-hint">z {extraCount} aktivit</div>
</div> </div>
@@ -207,7 +238,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
))} ))}
<Bar <Bar
dataKey="extra" dataKey="extra"
name="Mimo šablonu" name="Ostatní pohyb"
stackId="vydej" stackId="vydej"
fill="var(--ft-series-other)" fill="var(--ft-series-other)"
stroke="var(--ft-bg-card)" stroke="var(--ft-bg-card)"
@@ -217,7 +248,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
{/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */} {/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */}
<Line <Line
dataKey="planned" dataKey="planned"
name="Plán šablon" name="Plán"
stroke="var(--ft-text-secondary)" stroke="var(--ft-text-secondary)"
strokeWidth={2} strokeWidth={2}
strokeDasharray="5 4" strokeDasharray="5 4"
@@ -229,16 +260,18 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</ResponsiveContainer> </ResponsiveContainer>
<p className="usage-note"> <p className="usage-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" /> u bodu plánu <FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" /> 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.
{' '}<FontAwesomeIcon icon={faArrowRight} className="plan-note-icon replaced" /> znamená, že se
vynechané nahradilo jiný den; bublina ukáže kdy. Dny volna a necvičené dny plánu se nezapisují.
</p> </p>
</div> </div>
<div className="card-panel chart-panel"> <div className="card-panel chart-panel">
<h2 className="chart-title">Plnění šablon</h2> <h2 className="chart-title">Plnění plánů a tréninků</h2>
<table className="source-table usage-table"> <table className="source-table usage-table">
<thead> <thead>
<tr> <tr>
<th>Šablona</th> <th>Trénink</th>
<th className="numeric">Použito</th> <th className="numeric">Použito</th>
<th className="numeric">Cviky</th> <th className="numeric">Cviky</th>
<th className="numeric">Plán</th> <th className="numeric">Plán</th>
@@ -250,8 +283,8 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
{stats.byTemplate.map(usage => { {stats.byTemplate.map(usage => {
const usageDeviation = usage.actualCalories - usage.plannedCalories; const usageDeviation = usage.actualCalories - usage.plannedCalories;
return ( return (
<tr key={usage.templateId}> <tr key={`${usage.templateId}:${usage.planDayId ?? ''}`}>
<td>{usage.name}</td> <td>{getUsageName(usage)}</td>
<td className="numeric">{usage.dayCount}×</td> <td className="numeric">{usage.dayCount}×</td>
<td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</td> <td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</td>
<td className="numeric">{formatCalories(usage.plannedCalories)}</td> <td className="numeric">{formatCalories(usage.plannedCalories)}</td>
@@ -266,7 +299,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<tr className="extras-toggle" onClick={() => setExtrasOpen(open => !open)}> <tr className="extras-toggle" onClick={() => setExtrasOpen(open => !open)}>
<td> <td>
<FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth /> <FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth />
{' '}Mimo šablonu {' '}Ostatní pohyb
</td> </td>
<td className="numeric">{extraCount}×</td> <td className="numeric">{extraCount}×</td>
<td className="numeric" /> <td className="numeric" />
@@ -288,12 +321,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</tbody> </tbody>
</table> </table>
{!stats.byTemplate.length && ( {!stats.byTemplate.length && (
<p className="usage-note">V tomto období jste žádnou šablonu tréninku nepoužili.</p> <p className="usage-note">V tomto období jste nepoužili žádný plán ani trénink.</p>
)} )}
<p className="usage-note"> <p className="usage-note">
Plán je to, co šablona slibovala v den použití. Smazaný nebo zkrácený cvik se Plán je to, co trénink sliboval v den použití. Smazaný nebo zkrácený cvik se
projeví jako odchylka; když smažete všechny cviky šablony, bere se to jako projeví jako odchylka, nahrazený jiný den se počítá jako splněný; když smažete
vrácené použití. všechny cviky tréninku, bere se to jako vrácené použití.
</p> </p>
</div> </div>
</> </>
+118
View File
@@ -262,3 +262,121 @@
color: var(--ft-text-secondary); 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;
}
}
+27 -6
View File
@@ -8,7 +8,7 @@ import {
faWeightScale, faWeightScale,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { import {
ActivityEntry, DayOverview, MealEntry, ActivityEntry, DayOverview, MealEntry, PlannedWorkoutStatus,
deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier, deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier,
} from "../../../types"; } from "../../../types";
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
@@ -27,6 +27,8 @@ import ApplyMealModal from "../components/modals/ApplyMealModal";
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal"; import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
import MealRow from "../components/MealRow"; import MealRow from "../components/MealRow";
import ActivityRow from "../components/ActivityRow"; import ActivityRow from "../components/ActivityRow";
import PlannedWorkoutCard from "../components/PlannedWorkoutCard";
import PendingReplacementsPanel from "../components/PendingReplacementsPanel";
import { DAY_DATE_PARAM } from "../routes"; import { DAY_DATE_PARAM } from "../routes";
import "./DayPage.scss"; import "./DayPage.scss";
@@ -37,6 +39,8 @@ function emptyOverview(date: string): DayOverview {
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 }, meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
activities: { date, entries: [], totalCalories: 0 }, activities: { date, entries: [], totalCalories: 0 },
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false }, 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); 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 removeActivity = async (entry: ActivityEntry) => {
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } }); const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
if (response.data) setDay(response.data); if (response.data) setDay(response.data);
@@ -162,11 +169,11 @@ export default function DayPage() {
<div className="header-actions"> <div className="header-actions">
{!!day.activities.entries.length && ( {!!day.activities.entries.length && (
<Button variant="outline-secondary" onClick={() => openSaveWorkout()}> <Button variant="outline-secondary" onClick={() => openSaveWorkout()}>
<FontAwesomeIcon icon={faBookmark} /> Uložit jako šablonu <FontAwesomeIcon icon={faBookmark} /> Uložit jako trénink
</Button> </Button>
)} )}
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}> <Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
<FontAwesomeIcon icon={faDumbbell} /> Ze šablony <FontAwesomeIcon icon={faDumbbell} /> Z tréninků
</Button> </Button>
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}> <Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu <FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
@@ -358,12 +365,26 @@ export default function DayPage() {
{!loading && tab === DayTab.VYDEJ && ( {!loading && tab === DayTab.VYDEJ && (
<> <>
{!day.activities.entries.length && ( {day.plannedWorkouts.map(planned => (
<PlannedWorkoutCard
key={planned.templateId}
planned={planned}
date={date}
onConfirmed={saved => setDay(saved)}
/>
))}
<PendingReplacementsPanel
replacements={day.pendingReplacements}
date={date}
onReplaced={saved => setDay(saved)}
/>
{!day.activities.entries.length && !hasProposal && (
<div className="card-panel empty-state"> <div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div> <div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
<div className="empty-title">Za tento den nemáte žádný pohyb</div> <div className="empty-title">Za tento den nemáte žádný pohyb</div>
<p className="empty-description"> <p className="empty-description">
Přidejte aktivitu, nebo použijte šablonu tréninku a založí se celá naráz. Přidejte aktivitu, nebo si na stránce Tréninky založte plán — přehled dne
pak sám navrhne, co je na řadě.
</p> </p>
</div> </div>
)} )}
@@ -436,7 +457,7 @@ export default function DayPage() {
onClose={() => setSaveWorkoutOpen(false)} onClose={() => setSaveWorkoutOpen(false)}
onSaved={() => { onSaved={() => {
setSaveWorkoutOpen(false); setSaveWorkoutOpen(false);
showNotice('Šablona tréninku vytvořena.'); showNotice('Trénink vytvořen.');
}} }}
/> />
+9
View File
@@ -307,6 +307,15 @@
.plan-note-icon { .plan-note-icon {
color: var(--ft-warning); color: var(--ft-warning);
margin-top: 2px; margin-top: 2px;
// Vynechané, které se později nahradilo — informace, ne varování
&.replaced {
color: var(--ft-info);
}
}
.plan-note-replaced {
color: var(--ft-text-secondary);
} }
.usage-note { .usage-note {
+1 -1
View File
@@ -21,7 +21,7 @@ const TAB_ICONS: Record<StatsTab, typeof faCoins> = {
const TAB_SUBTITLES: Record<StatsTab, string> = { const TAB_SUBTITLES: Record<StatsTab, string> = {
PRIJEM: 'Kolik jste snědli, v čem a jak to vychází proti výdeji', PRIJEM: 'Kolik jste snědli, v čem a jak to vychází proti výdeji',
VYDEJ: 'Kolik jste spálili pohybem a jak se držíte šablon', VYDEJ: 'Kolik jste spálili pohybem a jak se držíte plánu',
UTRATA: 'Kolik jste utratili za jídlo a za co', UTRATA: 'Kolik jste utratili za jídlo a za co',
}; };
+49 -332
View File
@@ -1,60 +1,31 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Form, Spinner } from "react-bootstrap"; import { Alert, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { faDumbbell } from "@fortawesome/free-solid-svg-icons";
import { import { WorkoutTemplate, WorkoutTemplateType, deleteWorkoutTemplate, getWorkoutTemplates } from "../../../types";
ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, import WorkoutTemplateCard from "../components/WorkoutTemplateCard";
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate, import WorkoutTemplateForm from "../components/WorkoutTemplateForm";
} from "../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
import {
deriveActivityCalories, formatCalories, formatExtraWeight, formatKgInput, formatQuantityWithSets,
parseIntInput, parseWeightKgInput,
} from "../Utils";
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */ /** Plány se řadí před volné šablony — jsou to ty, podle kterých se cvičí každý den. */
type DraftItem = { const sortTemplates = (templates: WorkoutTemplate[]): WorkoutTemplate[] => [
kind: WorkoutItemKind, ...templates.filter(template => template.type !== WorkoutTemplateType.VOLNY),
name: string, ...templates.filter(template => template.type === WorkoutTemplateType.VOLNY),
unit: ActivityUnit, ];
quantity: string,
sets: string,
caloriesPer100Units: string,
/** Dodatečná zátěž v kg, jak ji uživatel píše */
extraWeight: string,
templateRefId: string,
};
const emptyExercise = (): DraftItem => ({
kind: WorkoutItemKind.CVIK,
name: '',
unit: ActivityUnit.OPAKOVANI,
quantity: '',
sets: '',
caloriesPer100Units: '',
extraWeight: '',
templateRefId: '',
});
const emptyReference = (templateRefId: string): DraftItem => ({
...emptyExercise(),
kind: WorkoutItemKind.SABLONA,
templateRefId,
});
/** /**
* Panel se správou šablon tréninků. * Panel se správou šablon tréninků.
* *
* Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu, * Volná šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou
* jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu * volnou šablonu, jejíž cviky se při použití rozbalí — zanoření je ale povolené
* úroveň, takže šablona, která už něco skládá, se sama vnořit nedá. * jen na jednu úroveň. Plán (fixní nebo plovoucí) má tréninkové dny a přehled
* dne z něj sám navrhuje, co je na řadě; volné šablony do jeho dnů jde zařadit.
*/ */
const WorkoutTemplatesPanel = () => { const WorkoutTemplatesPanel = () => {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]); const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editedId, setEditedId] = useState<string | undefined>(); const [edited, setEdited] = useState<WorkoutTemplate | undefined>();
const [name, setName] = useState(''); /** Mění se po uložení, aby se formulář nové šablony vyprázdnil. */
const [items, setItems] = useState<DraftItem[]>([emptyExercise()]); const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | undefined>(); const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -67,72 +38,9 @@ const WorkoutTemplatesPanel = () => {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const resetForm = () => { const closeForm = () => {
setEditedId(undefined); setEdited(undefined);
setName(''); setFormKey(key => key + 1);
setItems([emptyExercise()]);
setError(undefined);
};
const updateItem = (index: number, changes: Partial<DraftItem>) =>
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
/**
* Šablony, které lze vnořit do právě upravované.
*
* Vypadne ta upravovaná (odkaz sám na sebe) a všechny, které samy něco
* skládají — ty už by druhou úroveň zanoření přinesly.
*/
const nestable = templates.filter(template => template.id !== editedId && !template.hasNested);
/** True, pokud upravovanou šablonu už někdo skládá — pak do ní nelze vnořit další. */
const isUsedByOther = !!editedId && (templates.find(t => t.id === editedId)?.usedByCount ?? 0) > 0;
const toItems = (): WorkoutTemplateItem[] => items
.filter(item => item.kind === WorkoutItemKind.SABLONA
? item.templateRefId.length > 0
: item.name.trim().length > 0 && parseIntInput(item.quantity) != null)
.map(item => item.kind === WorkoutItemKind.SABLONA
? { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId }
: {
kind: WorkoutItemKind.CVIK,
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(parseIntInput(item.sets) != null && parseIntInput(item.sets)! > 1
? { sets: parseIntInput(item.sets) }
: {}),
...(parseIntInput(item.caloriesPer100Units) != null
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
: {}),
...(parseWeightKgInput(item.extraWeight) != null
? { extraWeightGrams: parseWeightKgInput(item.extraWeight) }
: {}),
});
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
setError(undefined);
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
resetForm();
} else {
// Server odmítá typicky kvůli pravidlu o jedné úrovni zanoření
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
}
}; };
const remove = async (id: string) => { const remove = async (id: string) => {
@@ -142,50 +50,30 @@ const WorkoutTemplatesPanel = () => {
setBusy(false); setBusy(false);
if (response.data) { if (response.data) {
setTemplates(response.data); setTemplates(response.data);
if (editedId === id) resetForm(); if (edited?.id === id) closeForm();
} else { } else {
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo smazat'); setError((response.error as { error?: string })?.error ?? 'Nepodařilo se smazat');
} }
}; };
/** Načte šablonu do formuláře k úpravě. */
const edit = (template: WorkoutTemplate) => { const edit = (template: WorkoutTemplate) => {
setEditedId(template.id); setEdited(template);
setName(template.name);
setError(undefined);
setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA
? emptyReference(item.templateRefId ?? '')
: {
kind: WorkoutItemKind.CVIK,
name: item.name ?? '',
unit: item.unit ?? ActivityUnit.OPAKOVANI,
quantity: item.quantity != null ? String(item.quantity) : '',
sets: item.sets != null ? String(item.sets) : '',
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
extraWeight: formatKgInput(item.extraWeightGrams),
templateRefId: '',
}));
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}; };
/** Průběžný odhad kalorií rozpracované šablony, včetně vnořených. */
const draftCalories = items.reduce((sum, item) => {
if (item.kind === WorkoutItemKind.SABLONA) {
return sum + (templates.find(t => t.id === item.templateRefId)?.estimatedCalories ?? 0);
}
return sum + (deriveActivityCalories(
parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0);
}, 0);
return ( return (
<> <>
<p className="text-body-secondary small"> <p className="text-body-secondary small">
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím. <strong>Plán</strong> popisuje, jak běžně cvičíte — fixní podle dnů v týdnu, plovoucí jako
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku. sekvenci dnů s volnem na konci. Přehled dne z něj sám navrhne trénink a vy ho jen potvrdíte;
Zanoření je kvůli jednoduchosti jen na jednu úroveň. U cviku jde zapsat vynechané cviky jde ve zvolené lhůtě nahradit jindy. <strong>Volný trénink</strong> je seznam
i dodatečnou zátěž (činky, vesta) — je to jen informace, kalorie nemění. cviků na kdykoli (rozcvička, protažení) a jde vložit i do dne plánu nebo do jiného volného tréninku.
U cviku jde zapsat i dodatečnou zátěž — je to jen informace, kalorie nemění. Úprava plánu
ani tréninku historii nemění, co jste kdy odcvičili, zůstává.
</p> </p>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{loading && ( {loading && (
<div className="empty-state"> <div className="empty-state">
<Spinner animation="border" variant="success" /> <Spinner animation="border" variant="success" />
@@ -195,202 +83,31 @@ const WorkoutTemplatesPanel = () => {
{!loading && !templates.length && ( {!loading && !templates.length && (
<div className="card-panel empty-state"> <div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div> <div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu</div> <div className="empty-title">Zatím nemáte žádný plán ani trénink</div>
<p className="empty-description">Sestavte si ji ve formuláři níže.</p> <p className="empty-description">Sestavte si ji ve formuláři níže.</p>
</div> </div>
)} )}
{!loading && templates.map(template => ( {!loading && sortTemplates(templates).map(template => (
<div className="card-panel template-card" key={template.id}> <WorkoutTemplateCard
<div className="template-head"> key={template.id}
<div className="template-title"> template={template}
{template.name} busy={busy}
{template.hasNested && ( onEdit={() => edit(template)}
<span className="tag tag-import"> onDelete={() => remove(template.id)}
<FontAwesomeIcon icon={faLayerGroup} /> skládaná />
</span>
)}
{template.usedByCount > 0 && (
<span className="tag">používá ji {template.usedByCount}×</span>
)}
</div>
<span className="template-calories">{formatCalories(template.estimatedCalories)}</span>
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => remove(template.id)}
disabled={busy}
title={template.usedByCount > 0 ? 'Nelze smazat, skládá ji jiná šablona' : 'Smazat'}
aria-label="Smazat šablonu"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
<ul className="template-items">
{template.items.map((item, index) => (
// Položky nemají vlastní id, v rámci jedné šablony je index stabilní
<li key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="template-item-icon" />
<span className="template-item-name">{item.templateRefName ?? 'neznámá šablona'}</span>
<span className="template-item-calories">
{formatCalories(item.templateRefCalories)}
</span>
</>
) : (
<>
<span className="template-item-name">{item.name}</span>
<span className="template-item-quantity">
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
</span>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">
{formatExtraWeight(item.extraWeightGrams)}
</span>
)}
<span className="template-item-calories">
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
</span>
</>
)}
</li>
))}
</ul>
</div>
))} ))}
<div className="card-panel template-form"> <WorkoutTemplateForm
<h2 className="chart-title">{editedId ? 'Úprava šablony' : 'Nová šablona'}</h2> key={edited?.id ?? `nova-${formKey}`}
templates={templates}
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>} edited={edited}
onSaved={saved => {
<Form.Group className="mb-3"> setTemplates(saved);
<Form.Label>Název</Form.Label> closeForm();
<Form.Control }}
value={name} onCancel={() => closeForm()}
placeholder="Např. Workout day 1"
onChange={event => setName(event.target.value)}
/> />
</Form.Group>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="workout-item-nested-icon" />
<Form.Select
value={item.templateRefId}
onChange={event => updateItem(index, { templateRefId: event.target.value })}
>
<option value="">— vyberte šablonu —</option>
{nestable.map(template => (
<option key={template.id} value={template.id}>
{template.name} ({template.estimatedCalories} kcal)
</option>
))}
</Form.Select>
</>
) : (
<>
<Form.Control
list="activity-presets-templates"
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-sets"
inputMode="numeric"
value={item.sets}
placeholder="série"
title="Počet sérií"
onChange={event => updateItem(index, { sets: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
title="Množství v jedné sérii"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-weight"
inputMode="decimal"
value={item.extraWeight}
placeholder="zátěž kg"
title="Dodatečná zátěž v kg — činky, vesta. Kalorie nemění."
onChange={event => updateItem(index, { extraWeight: event.target.value })}
/>
</>
)}
<button
className="btn-icon danger"
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<datalist id="activity-presets-templates">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyExercise()])}
>
<FontAwesomeIcon icon={faPlus} /> Cvik
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyReference('')])}
disabled={!nestable.length || isUsedByOther}
title={isUsedByOther
? 'Tuto šablonu už skládá jiná, další zanoření by bylo druhou úrovní'
: (!nestable.length ? 'Není k dispozici žádná šablona, kterou by šlo vnořit' : undefined)}
>
<FontAwesomeIcon icon={faLayerGroup} /> Vnořit šablonu
</Button>
{draftCalories > 0 && (
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
)}
{editedId && (
<Button variant="outline-secondary" size="sm" onClick={() => resetForm()}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={() => save()} disabled={!canSave}>
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
</Button>
</div>
</div>
</> </>
); );
}; };
+55 -10
View File
@@ -1,4 +1,4 @@
import { ActivityDayStats, PlanDeviationKind } from "../../../types"; import { ActivityDayStats, PlanDeviationKind, WorkoutTemplate } from "../../../types";
import { StatsRange } from "../enums"; import { StatsRange } from "../enums";
import { formatDateString } from "../Utils"; import { formatDateString } from "../Utils";
import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared"; 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. */ /** Klíč řady pro šablony, které se do palety nevešly. */
export const OTHER_TEMPLATES_KEY = 'ostatni'; 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. */ /** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */
export type PlanNote = { export type PlanNote = {
/** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */ /** 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; templateName: string;
missing: string[]; missing: string[];
changed: 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:<id>`. */ /** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
@@ -28,12 +39,34 @@ export type ActivitySeriesRow = {
*/ */
planned: number; planned: number;
notes: PlanNote[]; notes: PlanNote[];
[templateKey: string]: number | string | null | PlanNote[]; replacements: ReplacementNote[];
[templateKey: string]: number | string | null | PlanNote[] | ReplacementNote[];
}; };
/** Šablona jako řada grafu. */ /** Šablona jako řada grafu. */
export type TemplateSeries = { key: string, name: string, color: string }; 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. */ /** Klíč řady šablony v řádku grafu. */
export const templateKey = (templateId: string): string => `t:${templateId}`; 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 * 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í". * 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 allTemplateIds identifikátory všech řad (viz {@link listSeriesIds})
* @param used použité šablony v období (id → název) * @param used použité řady v období (id → název)
*/ */
export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): { export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): {
series: TemplateSeries[], keyOf: (templateId: string) => string, series: TemplateSeries[], keyOf: (templateId: string) => string,
@@ -60,7 +93,7 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
.sort(([a], [b]) => colorIndex.get(a)! - colorIndex.get(b)!) .sort(([a], [b]) => colorIndex.get(a)! - colorIndex.get(b)!)
.map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` })); .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)) { 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 }; return { series, keyOf };
} }
@@ -72,13 +105,13 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
* @param days dny s pohybem ze serveru * @param days dny s pohybem ze serveru
* @param range zobrazené období * @param range zobrazené období
* @param mode rozsah statistik * @param mode rozsah statistik
* @param keyOf klíč řady pro šablonu (viz {@link buildTemplateSeries}) * @param keyOf klíč řady podle jejího identifikátoru (viz {@link buildTemplateSeries})
*/ */
export function buildActivitySeries( export function buildActivitySeries(
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string, days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
): ActivitySeriesRow[] { ): ActivitySeriesRow[] {
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days[0]?.date).map(period => [period, { const rows = new Map<string, ActivitySeriesRow>(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) { for (const day of days) {
@@ -86,20 +119,32 @@ export function buildActivitySeries(
if (!row) continue; if (!row) continue;
row.extra += day.extraCalories; row.extra += day.extraCalories;
for (const usage of day.templates) { 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[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories;
row.planned += usage.plannedCalories; row.planned += usage.plannedCalories;
const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO); const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO);
const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO); 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({ row.notes.push({
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }), ...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
templateName: usage.name, templateName: getUsageName(usage),
missing: missing.map(item => item.planned.name), missing: missing.map(item => item.planned.name),
changed: changed.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()]; return [...rows.values()];
} }
+142
View File
@@ -309,3 +309,145 @@
border: none; 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);
}
+111
View File
@@ -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);
+11 -4
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen"; import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils"; import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
import { dropOrphanedPlans } from "./workoutPlans"; import { dropOrphanedPlans, updateReplacementLink } from "./workoutPlans";
const storage = getStorage(); const storage = getStorage();
@@ -230,6 +230,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
); );
if (originalDate !== normalized.date) { if (originalDate !== normalized.date) {
await dropOrphanedPlans(login, originalDate, remaining); await dropOrphanedPlans(login, originalDate, remaining);
if (original.replacesDate) {
await updateReplacementLink(login, original.replacesDate, id, normalized.date);
}
} }
return normalized.date; return normalized.date;
} }
@@ -248,15 +251,19 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
if (!isValidIsoDate(date)) { if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
} }
let found = false; let deleted: ActivityEntry | undefined;
const remaining = await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => { const remaining = await storage.updateData<ActivityEntry[]>(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); return (current ?? []).filter(entry => entry.id !== id);
}); });
if (!found) { if (!deleted) {
throw new NotFoundError("Aktivita nebyla nalezena"); throw new NotFoundError("Aktivita nebyla nalezena");
} }
await dropOrphanedPlans(login, date, remaining); 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; return date;
} }
+8 -2
View File
@@ -6,6 +6,7 @@ import { getSleep } from "./sleep";
import { findWeightOn, getWeights } from "./weights"; import { findWeightOn, getWeights } from "./weights";
import { computeBasalCalories } from "./profile"; import { computeBasalCalories } from "./profile";
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics"; import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
import { getPendingReplacements, getPlannedWorkouts } from "./workoutSchedule";
/** /**
* Spočítá energetickou bilanci dne. * 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)); 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 * 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 * 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 * @param date datum ve formátu YYYY-MM-DD
*/ */
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => { export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([ const [meals, activities, settings, sleepMinutes, weightList, plannedWorkouts, pendingReplacements] = await Promise.all([
getDay(login, date), getDay(login, date),
getActivityDay(login, date), getActivityDay(login, date),
getSettings(login), getSettings(login),
getSleep(login, date), getSleep(login, date),
getWeights(login), getWeights(login),
getPlannedWorkouts(login, date),
getPendingReplacements(login, date),
]); ]);
const weightGrams = getWeightOn(settings, weightList, date); const weightGrams = getWeightOn(settings, weightList, date);
const basal = getBasalOn(settings, weightList, date); const basal = getBasalOn(settings, weightList, date);
@@ -83,6 +87,8 @@ export const getDayOverview = async (login: string, date: string): Promise<DayOv
activities, activities,
energy: buildEnergyBalance( energy: buildEnergyBalance(
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)), meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
plannedWorkouts,
pendingReplacements,
...(sleepMinutes ? { sleepMinutes } : {}), ...(sleepMinutes ? { sleepMinutes } : {}),
...(dayWeight != null ? { weightGrams: dayWeight } : {}), ...(dayWeight != null ? { weightGrams: dayWeight } : {}),
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}), ...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
+16 -5
View File
@@ -3,6 +3,7 @@ import {
applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate, applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate,
getWorkoutTemplates, saveWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
} from "../workouts"; } from "../workouts";
import { replaceSkippedExercise } from "../workoutSchedule";
import { getDayOverview } from "../dayOverview"; import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
@@ -21,8 +22,8 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
router.post("/save", async (req: Request, res: Response, next: NextFunction) => { router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
try { try {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const { id, name, items } = req.body ?? {}; const { id, name, items, ...plan } = req.body ?? {};
res.status(200).json(await saveWorkoutTemplate(login, id, name, items)); res.status(200).json(await saveWorkoutTemplate(login, id, name, items, plan));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@@ -34,12 +35,22 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
} catch (e) { next(e); } } 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) => { router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
try { try {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const { id, date } = req.body ?? {}; const { id, date, dayId, doneIndexes } = req.body ?? {};
const day = await applyWorkoutTemplate(login, id, date); 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)); res.status(200).json(await getDayOverview(login, day));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+98 -25
View File
@@ -1,7 +1,7 @@
import { import {
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem, ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients, ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients,
PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal, PeriodTotal, PlanDeviationKind, ReplacementUsage, TemplateDayUsage, TemplateUsageTotal,
} from "../../types/gen/types.gen"; } from "../../types/gen/types.gen";
import { getRange } from "./meals"; import { getRange } from "./meals";
import { getActivityDay } from "./activities"; 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. */ /** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */
type UsageCounts = { planned: number, removed: number }; 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<string, string> };
/** 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 * Rozebere jeden den: ke každé použité šabloně (u plánu ke každému jeho dni)
* a aktivity mimo šablony vrátí zvlášť. * 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 * 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. * berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde.
*/ */
function analyzeDay( function analyzeDay(
entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map<string, string>, date: string, entries: ActivityEntry[], plans: WorkoutPlan[], templateInfo: Map<string, TemplateInfo>, date: string,
): { day: ActivityDayStats, counts: Map<string, UsageCounts> } { ): { day: ActivityDayStats, counts: Map<string, UsageCounts> } {
const byId = new Map(entries.map(entry => [entry.id, entry])); const byId = new Map(entries.map(entry => [entry.id, entry]));
const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId))); const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId)));
const usages = new Map<string, TemplateDayUsage>(); const usages = new Map<string, TemplateDayUsage>();
const counts = new Map<string, UsageCounts>(); const counts = new Map<string, UsageCounts>();
const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => { const usageFor = (
const usage = usages.get(templateId) ?? { templateId: string, planDayId: string | undefined, name: string, dayName: string | undefined, hasPlan: boolean,
templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [], ): { 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); usages.set(key, usage);
if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 }); const count = counts.get(key) ?? { planned: 0, removed: 0 };
return usage; 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) { for (const plan of plans) {
// Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se // 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 { usage, count } = usageFor(plan.templateId, plan.planDayId, plan.templateName, plan.planDayName, true);
const count = counts.get(plan.templateId)!;
for (const item of plan.items) { for (const item of plan.items) {
usage.plannedCalories += item.calories; usage.plannedCalories += item.calories;
count.planned++; count.planned++;
const entry = item.entryId ? byId.get(item.entryId) : undefined; const entry = item.entryId ? byId.get(item.entryId) : undefined;
if (!entry) { if (!entry) {
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) }); usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
count.removed++; count.removed++;
}
continue; continue;
} }
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
@@ -317,6 +351,7 @@ function analyzeDay(
} }
const extras: ActivitySummaryItem[] = []; const extras: ActivitySummaryItem[] = [];
const replacements: ReplacementUsage[] = [];
for (const entry of entries) { for (const entry of entries) {
if (planned.has(entry.id)) continue; if (planned.has(entry.id)) continue;
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
@@ -324,19 +359,35 @@ function analyzeDay(
extras.push(summarize(entry, calories)); extras.push(summarize(entry, calories));
continue; 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.plannedCalories += calories;
usage.actualCalories += calories; usage.actualCalories += calories;
counts.get(entry.templateId)!.planned++; count.planned++;
} }
const templates = Array.from(usages.values()); const templates = Array.from(usages.values());
const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0); const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0);
const replacementCalories = replacements.reduce((sum, item) => sum + item.item.calories, 0);
return { return {
day: { day: {
date, date,
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories, totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0)
+ replacementCalories + extraCalories,
templates, templates,
replacements,
extraCalories, extraCalories,
extras, extras,
}, },
@@ -349,7 +400,9 @@ function analyzeDay(
* *
* Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při * 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 * 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 login přihlašovací jméno uživatele
* @param from první den období (včetně), YYYY-MM-DD * @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))), Promise.all(dates.map(date => getWorkoutPlans(login, date))),
getWorkoutTemplates(login), 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<string, ActivityPeriodTotal>(); const byDay = new Map<string, ActivityPeriodTotal>();
const byMonth = new Map<string, ActivityPeriodTotal>(); const byMonth = new Map<string, ActivityPeriodTotal>();
@@ -382,28 +438,44 @@ export async function getActivityStats(login: string, from: string, to: string):
activityDays.forEach((activityDay, index) => { activityDays.forEach((activityDay, index) => {
if (!activityDay.entries.length) return; if (!activityDay.entries.length) return;
entryCount += activityDay.entries.length; 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); 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 dayTemplate = 0;
let dayPlanned = 0; let dayPlanned = 0;
for (const usage of day.templates) { for (const usage of day.templates) {
const count = counts.get(usage.templateId)!; const count = counts.get(getUsageKey(usage.templateId, usage.planDayId))!;
const total = byTemplate.get(usage.templateId) ?? { const total = totalFor(usage);
templateId: usage.templateId, name: usage.name, dayCount: 0,
plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
};
// Dny jdou vzestupně, takže zůstane název z posledního použití // Dny jdou vzestupně, takže zůstane název z posledního použití
total.name = usage.name; total.name = usage.name;
if (usage.dayName) total.dayName = usage.dayName;
total.dayCount++; total.dayCount++;
total.plannedCalories += usage.plannedCalories; total.plannedCalories += usage.plannedCalories;
total.actualCalories += usage.actualCalories; total.actualCalories += usage.actualCalories;
total.plannedItemCount += count.planned; total.plannedItemCount += count.planned;
total.doneItemCount += count.planned - count.removed; total.doneItemCount += count.planned - count.removed;
byTemplate.set(usage.templateId, total);
dayTemplate += usage.actualCalories; dayTemplate += usage.actualCalories;
dayPlanned += usage.plannedCalories; 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) { for (const item of day.extras) {
const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 }; const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 };
group.calories += item.calories; group.calories += item.calories;
@@ -432,7 +504,8 @@ export async function getActivityStats(login: string, from: string, to: string):
byMonth: sortPeriods(byMonth), byMonth: sortPeriods(byMonth),
byYear: sortPeriods(byYear), byYear: sortPeriods(byYear),
byTemplate: Array.from(byTemplate.values()) 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()) extras: Array.from(extras.values())
.sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')), .sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')),
days, days,
+3 -3
View File
@@ -157,13 +157,13 @@ describe('šablony tréninků', () => {
}); });
it('odmítne šablonu bez názvu nebo bez položek', async () => { 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/); await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/);
}); });
it('hlásí chybu u neexistující šablony', async () => { it('hlásí chybu u neexistující šablony', async () => {
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/); await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/); await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
}); });
it('šablony jsou oddělené mezi uživateli', async () => { it('šablony jsou oddělené mezi uživateli', async () => {
+1 -1
View File
@@ -79,7 +79,7 @@ describe('překlopení aktivit do šablony tréninku', () => {
await addActivity(LOGIN, kliky); await addActivity(LOGIN, kliky);
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' ')) 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 () => { it('vytvořená šablona jde rovnou použít', async () => {
+3 -3
View File
@@ -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)]); const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)])) 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 () => { it('nedovolí odkaz na neexistující šablonu', async () => {
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')])) 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 () => { it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]); const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]); await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 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 () => { it('šablonu bez vazeb smazat lze', async () => {
@@ -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/);
});
});
+25
View File
@@ -74,6 +74,31 @@ export function listDatesInRange(from: string, to: string): string[] {
return dates; 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. */ /** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
const MAX_MULTIPLIER = 100; const MAX_MULTIPLIER = 100;
+109 -6
View File
@@ -1,22 +1,33 @@
import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen"; import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
import { NotFoundError } from "./utils";
const storage = getStorage(); 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. */ /** Jeden cvik plánu — jak ho šablona založila. */
export type WorkoutPlanItem = { export type WorkoutPlanItem = {
/** /**
* Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni * 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 * 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 * stejný cvik dvakrát. Chybí u cviku, který se při potvrzení dne plánu
* šablony, kde se cvik ve dni nenašel. * odškrtl jako vynechaný.
*/ */
entryId?: string; entryId?: string;
name: string; name: string;
unit: ActivityUnit; unit: ActivityUnit;
quantity: number; quantity: number;
sets?: number; sets?: number;
caloriesPer100Units?: number;
extraWeightGrams?: number;
calories: 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í * 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 * 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 = { 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; templateId: string;
/** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */ /** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */
templateName: string; 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; appliedAt: string;
items: WorkoutPlanItem[]; items: WorkoutPlanItem[];
}; };
@@ -37,6 +59,16 @@ export type WorkoutPlan = {
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */ /** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`; 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. * 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 * @param date datum ve formátu YYYY-MM-DD
*/ */
export const getWorkoutPlans = async (login: string, date: string): Promise<WorkoutPlan[]> => export const getWorkoutPlans = async (login: string, date: string): Promise<WorkoutPlan[]> =>
await storage.getData<WorkoutPlan[]>(getKey(login, date)) ?? []; (await storage.getData<WorkoutPlan[]>(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<string[]> => {
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. * Zapamatuje si plán použité šablony.
@@ -57,6 +105,56 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko
await storage.updateData<WorkoutPlan[]>(getKey(login, date), current => [...(current ?? []), plan]); await storage.updateData<WorkoutPlan[]>(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<void> => {
let found = false;
await storage.updateData<WorkoutPlan[]>(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<void> => {
const key = getKey(login, sourceDate);
if (!await storage.hasData(key)) return;
await storage.updateData<WorkoutPlan[]>(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. * 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 * 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á. * 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 login přihlašovací jméno uživatele
* @param date datum dne * @param date datum dne
* @param remaining aktivity, které ve dni zůstaly * @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); const key = getKey(login, date);
if (!await storage.hasData(key)) return; if (!await storage.hasData(key)) return;
const ids = new Set(remaining.map(entry => entry.id)); const ids = new Set(remaining.map(entry => entry.id));
await storage.updateData<WorkoutPlan[]>(key, await storage.updateData<WorkoutPlan[]>(key, current => (current ?? []).filter(plan =>
current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId)))); plan.items.some(item => (item.entryId && ids.has(item.entryId)) || item.replacement)
|| plan.items.every(item => !item.entryId)));
}; };
+264
View File
@@ -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<string, StoredTemplate>,
): Pick<PlannedWorkout, 'status' | 'dayId' | 'dayName' | 'items'> => ({
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<string, WorkoutPlan[]>, 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<string, WorkoutPlan[]>,
today: string,
byId: Map<string, StoredTemplate>,
): 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<PlannedWorkout[]> => {
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<PendingReplacement[]> => {
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<string> => {
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;
};
+333 -93
View File
@@ -1,85 +1,172 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { import {
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, ActivityEntry, ActivityUnit, PlannedWorkoutItem, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate,
WorkoutTemplateItem, WorkoutTemplateType,
} from "../../types/gen/types.gen"; } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities"; 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"; import { recordWorkoutPlan } from "./workoutPlans";
const storage = getStorage(); 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; 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. */ /** Klíč úložiště se šablonami tréninků jednoho uživatele. */
const getKey = (login: string): string => `workouts:${login}`; const getKey = (login: string): string => `workouts:${login}`;
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */ /** Uložený den plánu — dopočítávaná pole se neukládají. */
type StoredTemplate = { export type StoredPlanDay = {
id: string; id: string;
name: string; name: string;
weekday?: number;
items: WorkoutTemplateItem[]; 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. */ /** Vrátí true, pokud je položka odkazem na jinou šablonu. */
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA; 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í. * Doplní položkám druh, pokud chybí.
* *
* Šablony uložené dřív, než přibylo skládání z jiných šablon, pole `kind` * Š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. * 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, ...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í. */ /** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */
const itemCalories = (item: WorkoutTemplateItem): number => const itemCalories = (item: WorkoutTemplateItem): number =>
Math.round(getTotalQuantity(item.quantity ?? 0, item.sets) / 100 * (item.caloriesPer100Units ?? 0)); 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<string, StoredTemplate>, 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<string, StoredTemplate>): 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), * 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á. * názvy vnořených šablon a informaci, kolik jiných šablon ji používá.
*/ */
const withDerived = (template: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => { const withDerived = (template: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => {
const byId = new Map(all.map(item => [item.id, item])); const byId = new Map(all.map(item => [item.id, item]));
let calories = 0;
let hasNested = false;
const items = template.items.map(item => { const deriveItems = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => items.map(item => {
if (!isTemplateRef(item)) { if (!isTemplateRef(item)) return item;
calories += itemCalories(item);
return item;
}
hasNested = true;
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined; 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 { return {
...item, ...item,
...(nested ? { templateRefName: nested.name } : {}), ...(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 { return {
id: template.id, id: template.id,
name: template.name, name: template.name,
items, type: getTemplateType(template),
estimatedCalories: calories, items: deriveItems(template.items),
hasNested, ...(days ? { days } : {}),
usedByCount: all.filter(other => other.id !== template.id ...(template.replaceWithinDays != null ? { replaceWithinDays: template.replaceWithinDays } : {}),
&& other.items.some(item => isTemplateRef(item) && item.templateRefId === template.id)).length, ...(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. */ /** Načte uložené šablony uživatele se doplněným druhem položek. */
const loadTemplates = async (login: string): Promise<StoredTemplate[]> => export const loadTemplates = async (login: string): Promise<StoredTemplate[]> =>
(await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(withKind); (await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(fromStorage);
/** /**
* Vrátí šablony tréninků uživatele seřazené podle názvu. * 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 (kind === WorkoutItemKind.SABLONA) {
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) { 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 // Název a odhad kalorií vnořené šablony si server dopočítá sám
return { kind, templateRefId: value.templateRefId }; 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<string, unknown>;
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: * 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ň * š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 checkReferences = (template: StoredTemplate, all: StoredTemplate[]): void => {
const byId = new Map(all.map(template => [template.id, template])); const byId = new Map(all.map(item => [item.id, item]));
const nestedRefs = items.filter(isTemplateRef); const plan = isPlan(template);
const refs = [...template.items, ...(template.days ?? []).flatMap(day => day.items)].filter(isTemplateRef);
for (const item of nestedRefs) { for (const item of refs) {
if (item.templateRefId === id) { if (item.templateRefId === template.id) {
throw new BadRequestError("Šablona nemůže odkazovat sama na sebe"); throw new BadRequestError("Trénink nemůže obsahovat sám sebe");
} }
const nested = byId.get(item.templateRefId!); const nested = byId.get(item.templateRefId!);
if (!nested) { 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( 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] = findNestingTemplates(template.id, all);
const usedBy = all.find(template => template.id !== id const [usedInPlan] = findUsingPlans(template.id, all);
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id)); if (plan && (usedBy || usedInPlan)) {
if (usedBy) {
throw new BadRequestError( 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ň`); `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. * 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 login přihlašovací jméno uživatele
* @param id identifikátor upravované šablony, nebo nic pro založení nové * @param id identifikátor upravované šablony, nebo nic pro založení nové
* @param name název šablony * @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 ( export const saveWorkoutTemplate = async (
login: string, login: string,
id: unknown, id: unknown,
name: unknown, name: unknown,
items: unknown, items: unknown,
plan?: unknown,
): Promise<WorkoutTemplate[]> => { ): Promise<WorkoutTemplate[]> => {
if (typeof name !== 'string' || !name.trim().length) { if (typeof name !== 'string' || !name.trim().length) {
throw new BadRequestError("Nebyl předán název šablony"); throw new BadRequestError("Nebyl předán název tréninku");
}
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`);
} }
const options = (plan ?? {}) as Record<string, unknown>;
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 = { let normalized: StoredTemplate;
id: typeof id === 'string' && id.length ? id : randomUUID(), if (type === WorkoutTemplateType.VOLNY) {
normalized = {
id: templateId,
name: name.trim(), name: name.trim(),
items: items.map(normalizeItem), items: normalizeItems(items, "Trénink musí mít alespoň jednu položku"),
}; };
checkNesting(normalized.id, normalized.items, await loadTemplates(login)); } 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<StoredTemplate> = { ...normalized };
if (isPlan(normalized)) delete toStore.items;
await storage.updateData<StoredTemplate[]>(getKey(login), current => { await storage.updateData<StoredTemplate[]>(getKey(login), current => {
const others = (current ?? []).filter(template => template.id !== normalized.id); const others = (current ?? []).filter(template => template.id !== normalized.id);
return [...others, normalized]; return [...others, toStore as StoredTemplate];
}); });
return getWorkoutTemplates(login); return getWorkoutTemplates(login);
}; };
/** /**
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze — * Smaže šablonu tréninku. Šablonu, kterou skládá jiná nebo kterou má plán
* jinak by ve složené šabloně zůstal odkaz do prázdna. * 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 login přihlašovací jméno uživatele
* @param id identifikátor šablony * @param id identifikátor šablony
*/ */
export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise<WorkoutTemplate[]> => { export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise<WorkoutTemplate[]> => {
if (typeof id !== 'string' || !id.length) { if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony"); throw new BadRequestError("Nebyl předán identifikátor tréninku");
} }
const all = await loadTemplates(login); const all = await loadTemplates(login);
if (!all.some(template => template.id === id)) { 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 const [usedBy] = [...findNestingTemplates(id, all), ...findUsingPlans(id, all)];
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
if (usedBy) { 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<StoredTemplate[]>(getKey(login), await storage.updateData<StoredTemplate[]>(getKey(login),
@@ -236,20 +429,65 @@ export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise
return getWorkoutTemplates(login); return getWorkoutTemplates(login);
}; };
/** Ověří výčet odcvičených cviků; bez něj se berou všechny. */
const normalizeDoneIndexes = (value: unknown, count: number): Set<number> => {
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, 'templateId' | 'planDayId' | 'replacesDate'>,
): 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, * 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, * aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
* odkud přišly. * 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 login přihlašovací jméno uživatele
* @param id identifikátor šablony * @param id identifikátor šablony
* @param date den, do kterého se položky založí * @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<string> => { export const applyWorkoutTemplate = async (
login: string, id: unknown, date: unknown, dayId?: unknown, doneIndexes?: unknown,
): Promise<string> => {
if (typeof id !== 'string' || !id.length) { if (typeof id !== 'string' || !id.length) {
throw new BadRequestError("Nebyl předán identifikátor šablony"); throw new BadRequestError("Nebyl předán identifikátor tréninku");
} }
if (!isValidIsoDate(date)) { if (!isValidIsoDate(date)) {
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
@@ -257,48 +495,50 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
const all = await loadTemplates(login); const all = await loadTemplates(login);
const template = all.find(item => item.id === id); const template = all.find(item => item.id === id);
if (!template) { 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 byId = new Map(all.map(item => [item.id, item]));
const exercises = template.items.flatMap(item => const exercises = expandExercises(day ? day.items : template.items, byId).map(toPlannedItem);
isTemplateRef(item) ? (byId.get(item.templateRefId!)?.items ?? []) : [item]); const done = normalizeDoneIndexes(doneIndexes, exercises.length);
if (!day && !done.size) {
throw new BadRequestError("Nebyl vybrán žádný cvik");
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const entries: ActivityEntry[] = exercises.map(item => ({ const link = { templateId: template.id, ...(day ? { planDayId: day.id } : {}) };
id: randomUUID(), const entries = exercises.map((item, index) =>
date, done.has(index) ? createActivityFromExercise(item, date, now, link) : undefined);
name: item.name!,
unit: item.unit!,
quantity: item.quantity!,
...(item.sets != null ? { sets: item.sets } : {}),
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
calories: itemCalories(item),
templateId: template.id,
createdAt: now,
}));
await addActivities(login, date, entries); await addActivities(login, date, entries.filter(entry => !!entry));
await recordWorkoutPlan(login, date, { await recordWorkoutPlan(login, date, {
id: randomUUID(),
templateId: template.id, templateId: template.id,
templateName: template.name, templateName: template.name,
...(day ? { planDayId: day.id, planDayName: day.name } : {}),
...(day && template.replaceWithinDays ? { replaceWithinDays: template.replaceWithinDays } : {}),
appliedAt: now, appliedAt: now,
items: entries.map(entry => ({ items: exercises.map((item, index) => ({
entryId: entry.id, ...(entries[index] ? { entryId: entries[index].id } : {}),
name: entry.name, ...item,
unit: entry.unit,
quantity: entry.quantity,
...(entry.sets != null ? { sets: entry.sets } : {}),
calories: entry.calories ?? 0,
})), })),
}); });
return date; 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ý * 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. * "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 const activities = (await getActivityDay(login, date)).entries
.filter(entry => !selected || selected.has(entry.id)); .filter(entry => !selected || selected.has(entry.id));
if (!activities.length) { 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 => ({ const items = activities.map(entry => ({
+2
View File
@@ -84,6 +84,8 @@ paths:
$ref: "./paths/workouts/apply.yml" $ref: "./paths/workouts/apply.yml"
/workouts/fromActivities: /workouts/fromActivities:
$ref: "./paths/workouts/fromActivities.yml" $ref: "./paths/workouts/fromActivities.yml"
/workouts/replace:
$ref: "./paths/workouts/replace.yml"
# Recepty (/api/mealTemplates) # Recepty (/api/mealTemplates)
/mealTemplates: /mealTemplates:
+15 -1
View File
@@ -1,6 +1,9 @@
post: post:
operationId: applyWorkoutTemplate 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: requestBody:
required: true required: true
content: content:
@@ -16,6 +19,17 @@ post:
description: Den, do kterého se položky založí description: Den, do kterého se položky založí
type: string type: string
format: date 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: responses:
"200": "200":
description: Aktualizovaný přehled dne description: Aktualizovaný přehled dne
+40
View File
@@ -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
+2 -13
View File
@@ -1,23 +1,12 @@
post: post:
operationId: saveWorkoutTemplate 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: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
type: object $ref: "../../schemas/_index.yml#/WorkoutTemplateInput"
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"
responses: responses:
"200": "200":
description: Aktualizovaný seznam šablon description: Aktualizovaný seznam šablon
+315 -7
View File
@@ -884,6 +884,15 @@ ActivityEntry:
templateId: templateId:
description: Identifikátor šablony, ze které aktivita vznikla description: Identifikátor šablony, ze které aktivita vznikla
type: string 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: createdAt:
description: Čas vzniku záznamu description: Čas vzniku záznamu
type: string type: string
@@ -992,10 +1001,22 @@ WorkoutTemplateItem:
description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje. description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje.
type: integer type: integer
WorkoutTemplate: WorkoutTemplateType:
description: | description: |
Předpřipravený trénink. Použitím se jeho položky založí do zvoleného dne, Typ šablony tréninku.
takže opakovaný trénink není potřeba zadávat cvik po cviku znovu. 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 type: object
additionalProperties: false additionalProperties: false
required: required:
@@ -1003,20 +1024,95 @@ WorkoutTemplate:
- name - name
- items - items
- estimatedCalories - 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 - hasNested
- usedByCount - usedByCount
- usedInPlanCount
properties: properties:
id: id:
type: string type: string
name: name:
description: Název šablony, např. "Workout day 1" description: Název šablony, např. "Workout day 1"
type: string type: string
type:
$ref: "#/WorkoutTemplateType"
items: items:
description: Položky volné šablony; u plánu je seznam prázdný a cviky jsou ve dnech
type: array type: array
items: items:
$ref: "#/WorkoutTemplateItem" $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: 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 type: integer
hasNested: hasNested:
description: | description: |
@@ -1024,8 +1120,155 @@ WorkoutTemplate:
už nelze vnořit do další — zanoření je povolené jen na jednu úroveň. už nelze vnořit do další — zanoření je povolené jen na jednu úroveň.
type: boolean type: boolean
usedByCount: usedByCount:
description: Kolik jiných šablon tuto šablonu používá description: Kolik jiných volných šablon tuto šablonu skládá
type: integer 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: EnergyBalance:
description: Energetická bilance dne — kolik přišlo jídlem a kolik se vydalo description: Energetická bilance dne — kolik přišlo jídlem a kolik se vydalo
@@ -1078,6 +1321,8 @@ DayOverview:
- meals - meals
- activities - activities
- energy - energy
- plannedWorkouts
- pendingReplacements
properties: properties:
date: date:
type: string type: string
@@ -1086,6 +1331,16 @@ DayOverview:
$ref: "#/DayRecord" $ref: "#/DayRecord"
activities: activities:
$ref: "#/ActivityDay" $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: energy:
$ref: "#/EnergyBalance" $ref: "#/EnergyBalance"
sleepMinutes: sleepMinutes:
@@ -1389,6 +1644,12 @@ TemplateUsageTotal:
name: name:
description: Název šablony v době použití description: Název šablony v době použití
type: string 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: dayCount:
description: Počet dní, ve kterých se šablona použila description: Počet dní, ve kterých se šablona použila
type: integer type: integer
@@ -1402,7 +1663,9 @@ TemplateUsageTotal:
description: Kolik cviků šablona založila description: Kolik cviků šablona založila
type: integer type: integer
doneItemCount: 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 type: integer
ActivityGroupTotal: ActivityGroupTotal:
@@ -1513,6 +1776,7 @@ ActivityDayStats:
- date - date
- totalCalories - totalCalories
- templates - templates
- replacements
- extraCalories - extraCalories
- extras - extras
properties: properties:
@@ -1527,6 +1791,11 @@ ActivityDayStats:
type: array type: array
items: items:
$ref: "#/TemplateDayUsage" $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: extraCalories:
description: Součet kcal aktivit mimo šablony description: Součet kcal aktivit mimo šablony
type: integer type: integer
@@ -1536,8 +1805,36 @@ ActivityDayStats:
items: items:
$ref: "#/ActivitySummaryItem" $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: 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 type: object
additionalProperties: false additionalProperties: false
required: required:
@@ -1553,6 +1850,11 @@ TemplateDayUsage:
name: name:
description: Název šablony v době použití description: Název šablony v době použití
type: string type: string
planDayId:
type: string
dayName:
description: Název dne plánu v době použití
type: string
plannedCalories: plannedCalories:
type: integer type: integer
actualCalories: actualCalories:
@@ -1572,10 +1874,12 @@ PlanDeviationKind:
description: | description: |
ODEBRANO — cvik ze šablony ve dni chybí. ODEBRANO — cvik ze šablony ve dni chybí.
UPRAVENO — cvik je, ale s jiným množstvím, počtem sérií nebo násobkem. 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 type: string
enum: enum:
- ODEBRANO - ODEBRANO
- UPRAVENO - UPRAVENO
- NAHRAZENO
PlanDeviation: PlanDeviation:
description: Odchylka jednoho cviku od plánu šablony description: Odchylka jednoho cviku od plánu šablony
@@ -1593,6 +1897,10 @@ PlanDeviation:
description: Skutečné provedení; u odebraného cviku chybí description: Skutečné provedení; u odebraného cviku chybí
allOf: allOf:
- $ref: "#/ActivitySummaryItem" - $ref: "#/ActivitySummaryItem"
replacementDate:
description: U nahrazeného cviku den, kdy se nahradil
type: string
format: date
ActivitySummaryItem: ActivitySummaryItem:
description: Stručný popis jedné aktivity pro statistiky description: Stručný popis jedné aktivity pro statistiky