chore: graf db
feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
1 parent
865c2c3b22
commit
7641e26c9f
43 files changed
+3459
-599
No files matched your search
@@ -62,7 +62,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
||||
- **Domain:** `meals.ts` (jídlo a historie jídel), `mealGroups.ts` (seznam jídel —
|
||||
skupiny podle názvu a zdroje, doplnění a přejmenování), `mealPhotos.ts` (fotky
|
||||
jídel), `nutrients.ts` (živiny), `mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony
|
||||
tréninků), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts`
|
||||
tréninků a plány), `workoutSchedule.ts` (co plán navrhuje na den, náhrady
|
||||
vynechaných cviků), `workoutPlans.ts` (záznamy použití šablon), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts`
|
||||
(tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle),
|
||||
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
||||
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
|
||||
@@ -76,11 +77,13 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||
`MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
|
||||
hromadným doplněním údajů a přejmenováním),
|
||||
`WorkoutsPage` (`/treninky` — šablony tréninků), `ProfilePage` (Profil — ukazatele,
|
||||
`WorkoutsPage` (`/treninky` — plány a šablony tréninků), `ProfilePage` (Profil — ukazatele,
|
||||
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
||||
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
||||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset,
|
||||
PlannedWorkoutCard a PendingReplacementsPanel — návrh tréninku a náhrady v přehledu dne,
|
||||
WorkoutTemplateForm/WorkoutTemplateCard — šablony a plány)
|
||||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||||
výběr z historie a receptů, SettingsModal, ImportModal, SpinWheelModal — kolo štěstí)
|
||||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||
@@ -186,7 +189,7 @@ trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série
|
||||
výchozí stav a neukládá se.
|
||||
|
||||
Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` —
|
||||
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. Záznamy ve dni
|
||||
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne volná šablona. Záznamy ve dni
|
||||
zůstanou beze změny. U jídla nic takového není, opakovaně jedená jídla se nabízejí
|
||||
z historie (viz Historie jídel a recepty).
|
||||
|
||||
@@ -195,17 +198,49 @@ je jen informace o tréninku, kalorie **nemění** — žádný jednoduchý vzor
|
||||
spotřebu podle zátěže nedává, takže by šlo o vymyšlené číslo. Kdo chce, promítne
|
||||
ji do sazby na 100 jednotek.
|
||||
|
||||
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď
|
||||
cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se
|
||||
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou
|
||||
samostatné, takže úprava založené položky šablonu nemění.
|
||||
`WorkoutTemplate` (`workouts.ts`) má typ (`WorkoutTemplateType`):
|
||||
|
||||
**Zanoření je povolené jen na jednu úroveň** a `checkNesting` to hlídá z obou stran:
|
||||
šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít
|
||||
skládat, pokud ji už někdo používá. Bez druhé kontroly by druhá úroveň vznikla
|
||||
oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — zůstal by odkaz
|
||||
do prázdna. Šablony uložené dřív, než skládání přibylo, nemají `kind`; `withKind`
|
||||
jim ho při načtení doplní na `CVIK`.
|
||||
- **VOLNY** — pojmenovaný seznam položek, do dne se zakládá ručně. Výchozí typ,
|
||||
nezapisuje se; šablony uložené dřív, než typy přibyly, jsou volné.
|
||||
- **FIXNI** — plán s dny (`days`) navázanými na den v týdnu (`weekday` 1–7).
|
||||
- **PLOVOUCI** — plán jako sekvence dnů zakončená dnem volna (volno se jako den
|
||||
nezapisuje, plyne z typu).
|
||||
|
||||
Položka je buď cvik (`kind: CVIK`), nebo odkaz na volnou šablonu (`kind: SABLONA`),
|
||||
jejíž cviky se při použití rozbalí (`expandExercises`). Použitím vzniknou běžné
|
||||
aktivity s vazbou `templateId` (u plánu i `planDayId`) — jsou samostatné, takže
|
||||
úprava založené položky šablonu nemění. Plán má navíc `replaceWithinDays`
|
||||
(lhůta pro náhradu vynechaných cviků) a `startDate`. Dny plánu mají stálé `id`,
|
||||
podle kterého se v historii pozná, který den se kdy cvičil, i po úpravě plánu.
|
||||
|
||||
**Zanoření volných šablon je povolené jen na jednu úroveň** a `checkReferences`
|
||||
to hlídá z obou stran: šablona nesmí odkazovat na takovou, která sama něco skládá,
|
||||
a zároveň nesmí začít skládat, pokud ji už jiná volná šablona používá. Bez druhé
|
||||
kontroly by druhá úroveň vznikla oklikou přes úpravu. Den plánu úroveň nepřidává,
|
||||
smí odkázat na libovolnou volnou šablonu; plán sám do ničeho vnořit nejde a volná
|
||||
šablona, kterou něco používá, se nemůže stát plánem. Smazat nelze šablonu, kterou
|
||||
skládá jiná nebo kterou má plán v některém dni — zůstal by odkaz do prázdna.
|
||||
Šablony uložené dřív, než skládání přibylo, nemají `kind`; `withKind` jim ho při
|
||||
načtení doplní na `CVIK`.
|
||||
|
||||
**Návrh tréninku** (`getPlannedWorkouts` ve `workoutSchedule.ts`, v přehledu dne
|
||||
`DayOverview.plannedWorkouts`): fixní plán navrhne den podle dne v týdnu.
|
||||
Plovoucí prochází záznamy použití od `startDate` jako stavový automat
|
||||
(`advanceDone`, `advanceGap`): odcvičený den posune sekvenci, den bez tréninku je
|
||||
řádné volno (když je na řadě), **posunuté volno** (uprostřed rozběhnutého cyklu,
|
||||
jednou za cyklus — po posledním dni se pak pokračuje rovnou od začátku), jinak
|
||||
trénink jen odsune. Budoucí dny se počítají, jako by se navržený trénink splnil.
|
||||
Návrh se potvrdí přes `applyWorkoutTemplate` s `dayId` a `doneIndexes`
|
||||
(pořadí v rozbaleném seznamu cviků); odškrtnuté cviky se zapíšou do záznamu bez
|
||||
`entryId`, jako vynechané. Den jde potvrdit i celý jako vynechaný.
|
||||
|
||||
**Náhrady** (`getPendingReplacements`, `replaceSkippedExercise`,
|
||||
`POST /api/workouts/replace`): vynechaný cvik (bez aktivity a bez náhrady) se
|
||||
nabízí v přehledu dne po dobu `replaceWithinDays` ze záznamu (lhůta, jaká platila
|
||||
v den vynechání, nejvýš 14). Náhrada založí aktivitu s `replacesDate` a k cviku
|
||||
v záznamu původního dne zapíše `replacement` (datum a `entryId`). Smazání náhradní
|
||||
aktivity vazbu zruší, přesun jí změní datum (`updateReplacementLink`). Náhrada ani
|
||||
den volna plovoucí plán neposouvají — posouvá ho jen záznam dne plánu.
|
||||
|
||||
`buildEnergyBalance` v `dayOverview.ts` počítá:
|
||||
`bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit.
|
||||
@@ -251,8 +286,15 @@ jednotlivé cviky s `entryId` aktivity, která z nich vznikla; podle něj
|
||||
`analyzeDay` u každého dne pozná cvik **odebraný** (aktivita chybí) a **upravený**
|
||||
(jiné množství, série nebo násobek) — podle názvu by to nešlo, šablona může mít
|
||||
stejný cvik dvakrát. Smazání všech cviků šablony ve dni `dropOrphanedPlans`
|
||||
bere jako vrácené použití a plán zahodí. Aktivity ze šablony bez plánu (cvik
|
||||
bere jako vrácené použití a plán zahodí — kromě záznamu vynechaného celého dne
|
||||
plánu a záznamu, jehož cvik už má náhradu. Aktivity ze šablony bez plánu (cvik
|
||||
přesunutý z jiného dne) se počítají jako splněné přesně dle plánu.
|
||||
Plán se ve statistikách dělí po dnech (klíč `templateId:planDayId`). Nahrazený
|
||||
cvik je odchylka **NAHRAZENO** s `replacementDate` a počítá se jako provedený;
|
||||
náhrada sama je v `ActivityDayStats.replacements` dne, kdy se odcvičila, a její
|
||||
kalorie jdou do skutečnosti dne plánu, ze kterého pochází. Graf u takového dne
|
||||
místo „!" ukáže šipku na den náhrady. Dny volna ani necvičené dny plánu se do
|
||||
statistik nijak nepromítají.
|
||||
|
||||
**Statistiky příjmu** (`getIntakeStats`, `GET /api/stats/intake`) jsou protějšek
|
||||
výdeje: příjem po chodech a zdrojích, živiny a porovnání s výdejem. Výdej
|
||||
|
||||
@@ -59,6 +59,192 @@ JWT_SECRET=... docker compose up --build
|
||||
Produkčně běží aplikace nad Redisem, ve vývoji nad JSON souborem
|
||||
(`server/data/db.json`). Viz `server/.env.template`.
|
||||
|
||||
## Datový model
|
||||
|
||||
Úložiště je klíč–hodnota (JSON soubor ve vývoji, Redis v produkci). „Tabulka" je
|
||||
tedy typ klíče a hodnotou je pod ním buď celé pole záznamů, nebo jeden objekt.
|
||||
Klíče začínají typem a přihlašovacím jménem uživatele, data jednotlivých
|
||||
uživatelů se nemíchají.
|
||||
|
||||
| Klíč | Hodnota |
|
||||
|---|---|
|
||||
| `meals:<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
|
||||
|
||||
Přehled dne má dvě záložky:
|
||||
@@ -75,25 +261,42 @@ včetně spánku a žádná standardní rovnice s délkou spánku nepočítá.
|
||||
|
||||
Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`.
|
||||
|
||||
## Šablony tréninků
|
||||
## Tréninky a plány
|
||||
|
||||
Opakované tréninky se ukládají jako šablony na stránce **Šablony** — v přehledu dne
|
||||
pak stačí jedno kliknutí a celý trénink se založí, místo zadávání cviku po cviku.
|
||||
Do šablony jde zařadit i **jinou šablonu**, třeba rozcvičku, kterou máte v každém
|
||||
tréninku; při použití se její cviky rozbalí. Zanoření je kvůli jednoduchosti jen
|
||||
na jednu úroveň a aplikace to hlídá.
|
||||
Na stránce **Tréninky** jsou tři typy:
|
||||
|
||||
- **Plovoucí plán** — sekvence tréninkových dnů (Den 1, Den 2, Den 3…), po které
|
||||
následuje den volna a cyklus se opakuje. Na kalendář navázaný není: další den
|
||||
přijde na řadu, až se splní předchozí. Den volna jde v rámci cyklu jednou
|
||||
**posunout dopředu** — když uprostřed cyklu den necvičíte, bere se to jako volno
|
||||
a po posledním dni se pokračuje rovnou od začátku (Den 1, Den 2, volno, Den 3,
|
||||
Den 1…). Další necvičený den trénink jen odsune.
|
||||
- **Fixní plán** — pevné dny v týdnu (pondělí nohy, středa běh…), ostatní dny jsou volno.
|
||||
- **Volný trénink** — seznam cviků na kdykoli (rozcvička, protažení). Jde ho vložit
|
||||
do dne plánu i do jiného volného tréninku; vkládání je jen na jednu úroveň.
|
||||
|
||||
Plán se založí jednou a **přehled dne** pak sám navrhne, co je ten den na řadě.
|
||||
Po odcvičení stačí návrh potvrdit; co se nestihlo, se odškrtne a zapíše jako
|
||||
vynechané. Nad rámec plánu se ručně přidávají jen „unikátnosti" — chůze, kroky,
|
||||
jednorázový běh. Jiný den plánu, než který je na řadě, nebo volný trénink jde
|
||||
založit tlačítkem *Z tréninků*.
|
||||
|
||||
U plánu jde nastavit **Nahraditelné během N dní**: vynechaný cvik (odškrtnutý při
|
||||
potvrzení nebo smazaný ze dne) se pak v následujících N dnech nabízí v přehledu
|
||||
dne k náhradě. Ve statistikách výdeje se u takového dne místo vykřičníku ukáže
|
||||
šipka na den náhrady. Dny volna ani necvičené dny se ve statistikách nijak
|
||||
nezapisují — zobrazuje se jen to, co proběhlo.
|
||||
|
||||
Plán i trénink jde kdykoli **vylepšit** — přidat cviky, dny, zvýšit zátěž nebo
|
||||
počet opakování. Historie zůstává, jak byla: každé použití si pamatuje, co plán
|
||||
v ten den sliboval.
|
||||
|
||||
Založené položky jsou samostatné aktivity, takže je jde v dni doupravit, aniž by
|
||||
se změnila šablona.
|
||||
se změnil plán. Volný trénink jde vytvořit i **opačným směrem** — z toho, co
|
||||
už máte v dni zapsané, tlačítkem *Uložit jako trénink* v záložce Výdej.
|
||||
|
||||
Šablonu jde vytvořit i **opačným směrem** — z toho, co už máte v dni zapsané.
|
||||
U jídla ikonou záložky v řádku, u tréninku tlačítkem *Uložit jako šablonu* v záložce
|
||||
Výdej (vybere se, které cviky zahrnout). Při zakládání jídla nebo aktivity je
|
||||
v dialogu i zaškrtávátko *Uložit i jako šablonu*.
|
||||
|
||||
Na druhé záložce jsou **šablony jídel** — opakovaně jedené jídlo (název, zdroj,
|
||||
kalorie, gramáž, cena), které se v přehledu dne založí jedním kliknutím. Ty se
|
||||
neskládají z jiných. U domácích jídel jde do poznámky zapsat postup přípravy.
|
||||
Recepty (vařená jídla se surovinami a postupem) jsou rozšířením jídla v seznamu
|
||||
jídel na stránce **Jídla**.
|
||||
|
||||
## Osobní údaje
|
||||
|
||||
|
||||
@@ -796,6 +796,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Název plánu nad jeho dny ve výběru šablony
|
||||
.workout-plan-title {
|
||||
margin: 16px 0 2px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
// Ikona u skládané šablony ve výběru
|
||||
.workout-nested-icon {
|
||||
margin-left: 8px;
|
||||
|
||||
@@ -73,6 +73,11 @@ export function getDayOfWeek(date: string): string {
|
||||
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date));
|
||||
}
|
||||
|
||||
const SHORT_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' });
|
||||
|
||||
/** Vrátí krátký zápis data s dnem v týdnu, např. "po 3. 3." — pro odkazy mezi blízkými dny. */
|
||||
export const formatShortDate = (date: string): string => SHORT_DATE_FORMAT.format(parseIsoDate(date));
|
||||
|
||||
/** Vrátí čitelný zápis data, např. "3. března 2025". */
|
||||
export function getHumanDate(date: string): string {
|
||||
return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActivityEntry } from "../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../enums";
|
||||
import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils";
|
||||
import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets, formatShortDate } from "../Utils";
|
||||
import RowActions from "./RowActions";
|
||||
import MultiplierControl from "./MultiplierControl";
|
||||
|
||||
@@ -24,7 +24,14 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChan
|
||||
{!!entry.extraWeightGrams && (
|
||||
<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>
|
||||
{entry.note && <div className="meal-note">{entry.note}</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;
|
||||
@@ -19,8 +19,8 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readon
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onSaveTemplate()}
|
||||
title="Uložit jako šablonu"
|
||||
aria-label={`Uložit ${label} jako šablonu`}
|
||||
title="Uložit jako trénink"
|
||||
aria-label={`Uložit ${label} jako trénink`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</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"
|
||||
id="activity-also-template"
|
||||
checked={alsoTemplate}
|
||||
label="Uložit i jako šablonu"
|
||||
label="Uložit i jako trénink"
|
||||
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||
/>
|
||||
<div className="meal-modal-summary">
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Modal, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
||||
import {
|
||||
DayOverview, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, WorkoutTemplateType, applyWorkoutTemplate,
|
||||
getWorkoutTemplates,
|
||||
} from "../../../../types";
|
||||
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||
import { TRENINKY_URL } from "../../routes";
|
||||
@@ -17,10 +20,10 @@ type Props = {
|
||||
onApplied: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Popíše položky šablony jednou řádkou. */
|
||||
const describeItems = (template: WorkoutTemplate): string =>
|
||||
template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? (item.templateRefName ?? 'šablona')
|
||||
/** Popíše položky šablony nebo dne plánu jednou řádkou. */
|
||||
const describeItems = (items: WorkoutTemplateItem[]): string =>
|
||||
items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? (item.templateRefName ?? 'trénink')
|
||||
: `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}`
|
||||
).join(' · ');
|
||||
|
||||
@@ -28,7 +31,8 @@ const describeItems = (template: WorkoutTemplate): string =>
|
||||
* Výběr šablony tréninku k založení do dne.
|
||||
*
|
||||
* Zakládání a úpravy šablon se řeší na vlastní stránce, sem chodí uživatel jen
|
||||
* vybrat hotovou šablonu a rozbalit ji do dne.
|
||||
* vybrat hotovou šablonu a rozbalit ji do dne. Den plánu běžně navrhne přehled
|
||||
* dne sám; tady jde založit i jiný den plánu, než který je na řadě.
|
||||
*/
|
||||
const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
@@ -45,9 +49,9 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const apply = async (id: string) => {
|
||||
const apply = async (id: string, dayId?: string) => {
|
||||
setBusy(true);
|
||||
const response = await applyWorkoutTemplate({ body: { id, date } });
|
||||
const response = await applyWorkoutTemplate({ body: { id, date, ...(dayId ? { dayId } : {}) } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
@@ -57,7 +61,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Použít šablonu tréninku</Modal.Title>
|
||||
<Modal.Title>Přidat trénink</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{loading && (
|
||||
@@ -69,21 +73,21 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
{!loading && !templates.length && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
|
||||
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
|
||||
<p className="empty-description">Vytvořit si ji můžete na stránce Tréninky.</p>
|
||||
<div className="empty-title">Zatím nemáte žádný trénink ani plán</div>
|
||||
<p className="empty-description">Vytvořit si ho můžete na stránce Tréninky.</p>
|
||||
</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-info">
|
||||
<div className="workout-name">
|
||||
{template.name}
|
||||
{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 className="workout-items">{describeItems(template)}</div>
|
||||
<div className="workout-items">{describeItems(template.items)}</div>
|
||||
</div>
|
||||
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
||||
@@ -91,13 +95,31 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
||||
</Button>
|
||||
</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.Footer>
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => { onClose(); navigate(TRENINKY_URL); }}
|
||||
>
|
||||
Spravovat šablony
|
||||
Spravovat tréninky
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
|
||||
@@ -58,20 +58,20 @@ const SaveWorkoutTemplateModal = ({
|
||||
if (response.data) {
|
||||
onSaved();
|
||||
} else {
|
||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit');
|
||||
setError((response.error as { error?: string })?.error ?? 'Trénink se nepodařilo vytvořit');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered>
|
||||
<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.Body>
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název šablony</Form.Label>
|
||||
<Form.Label>Název tréninku</Form.Label>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
value={name}
|
||||
@@ -99,7 +99,7 @@ const SaveWorkoutTemplateModal = ({
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button>
|
||||
<Button onClick={() => save()} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Vytvořit šablonu'}
|
||||
{saving ? 'Ukládám…' : 'Vytvořit trénink'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor,
|
||||
WorkoutTemplateType,
|
||||
} from "../../types";
|
||||
|
||||
/** Lidsky čitelné názvy typů jídel. */
|
||||
@@ -80,6 +81,30 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
|
||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
||||
];
|
||||
|
||||
/** Názvy typů šablon tréninků. */
|
||||
export const WORKOUT_TEMPLATE_TYPE_NAMES: Record<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. */
|
||||
export enum DayTab {
|
||||
PRIJEM = 'PRIJEM',
|
||||
|
||||
@@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
|
||||
faTriangleExclamation,
|
||||
faArrowRight, faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
|
||||
faRotateLeft, faTriangleExclamation,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis,
|
||||
} from "recharts";
|
||||
import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types";
|
||||
import { StatsRange } from "../enums";
|
||||
import { formatBalance, formatCalories } from "../Utils";
|
||||
import { formatBalance, formatCalories, formatShortDate } from "../Utils";
|
||||
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
|
||||
import {
|
||||
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries,
|
||||
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, getSeriesId, getUsageName,
|
||||
hasUnresolvedNote, listSeriesIds,
|
||||
} from "./activityChartSeries";
|
||||
|
||||
type Props = {
|
||||
@@ -28,16 +29,28 @@ function deviationClass(deviation: number): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. */
|
||||
/**
|
||||
* Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo.
|
||||
* Nahrazený cvik nese šipku na den, kdy se dodělal.
|
||||
*/
|
||||
function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) {
|
||||
const unresolved = note.missing.length > 0 || note.changed.length > 0;
|
||||
return (
|
||||
<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>
|
||||
{note.date && <span className="plan-note-date">{note.date} </span>}
|
||||
<strong>{note.templateName}</strong>
|
||||
{!!note.missing.length && <div>nesplněno: {note.missing.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>
|
||||
);
|
||||
@@ -62,11 +75,20 @@ function ActivityTooltip({ active, payload, label }: Readonly<Partial<TooltipCon
|
||||
{row.planned > 0 && (
|
||||
<div className="activity-tooltip-row activity-tooltip-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>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +98,7 @@ type DotProps = { cx?: number, cy?: number, payload?: ActivitySeriesRow, index?:
|
||||
/**
|
||||
* Bod plánu. Kde se ze šablony něco nesplnilo, je výraznější a nese "!" —
|
||||
* sedí na úrovni plánu, tedy přesně nad mezerou, kterou nesplněné cviky nechaly.
|
||||
* Když se všechno vynechané později nahradilo, nese místo "!" šipku k náhradě.
|
||||
*/
|
||||
function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
|
||||
// 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) {
|
||||
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 (
|
||||
<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} />
|
||||
@@ -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í
|
||||
useEffect(() => {
|
||||
getWorkoutTemplates().then(response => setTemplateIds((response.data ?? []).map(template => template.id)));
|
||||
getWorkoutTemplates().then(response => setTemplateIds(listSeriesIds(response.data ?? [])));
|
||||
}, []);
|
||||
|
||||
const { series, templateSeries } = useMemo(() => {
|
||||
if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] };
|
||||
const used = new Map(stats.byTemplate.map(usage => [usage.templateId, usage.name]));
|
||||
const used = new Map(stats.byTemplate.map(usage => [getSeriesId(usage), getUsageName(usage)]));
|
||||
const built = buildTemplateSeries(templateIds, used);
|
||||
return {
|
||||
series: buildActivitySeries(stats.days, range, mode, built.keyOf),
|
||||
@@ -141,7 +172,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
|
||||
<div className="empty-title">Za zvolené období nemáte žádný pohyb</div>
|
||||
<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>
|
||||
</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>
|
||||
<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-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 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)}`}>
|
||||
{deviation ? formatBalance(deviation) : '—'}
|
||||
</div>
|
||||
@@ -178,7 +209,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
<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-hint">z {extraCount} aktivit</div>
|
||||
</div>
|
||||
@@ -207,7 +238,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
))}
|
||||
<Bar
|
||||
dataKey="extra"
|
||||
name="Mimo šablonu"
|
||||
name="Ostatní pohyb"
|
||||
stackId="vydej"
|
||||
fill="var(--ft-series-other)"
|
||||
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 */}
|
||||
<Line
|
||||
dataKey="planned"
|
||||
name="Plán šablon"
|
||||
name="Plán"
|
||||
stroke="var(--ft-text-secondary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
@@ -229,16 +260,18 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
</ResponsiveContainer>
|
||||
<p className="usage-note">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Šablona</th>
|
||||
<th>Trénink</th>
|
||||
<th className="numeric">Použito</th>
|
||||
<th className="numeric">Cviky</th>
|
||||
<th className="numeric">Plán</th>
|
||||
@@ -250,8 +283,8 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
{stats.byTemplate.map(usage => {
|
||||
const usageDeviation = usage.actualCalories - usage.plannedCalories;
|
||||
return (
|
||||
<tr key={usage.templateId}>
|
||||
<td>{usage.name}</td>
|
||||
<tr key={`${usage.templateId}:${usage.planDayId ?? ''}`}>
|
||||
<td>{getUsageName(usage)}</td>
|
||||
<td className="numeric">{usage.dayCount}×</td>
|
||||
<td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</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)}>
|
||||
<td>
|
||||
<FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth />
|
||||
{' '}Mimo šablonu
|
||||
{' '}Ostatní pohyb
|
||||
</td>
|
||||
<td className="numeric">{extraCount}×</td>
|
||||
<td className="numeric" />
|
||||
@@ -288,12 +321,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
|
||||
</tbody>
|
||||
</table>
|
||||
{!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">
|
||||
Plán je to, co šablona slibovala v den použití. Smazaný nebo zkrácený cvik se
|
||||
projeví jako odchylka; když smažete všechny cviky šablony, bere se to jako
|
||||
vrácené použití.
|
||||
Plán je to, co trénink sliboval v den použití. Smazaný nebo zkrácený cvik se
|
||||
projeví jako odchylka, nahrazený jiný den se počítá jako splněný; když smažete
|
||||
všechny cviky tréninku, bere se to jako vrácené použití.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -262,3 +262,121 @@
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Návrh tréninku z plánu a cviky k náhradě nad seznamem aktivit
|
||||
.planned-workout-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 18px;
|
||||
border-left: 4px solid var(--ft-primary);
|
||||
|
||||
.planned-workout-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.planned-workout-icon {
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
|
||||
.planned-workout-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.planned-workout-plan {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.planned-workout-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planned-workout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.planned-workout-hint {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.planned-workout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--ft-radius);
|
||||
background: var(--ft-bg-card);
|
||||
font-size: 0.88rem;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
.planned-workout-done svg {
|
||||
color: var(--ft-success);
|
||||
}
|
||||
|
||||
.planned-workout-rest svg {
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.pending-replacements {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 18px;
|
||||
|
||||
.pending-replacements-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
.pending-replacement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-replacement-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pending-replacement-name {
|
||||
font-weight: 600;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.pending-replacement-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.template-pick-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
faWeightScale,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityEntry, DayOverview, MealEntry,
|
||||
ActivityEntry, DayOverview, MealEntry, PlannedWorkoutStatus,
|
||||
deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier,
|
||||
} from "../../../types";
|
||||
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||
@@ -27,6 +27,8 @@ import ApplyMealModal from "../components/modals/ApplyMealModal";
|
||||
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
|
||||
import MealRow from "../components/MealRow";
|
||||
import ActivityRow from "../components/ActivityRow";
|
||||
import PlannedWorkoutCard from "../components/PlannedWorkoutCard";
|
||||
import PendingReplacementsPanel from "../components/PendingReplacementsPanel";
|
||||
import { DAY_DATE_PARAM } from "../routes";
|
||||
import "./DayPage.scss";
|
||||
|
||||
@@ -37,6 +39,8 @@ function emptyOverview(date: string): DayOverview {
|
||||
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
|
||||
activities: { date, entries: [], totalCalories: 0 },
|
||||
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false },
|
||||
plannedWorkouts: [],
|
||||
pendingReplacements: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +144,9 @@ export default function DayPage() {
|
||||
setSaveWorkoutOpen(true);
|
||||
};
|
||||
|
||||
/** Plán, který na dnešek ještě čeká na potvrzení — pak se nehlásí prázdný den. */
|
||||
const hasProposal = day.plannedWorkouts.some(planned => planned.status === PlannedWorkoutStatus.NAVRH);
|
||||
|
||||
const removeActivity = async (entry: ActivityEntry) => {
|
||||
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
|
||||
if (response.data) setDay(response.data);
|
||||
@@ -162,11 +169,11 @@ export default function DayPage() {
|
||||
<div className="header-actions">
|
||||
{!!day.activities.entries.length && (
|
||||
<Button variant="outline-secondary" onClick={() => openSaveWorkout()}>
|
||||
<FontAwesomeIcon icon={faBookmark} /> Uložit jako šablonu
|
||||
<FontAwesomeIcon icon={faBookmark} /> Uložit jako trénink
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faDumbbell} /> Ze šablony
|
||||
<FontAwesomeIcon icon={faDumbbell} /> Z tréninků
|
||||
</Button>
|
||||
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
|
||||
@@ -358,12 +365,26 @@ export default function DayPage() {
|
||||
|
||||
{!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="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
|
||||
<div className="empty-title">Za tento den nemáte žádný pohyb</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -436,7 +457,7 @@ export default function DayPage() {
|
||||
onClose={() => setSaveWorkoutOpen(false)}
|
||||
onSaved={() => {
|
||||
setSaveWorkoutOpen(false);
|
||||
showNotice('Šablona tréninku vytvořena.');
|
||||
showNotice('Trénink vytvořen.');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -307,6 +307,15 @@
|
||||
.plan-note-icon {
|
||||
color: var(--ft-warning);
|
||||
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 {
|
||||
|
||||
@@ -21,7 +21,7 @@ const TAB_ICONS: Record<StatsTab, typeof faCoins> = {
|
||||
|
||||
const TAB_SUBTITLES: Record<StatsTab, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
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 { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
|
||||
import {
|
||||
deriveActivityCalories, formatCalories, formatExtraWeight, formatKgInput, formatQuantityWithSets,
|
||||
parseIntInput, parseWeightKgInput,
|
||||
} from "../Utils";
|
||||
import { faDumbbell } from "@fortawesome/free-solid-svg-icons";
|
||||
import { WorkoutTemplate, WorkoutTemplateType, deleteWorkoutTemplate, getWorkoutTemplates } from "../../../types";
|
||||
import WorkoutTemplateCard from "../components/WorkoutTemplateCard";
|
||||
import WorkoutTemplateForm from "../components/WorkoutTemplateForm";
|
||||
|
||||
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
|
||||
type DraftItem = {
|
||||
kind: WorkoutItemKind,
|
||||
name: string,
|
||||
unit: ActivityUnit,
|
||||
quantity: string,
|
||||
sets: string,
|
||||
caloriesPer100Units: string,
|
||||
/** 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,
|
||||
});
|
||||
/** Plány se řadí před volné šablony — jsou to ty, podle kterých se cvičí každý den. */
|
||||
const sortTemplates = (templates: WorkoutTemplate[]): WorkoutTemplate[] => [
|
||||
...templates.filter(template => template.type !== WorkoutTemplateType.VOLNY),
|
||||
...templates.filter(template => template.type === WorkoutTemplateType.VOLNY),
|
||||
];
|
||||
|
||||
/**
|
||||
* Panel se správou šablon tréninků.
|
||||
*
|
||||
* Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu,
|
||||
* jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu
|
||||
* úroveň, takže šablona, která už něco skládá, se sama vnořit nedá.
|
||||
* Volná šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou
|
||||
* volnou šablonu, jejíž cviky se při použití rozbalí — zanoření je ale povolené
|
||||
* 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 [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editedId, setEditedId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [items, setItems] = useState<DraftItem[]>([emptyExercise()]);
|
||||
const [edited, setEdited] = useState<WorkoutTemplate | undefined>();
|
||||
/** Mění se po uložení, aby se formulář nové šablony vyprázdnil. */
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -67,72 +38,9 @@ const WorkoutTemplatesPanel = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditedId(undefined);
|
||||
setName('');
|
||||
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 closeForm = () => {
|
||||
setEdited(undefined);
|
||||
setFormKey(key => key + 1);
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
@@ -142,50 +50,30 @@ const WorkoutTemplatesPanel = () => {
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (editedId === id) resetForm();
|
||||
if (edited?.id === id) closeForm();
|
||||
} 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) => {
|
||||
setEditedId(template.id);
|
||||
setName(template.name);
|
||||
setError(undefined);
|
||||
setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||
? emptyReference(item.templateRefId ?? '')
|
||||
: {
|
||||
kind: WorkoutItemKind.CVIK,
|
||||
name: item.name ?? '',
|
||||
unit: item.unit ?? ActivityUnit.OPAKOVANI,
|
||||
quantity: item.quantity != null ? String(item.quantity) : '',
|
||||
sets: item.sets != null ? String(item.sets) : '',
|
||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||
extraWeight: formatKgInput(item.extraWeightGrams),
|
||||
templateRefId: '',
|
||||
}));
|
||||
setEdited(template);
|
||||
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 (
|
||||
<>
|
||||
<p className="text-body-secondary small">
|
||||
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím.
|
||||
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku.
|
||||
Zanoření je kvůli jednoduchosti jen na jednu úroveň. U cviku jde zapsat
|
||||
i dodatečnou zátěž (činky, vesta) — je to jen informace, kalorie nemění.
|
||||
<strong>Plán</strong> popisuje, jak běžně cvičíte — fixní podle dnů v týdnu, plovoucí jako
|
||||
sekvenci dnů s volnem na konci. Přehled dne z něj sám navrhne trénink a vy ho jen potvrdíte;
|
||||
vynechané cviky jde ve zvolené lhůtě nahradit jindy. <strong>Volný trénink</strong> je seznam
|
||||
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>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
@@ -195,202 +83,31 @@ const WorkoutTemplatesPanel = () => {
|
||||
{!loading && !templates.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.map(template => (
|
||||
<div className="card-panel template-card" key={template.id}>
|
||||
<div className="template-head">
|
||||
<div className="template-title">
|
||||
{template.name}
|
||||
{template.hasNested && (
|
||||
<span className="tag tag-import">
|
||||
<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>
|
||||
{!loading && sortTemplates(templates).map(template => (
|
||||
<WorkoutTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
busy={busy}
|
||||
onEdit={() => edit(template)}
|
||||
onDelete={() => remove(template.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="card-panel template-form">
|
||||
<h2 className="chart-title">{editedId ? 'Úprava šablony' : 'Nová šablona'}</h2>
|
||||
|
||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={name}
|
||||
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>
|
||||
<WorkoutTemplateForm
|
||||
key={edited?.id ?? `nova-${formKey}`}
|
||||
templates={templates}
|
||||
edited={edited}
|
||||
onSaved={saved => {
|
||||
setTemplates(saved);
|
||||
closeForm();
|
||||
}}
|
||||
onCancel={() => closeForm()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
|
||||
import { ActivityDayStats, PlanDeviationKind, WorkoutTemplate } from "../../../types";
|
||||
import { StatsRange } from "../enums";
|
||||
import { formatDateString } from "../Utils";
|
||||
import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared";
|
||||
@@ -9,6 +9,9 @@ export const SERIES_COLOR_COUNT = 8;
|
||||
/** Klíč řady pro šablony, které se do palety nevešly. */
|
||||
export const OTHER_TEMPLATES_KEY = 'ostatni';
|
||||
|
||||
/** Vynechaný cvik, který se později nahradil, a kdy. */
|
||||
export type ReplacedNote = { name: string, replacementDate: string };
|
||||
|
||||
/** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */
|
||||
export type PlanNote = {
|
||||
/** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */
|
||||
@@ -16,6 +19,14 @@ export type PlanNote = {
|
||||
templateName: string;
|
||||
missing: string[];
|
||||
changed: string[];
|
||||
replaced: ReplacedNote[];
|
||||
};
|
||||
|
||||
/** Náhrada cviku vynechaného dřív, odcvičená v období sloupce. */
|
||||
export type ReplacementNote = {
|
||||
name: string;
|
||||
templateName: string;
|
||||
sourceDate: string;
|
||||
};
|
||||
|
||||
/** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
|
||||
@@ -28,12 +39,34 @@ export type ActivitySeriesRow = {
|
||||
*/
|
||||
planned: number;
|
||||
notes: PlanNote[];
|
||||
[templateKey: string]: number | string | null | PlanNote[];
|
||||
replacements: ReplacementNote[];
|
||||
[templateKey: string]: number | string | null | PlanNote[] | ReplacementNote[];
|
||||
};
|
||||
|
||||
/** Šablona jako řada grafu. */
|
||||
export type TemplateSeries = { key: string, name: string, color: string };
|
||||
|
||||
/** Název použití šablony — u plánu s názvem dne. */
|
||||
export const getUsageName = (usage: { name: string, dayName?: string }): string =>
|
||||
usage.dayName ? `${usage.name} · ${usage.dayName}` : usage.name;
|
||||
|
||||
/** Vrátí true, pokud se v období sloupce ze šablony něco nesplnilo a nenahradilo. */
|
||||
export const hasUnresolvedNote = (row: ActivitySeriesRow): boolean =>
|
||||
row.notes.some(note => note.missing.length > 0 || note.changed.length > 0);
|
||||
|
||||
/**
|
||||
* Identifikátor řady grafu. Plán má řadu za každý svůj den — celý plán jednou
|
||||
* barvou by neukázal, který den se plní a který ne.
|
||||
*/
|
||||
export const getSeriesId = (usage: { templateId: string, planDayId?: string }): string =>
|
||||
usage.planDayId ? `${usage.templateId}:${usage.planDayId}` : usage.templateId;
|
||||
|
||||
/** Identifikátory všech řad v pořadí, které určuje barvy — volné tréninky a dny plánů. */
|
||||
export const listSeriesIds = (templates: WorkoutTemplate[]): string[] => templates.flatMap(template =>
|
||||
template.days?.length
|
||||
? template.days.map(day => getSeriesId({ templateId: template.id, planDayId: day.id }))
|
||||
: [template.id]);
|
||||
|
||||
/** Klíč řady šablony v řádku grafu. */
|
||||
export const templateKey = (templateId: string): string => `t:${templateId}`;
|
||||
|
||||
@@ -44,8 +77,8 @@ export const templateKey = (templateId: string): string => `t:${templateId}`;
|
||||
* co se v zobrazeném období zrovna použilo — jinak by šablona měnila barvu podle
|
||||
* zvoleného měsíce. Smazané šablony a ty nad počet barev jdou do "Ostatní".
|
||||
*
|
||||
* @param allTemplateIds identifikátory všech šablon uživatele seřazené dle názvu
|
||||
* @param used použité šablony v období (id → název)
|
||||
* @param allTemplateIds identifikátory všech řad (viz {@link listSeriesIds})
|
||||
* @param used použité řady v období (id → název)
|
||||
*/
|
||||
export function buildTemplateSeries(allTemplateIds: string[], used: Map<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)!)
|
||||
.map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` }));
|
||||
if ([...used.keys()].some(id => keyOf(id) === OTHER_TEMPLATES_KEY)) {
|
||||
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní šablony', color: 'var(--ft-series-other-template)' });
|
||||
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní tréninky', color: 'var(--ft-series-other-template)' });
|
||||
}
|
||||
return { series, keyOf };
|
||||
}
|
||||
@@ -72,13 +105,13 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
|
||||
* @param days dny s pohybem ze serveru
|
||||
* @param range zobrazené období
|
||||
* @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(
|
||||
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
|
||||
): ActivitySeriesRow[] {
|
||||
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) {
|
||||
@@ -86,20 +119,32 @@ export function buildActivitySeries(
|
||||
if (!row) continue;
|
||||
row.extra += day.extraCalories;
|
||||
for (const usage of day.templates) {
|
||||
const key = keyOf(usage.templateId);
|
||||
const key = keyOf(getSeriesId(usage));
|
||||
row[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories;
|
||||
row.planned += usage.plannedCalories;
|
||||
const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO);
|
||||
const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO);
|
||||
if (missing.length || changed.length) {
|
||||
const replaced = usage.deviations.filter(item => item.kind === PlanDeviationKind.NAHRAZENO);
|
||||
if (missing.length || changed.length || replaced.length) {
|
||||
row.notes.push({
|
||||
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
|
||||
templateName: usage.name,
|
||||
templateName: getUsageName(usage),
|
||||
missing: missing.map(item => item.planned.name),
|
||||
changed: changed.map(item => item.planned.name),
|
||||
replaced: replaced.map(item => ({ name: item.planned.name, replacementDate: item.replacementDate! })),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Náhrada se kreslí do dílu plánu, ze kterého cvik pochází, plán dne nezvyšuje
|
||||
for (const replacement of day.replacements) {
|
||||
const key = keyOf(getSeriesId(replacement));
|
||||
row[key] = ((row[key] as number | undefined) ?? 0) + replacement.item.calories;
|
||||
row.replacements.push({
|
||||
name: replacement.item.name,
|
||||
templateName: getUsageName(replacement),
|
||||
sourceDate: replacement.sourceDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...rows.values()];
|
||||
}
|
||||
@@ -309,3 +309,145 @@
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PLÁNY TRÉNINKŮ
|
||||
// ============================================
|
||||
|
||||
.tag-plan {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
|
||||
.template-card {
|
||||
.template-plan-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.template-plan-day {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
// Hlavička dne je podbarvený pruh, cviky pod ní odsazené s linkou — na první
|
||||
// pohled je vidět, kde den začíná a co k němu patří
|
||||
.template-plan-day-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--ft-primary);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
background: var(--ft-primary-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.template-plan-day-label {
|
||||
color: var(--ft-primary);
|
||||
font-weight: 700;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.template-plan-day-name {
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.template-plan-day .template-items {
|
||||
margin: 2px 0 0 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--ft-border);
|
||||
|
||||
li:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Den volna není záznam, jen připomínka konce cyklu
|
||||
.template-plan-rest {
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--ft-border);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ft-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
.template-form-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
> * {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.template-form-name {
|
||||
flex: 3 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workout-items-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-day-editor {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius);
|
||||
|
||||
.plan-day-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plan-day-weekday {
|
||||
width: 140px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plan-day-order {
|
||||
font-weight: 700;
|
||||
color: var(--ft-text-secondary);
|
||||
min-width: 22px;
|
||||
}
|
||||
|
||||
.plan-day-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-rest-day {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--ft-border);
|
||||
border-radius: var(--ft-radius);
|
||||
font-size: 0.87rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
import { dropOrphanedPlans } from "./workoutPlans";
|
||||
import { dropOrphanedPlans, updateReplacementLink } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -230,6 +230,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
);
|
||||
if (originalDate !== normalized.date) {
|
||||
await dropOrphanedPlans(login, originalDate, remaining);
|
||||
if (original.replacesDate) {
|
||||
await updateReplacementLink(login, original.replacesDate, id, normalized.date);
|
||||
}
|
||||
}
|
||||
return normalized.date;
|
||||
}
|
||||
@@ -248,15 +251,19 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
let found = false;
|
||||
let deleted: ActivityEntry | undefined;
|
||||
const remaining = await storage.updateData<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);
|
||||
});
|
||||
if (!found) {
|
||||
if (!deleted) {
|
||||
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||
}
|
||||
await dropOrphanedPlans(login, date, remaining);
|
||||
// Smazaná náhrada vrací cvik v původním dni mezi vynechané
|
||||
if (deleted.replacesDate) {
|
||||
await updateReplacementLink(login, deleted.replacesDate, id);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSleep } from "./sleep";
|
||||
import { findWeightOn, getWeights } from "./weights";
|
||||
import { computeBasalCalories } from "./profile";
|
||||
import { getProteinTarget, getTargetDailyBalance } from "./bodyMetrics";
|
||||
import { getPendingReplacements, getPlannedWorkouts } from "./workoutSchedule";
|
||||
|
||||
/**
|
||||
* Spočítá energetickou bilanci dne.
|
||||
@@ -56,7 +57,8 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da
|
||||
settings.basalCalories ?? computeBasalCalories(settings.profile, getWeightOn(settings, weightList, date));
|
||||
|
||||
/**
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení a energetickou bilanci.
|
||||
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek, vážení, energetickou bilanci
|
||||
* a co na den navrhují plány tréninků včetně cviků, které jde ještě nahradit.
|
||||
*
|
||||
* Spočítaný klidový výdej i cíl bílkovin vycházejí z hmotnosti platné k tomuto
|
||||
* dni (poslední vážení v ten den nebo dřív), takže starší dny se se zhubnutím
|
||||
@@ -66,12 +68,14 @@ export const getBasalOn = (settings: UserSettings, weightList: WeightEntry[], da
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||
const [meals, activities, settings, sleepMinutes, weightList] = await Promise.all([
|
||||
const [meals, activities, settings, sleepMinutes, weightList, plannedWorkouts, pendingReplacements] = await Promise.all([
|
||||
getDay(login, date),
|
||||
getActivityDay(login, date),
|
||||
getSettings(login),
|
||||
getSleep(login, date),
|
||||
getWeights(login),
|
||||
getPlannedWorkouts(login, date),
|
||||
getPendingReplacements(login, date),
|
||||
]);
|
||||
const weightGrams = getWeightOn(settings, weightList, date);
|
||||
const basal = getBasalOn(settings, weightList, date);
|
||||
@@ -83,6 +87,8 @@ export const getDayOverview = async (login: string, date: string): Promise<DayOv
|
||||
activities,
|
||||
energy: buildEnergyBalance(
|
||||
meals.totalCalories, activities.totalCalories, basal, getTargetDailyBalance(settings.goals)),
|
||||
plannedWorkouts,
|
||||
pendingReplacements,
|
||||
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||
...(dayWeight != null ? { weightGrams: dayWeight } : {}),
|
||||
...(proteinTargetGrams != null ? { proteinTargetGrams } : {}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate,
|
||||
getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../workouts";
|
||||
import { replaceSkippedExercise } from "../workoutSchedule";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
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) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, name, items } = req.body ?? {};
|
||||
res.status(200).json(await saveWorkoutTemplate(login, id, name, items));
|
||||
const { id, name, items, ...plan } = req.body ?? {};
|
||||
res.status(200).json(await saveWorkoutTemplate(login, id, name, items, plan));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -34,12 +35,22 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Založí do zvoleného dne všechny položky šablony. */
|
||||
/** Založí do zvoleného dne cviky šablony, u plánu jednoho jeho dne (i jako potvrzení návrhu). */
|
||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date } = req.body ?? {};
|
||||
const day = await applyWorkoutTemplate(login, id, date);
|
||||
const { id, date, dayId, doneIndexes } = req.body ?? {};
|
||||
const day = await applyWorkoutTemplate(login, id, date, dayId, doneIndexes);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Nahradí cvik vynechaný v předchozím dni plánu. */
|
||||
router.post("/replace", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { date, sourceDate, planId, itemIndex } = req.body ?? {};
|
||||
const day = await replaceSkippedExercise(login, date, sourceDate, planId, itemIndex);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
+100
-27
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem,
|
||||
ActivityUnit, FoodStats, GroupTotal, IntakeGroupTotal, IntakePeriodTotal, IntakeStats, MealEntry, Nutrients,
|
||||
PeriodTotal, PlanDeviationKind, TemplateDayUsage, TemplateUsageTotal,
|
||||
PeriodTotal, PlanDeviationKind, ReplacementUsage, TemplateDayUsage, TemplateUsageTotal,
|
||||
} from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { getActivityDay } from "./activities";
|
||||
@@ -267,41 +267,75 @@ const differsFromPlan = (entry: ActivityEntry, item: WorkoutPlanItem, calories:
|
||||
/** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */
|
||||
type UsageCounts = { planned: number, removed: number };
|
||||
|
||||
/** Současný název šablony a jejích dnů — pro aktivity, ke kterým se plán neuložil. */
|
||||
type TemplateInfo = { name: string, dayNames: Map<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
|
||||
* a aktivity mimo šablony vrátí zvlášť.
|
||||
* Rozebere jeden den: ke každé použité šabloně (u plánu ke každému jeho dni)
|
||||
* dohledá odchylky od plánu, náhrady cviků vynechaných dřív a aktivity mimo
|
||||
* šablony vrátí zvlášť.
|
||||
*
|
||||
* Vynechaný cvik, který se později nahradil, není odchylka typu ODEBRANO, ale
|
||||
* NAHRAZENO s datem náhrady — počítá se jako provedený. Náhrada sama se
|
||||
* počítá do dne, kdy se odcvičila, bez plánu (plán nese původní den).
|
||||
*
|
||||
* Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se
|
||||
* berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde.
|
||||
*/
|
||||
function analyzeDay(
|
||||
entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map<string, string>, date: string,
|
||||
entries: ActivityEntry[], plans: WorkoutPlan[], templateInfo: Map<string, TemplateInfo>, date: string,
|
||||
): { day: ActivityDayStats, counts: Map<string, UsageCounts> } {
|
||||
const byId = new Map(entries.map(entry => [entry.id, entry]));
|
||||
const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId)));
|
||||
const usages = new Map<string, TemplateDayUsage>();
|
||||
const counts = new Map<string, UsageCounts>();
|
||||
|
||||
const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => {
|
||||
const usage = usages.get(templateId) ?? {
|
||||
templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [],
|
||||
const usageFor = (
|
||||
templateId: string, planDayId: string | undefined, name: string, dayName: string | undefined, hasPlan: boolean,
|
||||
): { usage: TemplateDayUsage, count: UsageCounts } => {
|
||||
const key = getUsageKey(templateId, planDayId);
|
||||
const usage = usages.get(key) ?? {
|
||||
templateId,
|
||||
name,
|
||||
...(planDayId ? { planDayId } : {}),
|
||||
...(dayName ? { dayName } : {}),
|
||||
plannedCalories: 0,
|
||||
actualCalories: 0,
|
||||
hasPlan,
|
||||
deviations: [],
|
||||
};
|
||||
usages.set(templateId, usage);
|
||||
if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 });
|
||||
return usage;
|
||||
usages.set(key, usage);
|
||||
const count = counts.get(key) ?? { planned: 0, removed: 0 };
|
||||
counts.set(key, count);
|
||||
return { usage, count };
|
||||
};
|
||||
|
||||
/** Současný název dne plánu, pokud den v šabloně pořád je. */
|
||||
const currentDayName = (templateId: string, planDayId?: string): string | undefined =>
|
||||
planDayId ? templateInfo.get(templateId)?.dayNames.get(planDayId) : undefined;
|
||||
|
||||
for (const plan of plans) {
|
||||
// Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se
|
||||
const usage = usageFor(plan.templateId, plan.templateName, true);
|
||||
const count = counts.get(plan.templateId)!;
|
||||
const { usage, count } = usageFor(plan.templateId, plan.planDayId, plan.templateName, plan.planDayName, true);
|
||||
for (const item of plan.items) {
|
||||
usage.plannedCalories += item.calories;
|
||||
count.planned++;
|
||||
const entry = item.entryId ? byId.get(item.entryId) : undefined;
|
||||
if (!entry) {
|
||||
usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
|
||||
count.removed++;
|
||||
if (item.replacement) {
|
||||
usage.deviations.push({
|
||||
kind: PlanDeviationKind.NAHRAZENO,
|
||||
planned: summarize(item, item.calories),
|
||||
replacementDate: item.replacement.date,
|
||||
});
|
||||
} else {
|
||||
usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) });
|
||||
count.removed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
@@ -317,6 +351,7 @@ function analyzeDay(
|
||||
}
|
||||
|
||||
const extras: ActivitySummaryItem[] = [];
|
||||
const replacements: ReplacementUsage[] = [];
|
||||
for (const entry of entries) {
|
||||
if (planned.has(entry.id)) continue;
|
||||
const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
@@ -324,19 +359,35 @@ function analyzeDay(
|
||||
extras.push(summarize(entry, calories));
|
||||
continue;
|
||||
}
|
||||
const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false);
|
||||
const name = templateInfo.get(entry.templateId)?.name ?? DELETED_TEMPLATE;
|
||||
const dayName = currentDayName(entry.templateId, entry.planDayId);
|
||||
if (entry.replacesDate) {
|
||||
replacements.push({
|
||||
templateId: entry.templateId,
|
||||
name,
|
||||
...(entry.planDayId ? { planDayId: entry.planDayId } : {}),
|
||||
...(dayName ? { dayName } : {}),
|
||||
sourceDate: entry.replacesDate,
|
||||
item: summarize(entry, calories),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const { usage, count } = usageFor(entry.templateId, entry.planDayId, name, dayName, false);
|
||||
usage.plannedCalories += calories;
|
||||
usage.actualCalories += calories;
|
||||
counts.get(entry.templateId)!.planned++;
|
||||
count.planned++;
|
||||
}
|
||||
|
||||
const templates = Array.from(usages.values());
|
||||
const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0);
|
||||
const replacementCalories = replacements.reduce((sum, item) => sum + item.item.calories, 0);
|
||||
return {
|
||||
day: {
|
||||
date,
|
||||
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories,
|
||||
totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0)
|
||||
+ replacementCalories + extraCalories,
|
||||
templates,
|
||||
replacements,
|
||||
extraCalories,
|
||||
extras,
|
||||
},
|
||||
@@ -349,7 +400,9 @@ function analyzeDay(
|
||||
*
|
||||
* Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při
|
||||
* použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla
|
||||
* mezitím změnit.
|
||||
* mezitím změnit. Plány (fixní a plovoucí šablony) se dělí po dnech. Dny,
|
||||
* kdy podle plánu bylo volno nebo se necvičilo, se nijak nezapisují — jsou
|
||||
* to mezery, ne záznamy.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param from první den období (včetně), YYYY-MM-DD
|
||||
@@ -365,7 +418,10 @@ export async function getActivityStats(login: string, from: string, to: string):
|
||||
Promise.all(dates.map(date => getWorkoutPlans(login, date))),
|
||||
getWorkoutTemplates(login),
|
||||
]);
|
||||
const templateNames = new Map(templates.map(template => [template.id, template.name]));
|
||||
const templateInfo = new Map(templates.map(template => [template.id, {
|
||||
name: template.name,
|
||||
dayNames: new Map((template.days ?? []).map(day => [day.id, day.name])),
|
||||
}]));
|
||||
|
||||
const byDay = new Map<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) => {
|
||||
if (!activityDay.entries.length) return;
|
||||
entryCount += activityDay.entries.length;
|
||||
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date);
|
||||
const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateInfo, activityDay.date);
|
||||
days.push(day);
|
||||
|
||||
/** Součet použití šablony (u plánu jejího dne) za období. */
|
||||
const totalFor = (usage: { templateId: string, name: string, planDayId?: string }): TemplateUsageTotal => {
|
||||
const key = getUsageKey(usage.templateId, usage.planDayId);
|
||||
const total = byTemplate.get(key) ?? {
|
||||
templateId: usage.templateId, name: usage.name,
|
||||
...(usage.planDayId ? { planDayId: usage.planDayId } : {}),
|
||||
dayCount: 0, plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
|
||||
};
|
||||
byTemplate.set(key, total);
|
||||
return total;
|
||||
};
|
||||
|
||||
let dayTemplate = 0;
|
||||
let dayPlanned = 0;
|
||||
for (const usage of day.templates) {
|
||||
const count = counts.get(usage.templateId)!;
|
||||
const total = byTemplate.get(usage.templateId) ?? {
|
||||
templateId: usage.templateId, name: usage.name, dayCount: 0,
|
||||
plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0,
|
||||
};
|
||||
const count = counts.get(getUsageKey(usage.templateId, usage.planDayId))!;
|
||||
const total = totalFor(usage);
|
||||
// Dny jdou vzestupně, takže zůstane název z posledního použití
|
||||
total.name = usage.name;
|
||||
if (usage.dayName) total.dayName = usage.dayName;
|
||||
total.dayCount++;
|
||||
total.plannedCalories += usage.plannedCalories;
|
||||
total.actualCalories += usage.actualCalories;
|
||||
total.plannedItemCount += count.planned;
|
||||
total.doneItemCount += count.planned - count.removed;
|
||||
byTemplate.set(usage.templateId, total);
|
||||
dayTemplate += usage.actualCalories;
|
||||
dayPlanned += usage.plannedCalories;
|
||||
}
|
||||
// Náhrada patří ke dni plánu, ve kterém se cvik vynechal — jeho plán už je započtený tam
|
||||
for (const replacement of day.replacements) {
|
||||
const total = totalFor(replacement);
|
||||
if (!total.dayName && replacement.dayName) total.dayName = replacement.dayName;
|
||||
total.actualCalories += replacement.item.calories;
|
||||
dayTemplate += replacement.item.calories;
|
||||
}
|
||||
for (const item of day.extras) {
|
||||
const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 };
|
||||
group.calories += item.calories;
|
||||
@@ -432,7 +504,8 @@ export async function getActivityStats(login: string, from: string, to: string):
|
||||
byMonth: sortPeriods(byMonth),
|
||||
byYear: sortPeriods(byYear),
|
||||
byTemplate: Array.from(byTemplate.values())
|
||||
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')),
|
||||
.sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')
|
||||
|| (a.dayName ?? '').localeCompare(b.dayName ?? '', 'cs')),
|
||||
extras: Array.from(extras.values())
|
||||
.sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')),
|
||||
days,
|
||||
|
||||
@@ -157,13 +157,13 @@ describe('šablony tréninků', () => {
|
||||
});
|
||||
|
||||
it('odmítne šablonu bez názvu nebo bez položek', async () => {
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název šablony/);
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, '', items)).rejects.toThrow(/název tréninku/);
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Prázdná', [])).rejects.toThrow(/alespoň jednu položku/);
|
||||
});
|
||||
|
||||
it('hlásí chybu u neexistující šablony', async () => {
|
||||
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
|
||||
await expect(applyWorkoutTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyl nalezen/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyl nalezen/);
|
||||
});
|
||||
|
||||
it('šablony jsou oddělené mezi uživateli', async () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('překlopení aktivit do šablony tréninku', () => {
|
||||
|
||||
await addActivity(LOGIN, kliky);
|
||||
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' '))
|
||||
.rejects.toThrow(/název šablony/);
|
||||
.rejects.toThrow(/název tréninku/);
|
||||
});
|
||||
|
||||
it('vytvořená šablona jde rovnou použít', async () => {
|
||||
|
||||
@@ -102,19 +102,19 @@ describe('skládání šablon z jiných šablon', () => {
|
||||
const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
|
||||
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)]))
|
||||
.rejects.toThrow(/sama na sebe/);
|
||||
.rejects.toThrow(/sám sebe/);
|
||||
});
|
||||
|
||||
it('nedovolí odkaz na neexistující šablonu', async () => {
|
||||
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')]))
|
||||
.rejects.toThrow(/nebyla nalezena/);
|
||||
.rejects.toThrow(/nebyl nalezen/);
|
||||
});
|
||||
|
||||
it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
|
||||
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/);
|
||||
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/Nelze smazat/);
|
||||
});
|
||||
|
||||
it('šablonu bez vazeb smazat lze', async () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,31 @@ export function listDatesInRange(from: string, to: string): string[] {
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** Převede datum YYYY-MM-DD na Date v lokální časové zóně. */
|
||||
const parseIsoDate = (date: string): Date => {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
/**
|
||||
* Posune datum o zadaný počet dní.
|
||||
*
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
* @param days počet dní, záporný posouvá zpět
|
||||
*/
|
||||
export const shiftIsoDate = (date: string, days: number): string => {
|
||||
const result = parseIsoDate(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return formatDate(result);
|
||||
};
|
||||
|
||||
/** Vrátí počet dní od `from` do `to`; kladný, pokud je `to` později. */
|
||||
export const daysBetween = (from: string, to: string): number =>
|
||||
Math.round((parseIsoDate(to).getTime() - parseIsoDate(from).getTime()) / 86_400_000);
|
||||
|
||||
/** Vrátí den v týdnu podle ISO, 1 = pondělí až 7 = neděle. */
|
||||
export const getIsoWeekday = (date: string): number => parseIsoDate(date).getDay() || 7;
|
||||
|
||||
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
|
||||
const MAX_MULTIPLIER = 100;
|
||||
|
||||
|
||||
+109
-6
@@ -1,22 +1,33 @@
|
||||
import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { NotFoundError } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Kde a kterou aktivitou se vynechaný cvik nahradil. */
|
||||
export type WorkoutPlanReplacement = {
|
||||
date: string;
|
||||
entryId: string;
|
||||
};
|
||||
|
||||
/** Jeden cvik plánu — jak ho šablona založila. */
|
||||
export type WorkoutPlanItem = {
|
||||
/**
|
||||
* Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni
|
||||
* zůstal a jestli se upravil — podle názvu by to nešlo, šablona může mít
|
||||
* stejný cvik dvakrát. Chybí jen u plánů dopočtených zpětně z dnešní podoby
|
||||
* šablony, kde se cvik ve dni nenašel.
|
||||
* stejný cvik dvakrát. Chybí u cviku, který se při potvrzení dne plánu
|
||||
* odškrtl jako vynechaný.
|
||||
*/
|
||||
entryId?: string;
|
||||
name: string;
|
||||
unit: ActivityUnit;
|
||||
quantity: number;
|
||||
sets?: number;
|
||||
caloriesPer100Units?: number;
|
||||
extraWeightGrams?: number;
|
||||
calories: number;
|
||||
/** Náhrada vynechaného cviku v některém z dalších dnů. */
|
||||
replacement?: WorkoutPlanReplacement;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,12 +35,23 @@ export type WorkoutPlanItem = {
|
||||
*
|
||||
* Bez něj by nešlo poznat odchylku od šablony: smazaná aktivita beze stopy zmizí
|
||||
* a šablona se mezitím mohla upravit, takže porovnání s její současnou podobou
|
||||
* by lhalo.
|
||||
* by lhalo. U plánů (fixních a plovoucích) je to zároveň záznam, že se daný den
|
||||
* plánu odcvičil — podle něj se určuje, co je na řadě dál.
|
||||
*/
|
||||
export type WorkoutPlan = {
|
||||
/**
|
||||
* Identifikátor záznamu. Starší záznamy ho nemají; {@link getWorkoutPlans}
|
||||
* jim místo něj doplní čas použití, který se už nemění.
|
||||
*/
|
||||
id: string;
|
||||
templateId: string;
|
||||
/** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */
|
||||
templateName: string;
|
||||
/** Den plánu; u volné šablony chybí. */
|
||||
planDayId?: string;
|
||||
planDayName?: string;
|
||||
/** Lhůta pro náhradu vynechaných cviků, jak ji plán měl v den použití. */
|
||||
replaceWithinDays?: number;
|
||||
appliedAt: string;
|
||||
items: WorkoutPlanItem[];
|
||||
};
|
||||
@@ -37,6 +59,16 @@ export type WorkoutPlan = {
|
||||
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
|
||||
const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`;
|
||||
|
||||
/** Vrátí cvik s novou náhradou, nebo bez náhrady. */
|
||||
const withReplacement = (item: WorkoutPlanItem, replacement?: WorkoutPlanReplacement): WorkoutPlanItem => {
|
||||
const result = { ...item };
|
||||
delete result.replacement;
|
||||
return replacement ? { ...result, replacement } : result;
|
||||
};
|
||||
|
||||
/** Doplní záznamu identifikátor, pokud ho ze starší verze nemá. */
|
||||
const withId = (plan: WorkoutPlan): WorkoutPlan => ({ ...plan, id: plan.id ?? plan.appliedAt });
|
||||
|
||||
/**
|
||||
* Vrátí plány šablon použitých v daném dni.
|
||||
*
|
||||
@@ -44,7 +76,23 @@ const getKey = (login: string, date: string): string => `workoutPlans:${login}:$
|
||||
* @param date datum ve formátu YYYY-MM-DD
|
||||
*/
|
||||
export const getWorkoutPlans = async (login: string, date: string): Promise<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.
|
||||
@@ -57,6 +105,56 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko
|
||||
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.
|
||||
*
|
||||
@@ -64,6 +162,10 @@ export const recordWorkoutPlan = async (login: string, date: string, plan: Worko
|
||||
* neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve
|
||||
* statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá.
|
||||
*
|
||||
* Zůstává záznam dne plánu, který se potvrdil celý jako vynechaný (žádný cvik
|
||||
* z něj aktivitu nezaložil), a záznam, jehož některý cvik se už nahradil —
|
||||
* na ten odkazuje náhradní aktivita.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date datum dne
|
||||
* @param remaining aktivity, které ve dni zůstaly
|
||||
@@ -72,6 +174,7 @@ export const dropOrphanedPlans = async (login: string, date: string, remaining:
|
||||
const key = getKey(login, date);
|
||||
if (!await storage.hasData(key)) return;
|
||||
const ids = new Set(remaining.map(entry => entry.id));
|
||||
await storage.updateData<WorkoutPlan[]>(key,
|
||||
current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId))));
|
||||
await storage.updateData<WorkoutPlan[]>(key, current => (current ?? []).filter(plan =>
|
||||
plan.items.some(item => (item.entryId && ids.has(item.entryId)) || item.replacement)
|
||||
|| plan.items.every(item => !item.entryId)));
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
+337
-97
@@ -1,85 +1,172 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||
ActivityEntry, ActivityUnit, PlannedWorkoutItem, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate,
|
||||
WorkoutTemplateItem, WorkoutTemplateType,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
import { BadRequestError, NotFoundError, formatDate, getToday, isValidIsoDate } from "./utils";
|
||||
import { recordWorkoutPlan } from "./workoutPlans";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Maximální počet položek jedné šablony — pojistka proti nesmyslnému vstupu. */
|
||||
/** Maximální počet položek jedné šablony nebo dne plánu — pojistka proti nesmyslnému vstupu. */
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
/** Maximální počet dnů plovoucího plánu. Fixní jich má nejvýš sedm, jeden na den v týdnu. */
|
||||
const MAX_PLAN_DAYS = 14;
|
||||
|
||||
/** Nejdelší povolená lhůta pro náhradu vynechaného cviku ve dnech. */
|
||||
export const MAX_REPLACE_DAYS = 14;
|
||||
|
||||
/** Názvy dnů v týdnu pro výchozí název dne fixního plánu, od pondělí. */
|
||||
const WEEKDAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle'];
|
||||
|
||||
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
|
||||
const getKey = (login: string): string => `workouts:${login}`;
|
||||
|
||||
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */
|
||||
type StoredTemplate = {
|
||||
/** Uložený den plánu — dopočítávaná pole se neukládají. */
|
||||
export type StoredPlanDay = {
|
||||
id: string;
|
||||
name: string;
|
||||
weekday?: number;
|
||||
items: WorkoutTemplateItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Uložená podoba šablony — dopočítávaná pole se neukládají.
|
||||
*
|
||||
* Volný typ je výchozí a nezapisuje se, takže šablony uložené dřív, než typy
|
||||
* přibyly, jsou volné. Plán položky nemá, cviky má ve dnech.
|
||||
*/
|
||||
export type StoredTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: WorkoutTemplateType;
|
||||
items: WorkoutTemplateItem[];
|
||||
days?: StoredPlanDay[];
|
||||
replaceWithinDays?: number;
|
||||
startDate?: string;
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
|
||||
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA;
|
||||
|
||||
/** Vrátí typ šablony; starší šablony typ nemají a jsou volné. */
|
||||
export const getTemplateType = (template: StoredTemplate): WorkoutTemplateType =>
|
||||
template.type ?? WorkoutTemplateType.VOLNY;
|
||||
|
||||
/** Vrátí true, pokud je šablona plánem (fixním nebo plovoucím), ne volnou šablonou. */
|
||||
export const isPlan = (template: StoredTemplate): boolean => getTemplateType(template) !== WorkoutTemplateType.VOLNY;
|
||||
|
||||
/**
|
||||
* Doplní položkám druh, pokud chybí.
|
||||
*
|
||||
* Šablony uložené dřív, než přibylo skládání z jiných šablon, pole `kind`
|
||||
* nemají — všechny jejich položky jsou cviky.
|
||||
*/
|
||||
const withKind = (template: StoredTemplate): StoredTemplate => ({
|
||||
const withKind = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] =>
|
||||
items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK }));
|
||||
|
||||
/** Znormalizuje šablonu načtenou z úložiště. */
|
||||
const fromStorage = (template: StoredTemplate): StoredTemplate => ({
|
||||
...template,
|
||||
items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })),
|
||||
items: withKind(template.items ?? []),
|
||||
...(template.days ? { days: template.days.map(day => ({ ...day, items: withKind(day.items) })) } : {}),
|
||||
});
|
||||
|
||||
/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */
|
||||
const itemCalories = (item: WorkoutTemplateItem): number =>
|
||||
Math.round(getTotalQuantity(item.quantity ?? 0, item.sets) / 100 * (item.caloriesPer100Units ?? 0));
|
||||
|
||||
/**
|
||||
* Rozbalí odkazy na jiné šablony na jejich cviky.
|
||||
*
|
||||
* Volné šablony se zanořují nejvýš o jednu úroveň a den plánu může odkázat
|
||||
* na volnou šablonu, která sama něco skládá — hloubka je tak nejvýš dvě.
|
||||
* Pojistka hloubky chrání jen před poškozenými daty.
|
||||
*
|
||||
* @param items položky šablony nebo dne plánu
|
||||
* @param byId všechny šablony uživatele podle id
|
||||
*/
|
||||
export const expandExercises = (
|
||||
items: WorkoutTemplateItem[], byId: Map<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),
|
||||
* názvy vnořených šablon a informaci, kolik jiných šablon ji používá.
|
||||
*/
|
||||
const withDerived = (template: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => {
|
||||
const byId = new Map(all.map(item => [item.id, item]));
|
||||
let calories = 0;
|
||||
let hasNested = false;
|
||||
|
||||
const items = template.items.map(item => {
|
||||
if (!isTemplateRef(item)) {
|
||||
calories += itemCalories(item);
|
||||
return item;
|
||||
}
|
||||
hasNested = true;
|
||||
const deriveItems = (items: WorkoutTemplateItem[]): WorkoutTemplateItem[] => items.map(item => {
|
||||
if (!isTemplateRef(item)) return item;
|
||||
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined;
|
||||
// Vnořená šablona smí obsahovat jen cviky, takže stačí sečíst její položky
|
||||
const nestedCalories = (nested?.items ?? []).reduce((sum, nestedItem) => sum + itemCalories(nestedItem), 0);
|
||||
calories += nestedCalories;
|
||||
return {
|
||||
...item,
|
||||
...(nested ? { templateRefName: nested.name } : {}),
|
||||
templateRefCalories: nestedCalories,
|
||||
templateRefCalories: nested ? sumCalories(nested.items, byId) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const days: WorkoutPlanDay[] | undefined = template.days?.map(day => ({
|
||||
...day,
|
||||
items: deriveItems(day.items),
|
||||
estimatedCalories: sumCalories(day.items, byId),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
items,
|
||||
estimatedCalories: calories,
|
||||
hasNested,
|
||||
usedByCount: all.filter(other => other.id !== template.id
|
||||
&& other.items.some(item => isTemplateRef(item) && item.templateRefId === template.id)).length,
|
||||
type: getTemplateType(template),
|
||||
items: deriveItems(template.items),
|
||||
...(days ? { days } : {}),
|
||||
...(template.replaceWithinDays != null ? { replaceWithinDays: template.replaceWithinDays } : {}),
|
||||
...(template.startDate ? { startDate: template.startDate } : {}),
|
||||
estimatedCalories: days
|
||||
? days.reduce((sum, day) => sum + day.estimatedCalories, 0)
|
||||
: sumCalories(template.items, byId),
|
||||
hasNested: template.items.some(isTemplateRef),
|
||||
usedByCount: findNestingTemplates(template.id, all).length,
|
||||
usedInPlanCount: findUsingPlans(template.id, all).length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Načte uložené šablony uživatele se doplněným druhem položek. */
|
||||
const loadTemplates = async (login: string): Promise<StoredTemplate[]> =>
|
||||
(await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(withKind);
|
||||
export const loadTemplates = async (login: string): Promise<StoredTemplate[]> =>
|
||||
(await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(fromStorage);
|
||||
|
||||
/**
|
||||
* Vrátí šablony tréninků uživatele seřazené podle názvu.
|
||||
@@ -100,7 +187,7 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
|
||||
if (kind === WorkoutItemKind.SABLONA) {
|
||||
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) {
|
||||
throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`);
|
||||
throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádný trénink`);
|
||||
}
|
||||
// Název a odhad kalorií vnořené šablony si server dopočítá sám
|
||||
return { kind, templateRefId: value.templateRefId };
|
||||
@@ -137,98 +224,204 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Ověří a znormalizuje seznam položek šablony nebo dne plánu. */
|
||||
const normalizeItems = (items: unknown, emptyMessage: string): WorkoutTemplateItem[] => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
throw new BadRequestError(emptyMessage);
|
||||
}
|
||||
if (items.length > MAX_ITEMS) {
|
||||
throw new BadRequestError(`Trénink může mít nejvýše ${MAX_ITEMS} položek`);
|
||||
}
|
||||
return items.map(normalizeItem);
|
||||
};
|
||||
|
||||
/** Ověří typ šablony; nevyplněný znamená volnou. */
|
||||
const normalizeType = (value: unknown): WorkoutTemplateType => {
|
||||
if (value == null) return WorkoutTemplateType.VOLNY;
|
||||
if (!(Object.values(WorkoutTemplateType) as unknown[]).includes(value)) {
|
||||
throw new BadRequestError("Neplatný typ tréninku");
|
||||
}
|
||||
return value as WorkoutTemplateType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ověří, že se zanořením šablon nepřekročí jedna úroveň.
|
||||
* Ověří a znormalizuje dny plánu.
|
||||
*
|
||||
* Fixní plán má u každého dne den v týdnu (každý nejvýš jednou) a dny řadí
|
||||
* od pondělí. Plovoucí je bez dnů v týdnu a zachová pořadí, ve kterém se cvičí.
|
||||
* Den bez názvu dostane výchozí — pořadí, nebo název dne v týdnu.
|
||||
*/
|
||||
const normalizeDays = (raw: unknown, type: WorkoutTemplateType): StoredPlanDay[] => {
|
||||
if (!Array.isArray(raw) || !raw.length) {
|
||||
throw new BadRequestError("Plán musí mít alespoň jeden tréninkový den");
|
||||
}
|
||||
const isFixed = type === WorkoutTemplateType.FIXNI;
|
||||
if (raw.length > (isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS)) {
|
||||
throw new BadRequestError(`Plán může mít nejvýše ${isFixed ? WEEKDAY_NAMES.length : MAX_PLAN_DAYS} dnů`);
|
||||
}
|
||||
|
||||
const days = raw.map((rawDay, index): StoredPlanDay => {
|
||||
const value = (rawDay ?? {}) as Record<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:
|
||||
* šablona nesmí odkazovat na šablonu, která sama něco vnořuje, a zároveň
|
||||
* nesmí sama začít vnořovat, pokud ji už někdo jiný používá.
|
||||
* nesmí sama začít vnořovat, pokud ji už jiná volná šablona používá.
|
||||
* Plán úroveň zanoření nepřidává, den plánu tak smí odkázat na libovolnou
|
||||
* volnou šablonu.
|
||||
*/
|
||||
const checkNesting = (id: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => {
|
||||
const byId = new Map(all.map(template => [template.id, template]));
|
||||
const nestedRefs = items.filter(isTemplateRef);
|
||||
const checkReferences = (template: StoredTemplate, all: StoredTemplate[]): void => {
|
||||
const byId = new Map(all.map(item => [item.id, item]));
|
||||
const plan = isPlan(template);
|
||||
const refs = [...template.items, ...(template.days ?? []).flatMap(day => day.items)].filter(isTemplateRef);
|
||||
|
||||
for (const item of nestedRefs) {
|
||||
if (item.templateRefId === id) {
|
||||
throw new BadRequestError("Šablona nemůže odkazovat sama na sebe");
|
||||
for (const item of refs) {
|
||||
if (item.templateRefId === template.id) {
|
||||
throw new BadRequestError("Trénink nemůže obsahovat sám sebe");
|
||||
}
|
||||
const nested = byId.get(item.templateRefId!);
|
||||
if (!nested) {
|
||||
throw new NotFoundError("Vnořená šablona nebyla nalezena");
|
||||
throw new NotFoundError("Vložený trénink nebyl nalezen");
|
||||
}
|
||||
if (nested.items.some(isTemplateRef)) {
|
||||
if (isPlan(nested)) {
|
||||
throw new BadRequestError(`'${nested.name}' je plán, do jiného tréninku ho vložit nelze`);
|
||||
}
|
||||
if (!plan && nested.items.some(isTemplateRef)) {
|
||||
throw new BadRequestError(
|
||||
`Šablona '${nested.name}' už sama skládá jiné šablony, nelze ji vnořit — zanoření je povolené jen na jednu úroveň`);
|
||||
`Trénink '${nested.name}' už sám obsahuje jiné tréninky, nelze ho vložit — vkládání je povolené jen na jednu úroveň`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nestedRefs.length) {
|
||||
const usedBy = all.find(template => template.id !== id
|
||||
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
|
||||
if (usedBy) {
|
||||
throw new BadRequestError(
|
||||
`Tuto šablonu už skládá šablona '${usedBy.name}', takže do ní nelze vnořit další — zanoření je povolené jen na jednu úroveň`);
|
||||
}
|
||||
const [usedBy] = findNestingTemplates(template.id, all);
|
||||
const [usedInPlan] = findUsingPlans(template.id, all);
|
||||
if (plan && (usedBy || usedInPlan)) {
|
||||
throw new BadRequestError(
|
||||
`Trénink je součástí '${(usedBy ?? usedInPlan).name}', takže z něj nejde udělat plán`);
|
||||
}
|
||||
if (!plan && refs.length && usedBy) {
|
||||
throw new BadRequestError(
|
||||
`Tento trénink už je vložený do '${usedBy.name}', takže do něj nelze vložit další — vkládání je povolené jen na jednu úroveň`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* Volná šablona má položky, plán (fixní nebo plovoucí) dny. Úprava plánu
|
||||
* historii nemění — co se kdy odcvičilo, drží záznamy použití (`workoutPlans.ts`).
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor upravované šablony, nebo nic pro založení nové
|
||||
* @param name název šablony
|
||||
* @param items položky šablony
|
||||
* @param items položky volné šablony
|
||||
* @param plan typ a u plánu jeho dny, lhůta pro náhradu a začátek platnosti
|
||||
*/
|
||||
export const saveWorkoutTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
name: unknown,
|
||||
items: unknown,
|
||||
plan?: unknown,
|
||||
): Promise<WorkoutTemplate[]> => {
|
||||
if (typeof name !== 'string' || !name.trim().length) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
}
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
throw new BadRequestError("Šablona musí mít alespoň jednu položku");
|
||||
}
|
||||
if (items.length > MAX_ITEMS) {
|
||||
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
|
||||
throw new BadRequestError("Nebyl předán název tréninku");
|
||||
}
|
||||
const options = (plan ?? {}) as Record<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 = {
|
||||
id: typeof id === 'string' && id.length ? id : randomUUID(),
|
||||
name: name.trim(),
|
||||
items: items.map(normalizeItem),
|
||||
};
|
||||
checkNesting(normalized.id, normalized.items, await loadTemplates(login));
|
||||
let normalized: StoredTemplate;
|
||||
if (type === WorkoutTemplateType.VOLNY) {
|
||||
normalized = {
|
||||
id: templateId,
|
||||
name: name.trim(),
|
||||
items: normalizeItems(items, "Trénink musí mít alespoň jednu položku"),
|
||||
};
|
||||
} else {
|
||||
if (options.startDate != null && !isValidIsoDate(options.startDate)) {
|
||||
throw new BadRequestError("Začátek platnosti plánu není platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const replaceWithinDays = normalizeReplaceWithinDays(options.replaceWithinDays);
|
||||
normalized = {
|
||||
id: templateId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
items: [],
|
||||
days: normalizeDays(options.days, type),
|
||||
...(replaceWithinDays ? { replaceWithinDays } : {}),
|
||||
startDate: (options.startDate as string | undefined) ?? existing?.startDate ?? formatDate(getToday()),
|
||||
};
|
||||
}
|
||||
checkReferences(normalized, all);
|
||||
|
||||
// Plán položky nemá, prázdný seznam se do úložiště nezapisuje
|
||||
const toStore: Partial<StoredTemplate> = { ...normalized };
|
||||
if (isPlan(normalized)) delete toStore.items;
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(template => template.id !== normalized.id);
|
||||
return [...others, normalized];
|
||||
return [...others, toStore as StoredTemplate];
|
||||
});
|
||||
return getWorkoutTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze —
|
||||
* jinak by ve složené šabloně zůstal odkaz do prázdna.
|
||||
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná nebo kterou má plán
|
||||
* v některém dni, smazat nelze — jinak by zůstal odkaz do prázdna.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
*/
|
||||
export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise<WorkoutTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
throw new BadRequestError("Nebyl předán identifikátor tréninku");
|
||||
}
|
||||
const all = await loadTemplates(login);
|
||||
if (!all.some(template => template.id === id)) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Trénink nebyl nalezen");
|
||||
}
|
||||
const usedBy = all.find(template => template.id !== id
|
||||
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
|
||||
const [usedBy] = [...findNestingTemplates(id, all), ...findUsingPlans(id, all)];
|
||||
if (usedBy) {
|
||||
throw new BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`);
|
||||
throw new BadRequestError(`Nelze smazat, je součástí '${usedBy.name}'`);
|
||||
}
|
||||
|
||||
await storage.updateData<StoredTemplate[]>(getKey(login),
|
||||
@@ -236,20 +429,65 @@ export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise
|
||||
return getWorkoutTemplates(login);
|
||||
};
|
||||
|
||||
/** Ověří výčet odcvičených cviků; bez něj se berou všechny. */
|
||||
const normalizeDoneIndexes = (value: unknown, count: number): Set<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,
|
||||
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
|
||||
* odkud přišly.
|
||||
*
|
||||
* U plánu se zakládá jeden jeho den a slouží to i k potvrzení navrženého
|
||||
* tréninku: cviky mimo `doneIndexes` se zaznamenají jako vynechané (bez
|
||||
* aktivity) a v lhůtě plánu je jde nahradit. Den plánu jde potvrdit i celý
|
||||
* jako vynechaný — u plovoucího plánu se tím posune na další den sekvence.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
* @param date den, do kterého se položky založí
|
||||
* @param dayId den plánu, povinný u plánu
|
||||
* @param doneIndexes pořadí odcvičených cviků v rozbaleném seznamu; bez něj všechny
|
||||
*/
|
||||
export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise<string> => {
|
||||
export const applyWorkoutTemplate = async (
|
||||
login: string, id: unknown, date: unknown, dayId?: unknown, doneIndexes?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
throw new BadRequestError("Nebyl předán identifikátor tréninku");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
@@ -257,48 +495,50 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
||||
const all = await loadTemplates(login);
|
||||
const template = all.find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
throw new NotFoundError("Trénink nebyl nalezen");
|
||||
}
|
||||
|
||||
let day: StoredPlanDay | undefined;
|
||||
if (isPlan(template)) {
|
||||
if (typeof dayId !== 'string' || !dayId.length) {
|
||||
throw new BadRequestError("Nebyl předán den plánu");
|
||||
}
|
||||
day = template.days?.find(item => item.id === dayId);
|
||||
if (!day) {
|
||||
throw new NotFoundError("Den plánu nebyl nalezen");
|
||||
}
|
||||
}
|
||||
|
||||
// Odkazy na vnořené šablony se rozbalí na jejich cviky; hlubší zanoření nemůže vzniknout
|
||||
const byId = new Map(all.map(item => [item.id, item]));
|
||||
const exercises = template.items.flatMap(item =>
|
||||
isTemplateRef(item) ? (byId.get(item.templateRefId!)?.items ?? []) : [item]);
|
||||
const exercises = expandExercises(day ? day.items : template.items, byId).map(toPlannedItem);
|
||||
const done = normalizeDoneIndexes(doneIndexes, exercises.length);
|
||||
if (!day && !done.size) {
|
||||
throw new BadRequestError("Nebyl vybrán žádný cvik");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entries: ActivityEntry[] = exercises.map(item => ({
|
||||
id: randomUUID(),
|
||||
date,
|
||||
name: item.name!,
|
||||
unit: item.unit!,
|
||||
quantity: item.quantity!,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||
calories: itemCalories(item),
|
||||
templateId: template.id,
|
||||
createdAt: now,
|
||||
}));
|
||||
const link = { templateId: template.id, ...(day ? { planDayId: day.id } : {}) };
|
||||
const entries = exercises.map((item, index) =>
|
||||
done.has(index) ? createActivityFromExercise(item, date, now, link) : undefined);
|
||||
|
||||
await addActivities(login, date, entries);
|
||||
await addActivities(login, date, entries.filter(entry => !!entry));
|
||||
await recordWorkoutPlan(login, date, {
|
||||
id: randomUUID(),
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
...(day ? { planDayId: day.id, planDayName: day.name } : {}),
|
||||
...(day && template.replaceWithinDays ? { replaceWithinDays: template.replaceWithinDays } : {}),
|
||||
appliedAt: now,
|
||||
items: entries.map(entry => ({
|
||||
entryId: entry.id,
|
||||
name: entry.name,
|
||||
unit: entry.unit,
|
||||
quantity: entry.quantity,
|
||||
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||
calories: entry.calories ?? 0,
|
||||
items: exercises.map((item, index) => ({
|
||||
...(entries[index] ? { entryId: entries[index].id } : {}),
|
||||
...item,
|
||||
})),
|
||||
});
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
|
||||
* Vytvoří volnou šablonu tréninku z aktivit zaznamenaných v přehledu dne.
|
||||
*
|
||||
* Slouží k opačnému směru než {@link applyWorkoutTemplate}: trénink odcvičený
|
||||
* "na truc" jde takhle povýšit na šablonu a příště použít jedním kliknutím.
|
||||
@@ -325,7 +565,7 @@ export const createWorkoutTemplateFromActivities = async (
|
||||
const activities = (await getActivityDay(login, date)).entries
|
||||
.filter(entry => !selected || selected.has(entry.id));
|
||||
if (!activities.length) {
|
||||
throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit");
|
||||
throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šel trénink vytvořit");
|
||||
}
|
||||
|
||||
const items = activities.map(entry => ({
|
||||
|
||||
@@ -84,6 +84,8 @@ paths:
|
||||
$ref: "./paths/workouts/apply.yml"
|
||||
/workouts/fromActivities:
|
||||
$ref: "./paths/workouts/fromActivities.yml"
|
||||
/workouts/replace:
|
||||
$ref: "./paths/workouts/replace.yml"
|
||||
|
||||
# Recepty (/api/mealTemplates)
|
||||
/mealTemplates:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
post:
|
||||
operationId: applyWorkoutTemplate
|
||||
summary: Založí do zvoleného dne všechny položky šablony tréninku.
|
||||
summary: |
|
||||
Založí do zvoleného dne cviky šablony tréninku. U plánu se zakládá jeden
|
||||
jeho den a jde tak i potvrdit navržený trénink — cviky mimo doneIndexes
|
||||
se zaznamenají jako vynechané a v okně replaceWithinDays je jde nahradit.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -16,6 +19,17 @@ post:
|
||||
description: Den, do kterého se položky založí
|
||||
type: string
|
||||
format: date
|
||||
dayId:
|
||||
description: Den plánu. Povinný u šablony typu FIXNI a PLOVOUCI.
|
||||
type: string
|
||||
doneIndexes:
|
||||
description: |
|
||||
Pořadí odcvičených cviků v rozbaleném seznamu (jak ho vrací
|
||||
PlannedWorkout.items). Bez něj se založí všechny; u dne plánu
|
||||
může být prázdné — celý den se pak zaznamená jako vynechaný.
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný přehled dne
|
||||
|
||||
@@ -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
|
||||
@@ -1,23 +1,12 @@
|
||||
post:
|
||||
operationId: saveWorkoutTemplate
|
||||
summary: Založí novou šablonu tréninku, nebo upraví existující.
|
||||
summary: Založí novou šablonu tréninku (volnou nebo plán), nebo upraví existující.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, items]
|
||||
properties:
|
||||
id:
|
||||
description: Identifikátor upravované šablony. Bez něj se založí nová.
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/WorkoutTemplateItem"
|
||||
$ref: "../../schemas/_index.yml#/WorkoutTemplateInput"
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný seznam šablon
|
||||
|
||||
+315
-7
@@ -884,6 +884,15 @@ ActivityEntry:
|
||||
templateId:
|
||||
description: Identifikátor šablony, ze které aktivita vznikla
|
||||
type: string
|
||||
planDayId:
|
||||
description: Den plánu (šablony typu FIXNI nebo PLOVOUCI), ze kterého aktivita vznikla
|
||||
type: string
|
||||
replacesDate:
|
||||
description: |
|
||||
Datum dne, ve kterém se cvik z plánu vynechal a tato aktivita ho nahrazuje.
|
||||
Nastavuje jen server při náhradě.
|
||||
type: string
|
||||
format: date
|
||||
createdAt:
|
||||
description: Čas vzniku záznamu
|
||||
type: string
|
||||
@@ -992,10 +1001,22 @@ WorkoutTemplateItem:
|
||||
description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje.
|
||||
type: integer
|
||||
|
||||
WorkoutTemplate:
|
||||
WorkoutTemplateType:
|
||||
description: |
|
||||
Předpřipravený trénink. Použitím se jeho položky založí do zvoleného dne,
|
||||
takže opakovaný trénink není potřeba zadávat cvik po cviku znovu.
|
||||
Typ šablony tréninku.
|
||||
VOLNY — cviky, které jde odcvičit kdykoli; do dne se zakládají ručně.
|
||||
FIXNI — plán s pevnými dny v týdnu; dny bez tréninku jsou volno.
|
||||
PLOVOUCI — plán jako sekvence dnů zakončená dnem volna. Na kalendář navázaný
|
||||
není: další den sekvence přijde na řadu, až se splní předchozí, a volno jde
|
||||
v rámci cyklu posunout dopředu.
|
||||
type: string
|
||||
enum:
|
||||
- VOLNY
|
||||
- FIXNI
|
||||
- PLOVOUCI
|
||||
|
||||
WorkoutPlanDay:
|
||||
description: Jeden tréninkový den plánu (šablony typu FIXNI nebo PLOVOUCI)
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
@@ -1003,20 +1024,95 @@ WorkoutTemplate:
|
||||
- name
|
||||
- items
|
||||
- estimatedCalories
|
||||
properties:
|
||||
id:
|
||||
description: |
|
||||
Stálý identifikátor dne. Podle něj se v historii pozná, který den plánu
|
||||
se kdy cvičil, i když se plán mezitím upraví.
|
||||
type: string
|
||||
name:
|
||||
description: Název dne, např. "Workout Day 1"
|
||||
type: string
|
||||
weekday:
|
||||
description: Den v týdnu u fixního plánu, 1 = pondělí až 7 = neděle
|
||||
type: integer
|
||||
items:
|
||||
description: Cviky dne; odkazem lze zařadit i volnou šablonu
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutTemplateItem"
|
||||
estimatedCalories:
|
||||
description: Odhad spálených kcal za den. Doplňuje server.
|
||||
type: integer
|
||||
|
||||
WorkoutPlanDayInput:
|
||||
description: Den plánu při ukládání šablony. Bez id se založí nový den.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
- items
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
weekday:
|
||||
type: integer
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutTemplateItem"
|
||||
|
||||
WorkoutTemplate:
|
||||
description: |
|
||||
Předpřipravený trénink. Volná šablona je seznam cviků, který se použitím
|
||||
založí do zvoleného dne. Plán (fixní nebo plovoucí) má tréninkové dny
|
||||
a přehled dne z něj sám navrhne, co je ten den na řadě.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- type
|
||||
- items
|
||||
- estimatedCalories
|
||||
- hasNested
|
||||
- usedByCount
|
||||
- usedInPlanCount
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
description: Název šablony, např. "Workout day 1"
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/WorkoutTemplateType"
|
||||
items:
|
||||
description: Položky volné šablony; u plánu je seznam prázdný a cviky jsou ve dnech
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutTemplateItem"
|
||||
days:
|
||||
description: |
|
||||
Tréninkové dny plánu. U plovoucího v pořadí, ve kterém se cvičí —
|
||||
den volna za posledním se nezapisuje, plyne z typu.
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutPlanDay"
|
||||
replaceWithinDays:
|
||||
description: |
|
||||
Do kolika dní jde cvik vynechaný v plánu nahradit. Chybí-li, náhrady
|
||||
se nenabízejí.
|
||||
type: integer
|
||||
startDate:
|
||||
description: Od kterého dne plán platí a přehled dne z něj navrhuje trénink
|
||||
type: string
|
||||
format: date
|
||||
estimatedCalories:
|
||||
description: Odhad spálených kcal za celou šablonu včetně vnořených
|
||||
description: |
|
||||
Odhad spálených kcal za celou šablonu včetně vnořených, u plánu
|
||||
za celý cyklus (týden)
|
||||
type: integer
|
||||
hasNested:
|
||||
description: |
|
||||
@@ -1024,8 +1120,155 @@ WorkoutTemplate:
|
||||
už nelze vnořit do další — zanoření je povolené jen na jednu úroveň.
|
||||
type: boolean
|
||||
usedByCount:
|
||||
description: Kolik jiných šablon tuto šablonu používá
|
||||
description: Kolik jiných volných šablon tuto šablonu skládá
|
||||
type: integer
|
||||
usedInPlanCount:
|
||||
description: V kolika plánech je šablona zařazená do některého dne
|
||||
type: integer
|
||||
|
||||
WorkoutTemplateInput:
|
||||
description: Data pro založení nebo úpravu šablony tréninku
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
id:
|
||||
description: Identifikátor upravované šablony. Bez něj se založí nová.
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/WorkoutTemplateType"
|
||||
items:
|
||||
description: Položky volné šablony
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutTemplateItem"
|
||||
days:
|
||||
description: Dny plánu (FIXNI, PLOVOUCI)
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/WorkoutPlanDayInput"
|
||||
replaceWithinDays:
|
||||
type: integer
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
|
||||
PlannedWorkoutStatus:
|
||||
description: |
|
||||
Stav plánu v konkrétní den.
|
||||
NAVRH — den plánu je na řadě a čeká na potvrzení.
|
||||
SPLNENO — den plánu se ten den už potvrdil.
|
||||
VOLNO — podle plánu je den volna.
|
||||
type: string
|
||||
enum:
|
||||
- NAVRH
|
||||
- SPLNENO
|
||||
- VOLNO
|
||||
|
||||
PlannedWorkoutItem:
|
||||
description: Jeden cvik navrženého dne plánu, po rozbalení vnořených šablon
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
- unit
|
||||
- quantity
|
||||
- calories
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
unit:
|
||||
$ref: "#/ActivityUnit"
|
||||
quantity:
|
||||
description: Množství na jednu sérii
|
||||
type: integer
|
||||
sets:
|
||||
type: integer
|
||||
caloriesPer100Units:
|
||||
type: integer
|
||||
extraWeightGrams:
|
||||
$ref: "#/ExtraWeightGrams"
|
||||
calories:
|
||||
description: Spálené kcal za všechny série
|
||||
type: integer
|
||||
|
||||
PlannedWorkout:
|
||||
description: Co plán (fixní nebo plovoucí šablona) navrhuje na konkrétní den
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- templateId
|
||||
- templateName
|
||||
- type
|
||||
- status
|
||||
- items
|
||||
properties:
|
||||
templateId:
|
||||
type: string
|
||||
templateName:
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/WorkoutTemplateType"
|
||||
status:
|
||||
$ref: "#/PlannedWorkoutStatus"
|
||||
dayId:
|
||||
description: Navržený den plánu; u stavu VOLNO chybí
|
||||
type: string
|
||||
dayName:
|
||||
description: |
|
||||
Název navrženého dne, u stavu SPLNENO dnů potvrzených v tento den
|
||||
(při dvou potvrzeních jsou názvy spojené)
|
||||
type: string
|
||||
items:
|
||||
description: Cviky navrženého dne; mimo stav NAVRH je seznam prázdný
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/PlannedWorkoutItem"
|
||||
restAvailable:
|
||||
description: |
|
||||
Jen plovoucí plán: true, pokud v tomto cyklu ještě nebyl den volna,
|
||||
takže když se dnes necvičí, bere se to jako posunuté volno a sekvence
|
||||
pak pokračuje bez další mezery.
|
||||
type: boolean
|
||||
|
||||
PendingReplacement:
|
||||
description: Cvik vynechaný v některém z předchozích dnů, který jde ještě nahradit
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- sourceDate
|
||||
- planId
|
||||
- itemIndex
|
||||
- templateId
|
||||
- templateName
|
||||
- deadline
|
||||
- item
|
||||
properties:
|
||||
sourceDate:
|
||||
description: Den, ve kterém se cvik vynechal
|
||||
type: string
|
||||
format: date
|
||||
planId:
|
||||
description: Identifikátor záznamu o použití plánu v daném dni
|
||||
type: string
|
||||
itemIndex:
|
||||
description: Pořadí cviku v záznamu o použití plánu
|
||||
type: integer
|
||||
templateId:
|
||||
type: string
|
||||
templateName:
|
||||
type: string
|
||||
dayName:
|
||||
type: string
|
||||
deadline:
|
||||
description: Poslední den, kdy jde cvik ještě nahradit
|
||||
type: string
|
||||
format: date
|
||||
item:
|
||||
$ref: "#/PlannedWorkoutItem"
|
||||
|
||||
EnergyBalance:
|
||||
description: Energetická bilance dne — kolik přišlo jídlem a kolik se vydalo
|
||||
@@ -1078,6 +1321,8 @@ DayOverview:
|
||||
- meals
|
||||
- activities
|
||||
- energy
|
||||
- plannedWorkouts
|
||||
- pendingReplacements
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
@@ -1086,6 +1331,16 @@ DayOverview:
|
||||
$ref: "#/DayRecord"
|
||||
activities:
|
||||
$ref: "#/ActivityDay"
|
||||
plannedWorkouts:
|
||||
description: Co na tento den navrhují plány tréninků, které už platí
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/PlannedWorkout"
|
||||
pendingReplacements:
|
||||
description: Cviky vynechané v předchozích dnech, které jde v tento den ještě nahradit
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/PendingReplacement"
|
||||
energy:
|
||||
$ref: "#/EnergyBalance"
|
||||
sleepMinutes:
|
||||
@@ -1389,6 +1644,12 @@ TemplateUsageTotal:
|
||||
name:
|
||||
description: Název šablony v době použití
|
||||
type: string
|
||||
planDayId:
|
||||
description: Den plánu; plán se ve statistikách dělí po dnech
|
||||
type: string
|
||||
dayName:
|
||||
description: Název dne plánu v době použití
|
||||
type: string
|
||||
dayCount:
|
||||
description: Počet dní, ve kterých se šablona použila
|
||||
type: integer
|
||||
@@ -1402,7 +1663,9 @@ TemplateUsageTotal:
|
||||
description: Kolik cviků šablona založila
|
||||
type: integer
|
||||
doneItemCount:
|
||||
description: Kolik z nich ve dni zůstalo (smazané se berou jako neprovedené)
|
||||
description: |
|
||||
Kolik z nich se provedlo — ve dni zůstalo, nebo se později nahradilo
|
||||
(smazané a nenahrazené se berou jako neprovedené)
|
||||
type: integer
|
||||
|
||||
ActivityGroupTotal:
|
||||
@@ -1513,6 +1776,7 @@ ActivityDayStats:
|
||||
- date
|
||||
- totalCalories
|
||||
- templates
|
||||
- replacements
|
||||
- extraCalories
|
||||
- extras
|
||||
properties:
|
||||
@@ -1527,6 +1791,11 @@ ActivityDayStats:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/TemplateDayUsage"
|
||||
replacements:
|
||||
description: Náhrady cviků vynechaných v předchozích dnech, odcvičené v tento den
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/ReplacementUsage"
|
||||
extraCalories:
|
||||
description: Součet kcal aktivit mimo šablony
|
||||
type: integer
|
||||
@@ -1536,8 +1805,36 @@ ActivityDayStats:
|
||||
items:
|
||||
$ref: "#/ActivitySummaryItem"
|
||||
|
||||
ReplacementUsage:
|
||||
description: Jedna náhrada vynechaného cviku z plánu
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- templateId
|
||||
- name
|
||||
- sourceDate
|
||||
- item
|
||||
properties:
|
||||
templateId:
|
||||
type: string
|
||||
name:
|
||||
description: Název šablony (plánu)
|
||||
type: string
|
||||
planDayId:
|
||||
description: Den plánu, ve kterém se cvik vynechal
|
||||
type: string
|
||||
dayName:
|
||||
description: Název dne plánu, ve kterém se cvik vynechal
|
||||
type: string
|
||||
sourceDate:
|
||||
description: Datum, kdy se cvik vynechal
|
||||
type: string
|
||||
format: date
|
||||
item:
|
||||
$ref: "#/ActivitySummaryItem"
|
||||
|
||||
TemplateDayUsage:
|
||||
description: Použití jedné šablony v konkrétní den
|
||||
description: Použití jedné šablony (u plánu jednoho jeho dne) v konkrétní den
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
@@ -1553,6 +1850,11 @@ TemplateDayUsage:
|
||||
name:
|
||||
description: Název šablony v době použití
|
||||
type: string
|
||||
planDayId:
|
||||
type: string
|
||||
dayName:
|
||||
description: Název dne plánu v době použití
|
||||
type: string
|
||||
plannedCalories:
|
||||
type: integer
|
||||
actualCalories:
|
||||
@@ -1572,10 +1874,12 @@ PlanDeviationKind:
|
||||
description: |
|
||||
ODEBRANO — cvik ze šablony ve dni chybí.
|
||||
UPRAVENO — cvik je, ale s jiným množstvím, počtem sérií nebo násobkem.
|
||||
NAHRAZENO — cvik ve dni chybí, ale později se nahradil (replacementDate).
|
||||
type: string
|
||||
enum:
|
||||
- ODEBRANO
|
||||
- UPRAVENO
|
||||
- NAHRAZENO
|
||||
|
||||
PlanDeviation:
|
||||
description: Odchylka jednoho cviku od plánu šablony
|
||||
@@ -1593,6 +1897,10 @@ PlanDeviation:
|
||||
description: Skutečné provedení; u odebraného cviku chybí
|
||||
allOf:
|
||||
- $ref: "#/ActivitySummaryItem"
|
||||
replacementDate:
|
||||
description: U nahrazeného cviku den, kdy se nahradil
|
||||
type: string
|
||||
format: date
|
||||
|
||||
ActivitySummaryItem:
|
||||
description: Stručný popis jedné aktivity pro statistiky
|
||||
|
||||
Reference in new issue
Block a user