feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
@@ -60,7 +60,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
||||||
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
||||||
- **Domain:** `meals.ts` (jídlo), `activities.ts` (pohyb), `workouts.ts` (šablony
|
- **Domain:** `meals.ts` (jídlo), `activities.ts` (pohyb), `workouts.ts` (šablony
|
||||||
tréninků), `dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
tréninků), `sleep.ts` (spánek), `profile.ts` (tělesné údaje a výpočet BMR),
|
||||||
|
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
||||||
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
|
`luncherImport.ts` (parsování exportů z Luncheru), `settings.ts`, `calories.ts`
|
||||||
- **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy
|
- **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy
|
||||||
- **Storage:** `storage/index.ts` factory dle proměnné `STORAGE`; backendy:
|
- **Storage:** `storage/index.ts` factory dle proměnné `STORAGE`; backendy:
|
||||||
@@ -70,9 +71,12 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
### Client (client/src/)
|
### Client (client/src/)
|
||||||
- **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
|
- **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka
|
||||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||||
|
`TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej),
|
||||||
`StatsPage` (statistiky + import)
|
`StatsPage` (statistiky + import)
|
||||||
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
||||||
(MealModal, ActivityModal, WorkoutModal, SettingsModal, ImportModal)
|
(MealModal, ActivityModal, ApplyWorkoutModal, SettingsModal, ImportModal)
|
||||||
|
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||||
|
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
||||||
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
||||||
- **Routing:** konstanty adres jsou v `routes.ts`, ne v `AppRoutes.tsx` — hlavička
|
- **Routing:** konstanty adres jsou v `routes.ts`, ne v `AppRoutes.tsx` — hlavička
|
||||||
je potřebuje a kruhový import by je nechal nedefinované
|
je potřebuje a kruhový import by je nechal nedefinované
|
||||||
@@ -140,23 +144,72 @@ Den má dvě strany a `DayPage` je dělí do záložek:
|
|||||||
- **Výdej** — pohyb (`ActivityEntry`), klíč `activities:<login>:<datum>`
|
- **Výdej** — pohyb (`ActivityEntry`), klíč `activities:<login>:<datum>`
|
||||||
|
|
||||||
Aktivita se měří v jednotce (`ActivityUnit`: KROKY, MINUTY, KM, OPAKOVANI) a
|
Aktivita se měří v jednotce (`ActivityUnit`: KROKY, MINUTY, KM, OPAKOVANI) a
|
||||||
kalorie se dopočtou jako `quantity / 100 * caloriesPer100Units`. **Sto jednotek,
|
kalorie se dopočtou jako `getTotalQuantity(quantity, sets) / 100 * caloriesPer100Units`.
|
||||||
ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal) a aplikace
|
**Sto jednotek, ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal)
|
||||||
všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g.
|
a aplikace všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g.
|
||||||
|
|
||||||
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam cviků. Použitím vzniknou
|
`quantity` je množství **na jednu sérii**, `sets` je počet sérií; celkové množství
|
||||||
běžné aktivity s vazbou `templateId` — jsou samostatné, takže úprava založené
|
je jejich součin. Drží se odděleně schválně: 3 × 40 opakování s pauzami je jiný
|
||||||
položky šablonu nemění.
|
trénink než 120 v kuse, i když spálené kalorie vyjdou stejně. Jedna série je
|
||||||
|
výchozí stav a neukládá se.
|
||||||
|
|
||||||
|
Opačný směr k použití šablony je `createWorkoutTemplateFromActivities` —
|
||||||
|
z aktivit odcvičeného dne (všech, nebo vybraných) vznikne šablona. U jídla to
|
||||||
|
umí `createMealTemplateFromMeal`, které navíc **přepíše šablonu stejného názvu**;
|
||||||
|
opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik
|
||||||
|
stejných. Záznamy ve dni zůstanou v obou případech beze změny.
|
||||||
|
|
||||||
|
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď
|
||||||
|
cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se
|
||||||
|
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou
|
||||||
|
samostatné, takže úprava založené položky šablonu nemění.
|
||||||
|
|
||||||
|
**Zanoření je povolené jen na jednu úroveň** a `checkNesting` to hlídá z obou stran:
|
||||||
|
šablona nesmí odkazovat na takovou, která sama něco skládá, a zároveň nesmí začít
|
||||||
|
skládat, pokud ji už někdo používá. Bez druhé kontroly by druhá úroveň vznikla
|
||||||
|
oklikou přes úpravu. Smazat nelze šablonu, kterou skládá jiná — zůstal by odkaz
|
||||||
|
do prázdna. Šablony uložené dřív, než skládání přibylo, nemají `kind`; `withKind`
|
||||||
|
jim ho při načtení doplní na `CVIK`.
|
||||||
|
|
||||||
`buildEnergyBalance` v `dayOverview.ts` počítá:
|
`buildEnergyBalance` v `dayOverview.ts` počítá:
|
||||||
`bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit.
|
`bilance = příjem − (klidový výdej + pohyb)`. Záporná hodnota je deficit.
|
||||||
Bez nastaveného klidového výdeje (`basalCalories` v nastavení) porovnává bilance
|
Bez nastaveného klidového výdeje porovnává bilance jen jídlo proti pohybu —
|
||||||
jen jídlo proti pohybu — **to není skutečný deficit**, proto se to přes
|
**to není skutečný deficit**, proto se to přes `hasBasal: false` propisuje do UI,
|
||||||
`hasBasal: false` propisuje do UI, aby to číslo nikoho nemátlo.
|
aby to číslo nikoho nemátlo.
|
||||||
|
|
||||||
|
Klidový výdej se bere z `settings.effectiveBasalCalories`: ručně zadaný
|
||||||
|
`basalCalories` má přednost před hodnotou spočítanou z tělesných údajů
|
||||||
|
(`computeBasalCalories` v `profile.ts`, rovnice Mifflin–St Jeor). Výpočet
|
||||||
|
**záměrně nenásobí koeficientem fyzické aktivity (PAL)**, jak to dělají kalkulačky
|
||||||
|
TDEE — pohyb se eviduje zvlášť a přičítá se, takže vynásobení by ho započítalo
|
||||||
|
dvakrát.
|
||||||
|
|
||||||
|
**Spánek** (`sleep.ts`, klíč `sleep:<login>:<datum>`) se eviduje jako kontext dne
|
||||||
|
a do bilance **nevstupuje**. Klidový výdej je hodnota za celých 24 hodin včetně
|
||||||
|
spánku a žádná ze standardních rovnic délku spánku jako proměnnou nemá — přičítat
|
||||||
|
nebo odečítat za něj kalorie by bylo vymýšlení čísla. Test to hlídá:
|
||||||
|
bilance se po zadání spánku nesmí změnit.
|
||||||
|
|
||||||
`GET /api/day?date=` vrací `DayOverview` se vším naráz (jídlo, pohyb, bilance),
|
`GET /api/day?date=` vrací `DayOverview` se vším naráz (jídlo, pohyb, bilance),
|
||||||
takže `DayPage` si vystačí s jedním voláním.
|
takže `DayPage` si vystačí s jedním voláním.
|
||||||
|
|
||||||
|
## Šablony jídel
|
||||||
|
|
||||||
|
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je opakovaně
|
||||||
|
jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných** — je to plochý
|
||||||
|
záznam. Použitím vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu
|
||||||
|
i kalorie dopočítá stejná logika jako u ručně zadaného jídla.
|
||||||
|
|
||||||
|
Šablona může nést buď `calories` (celá porce), nebo `caloriesPer100g` + `weight`.
|
||||||
|
Obojí projde `deriveAmounts`, takže stačí vyplnit, co o jídle víte.
|
||||||
|
|
||||||
|
Pole `note` slouží i na postup přípravy u domácích jídel. Strukturované suroviny
|
||||||
|
(recept jako seznam položek s množstvím) mohou přibýt později jako další volitelné
|
||||||
|
pole, aniž by se muselo měnit cokoli stávajícího.
|
||||||
|
|
||||||
|
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate`
|
||||||
|
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
||||||
|
|
||||||
## Import z Luncheru
|
## Import z Luncheru
|
||||||
|
|
||||||
`server/src/luncherImport.ts` čte měsíční přehled ze stránky statistik Luncheru
|
`server/src/luncherImport.ts` čte měsíční přehled ze stránky statistik Luncheru
|
||||||
|
|||||||
@@ -66,14 +66,44 @@ Přehled dne má dvě záložky:
|
|||||||
- **Příjem** — co jste snědli, s cenou, gramáží a kaloriemi.
|
- **Příjem** — co jste snědli, s cenou, gramáží a kaloriemi.
|
||||||
- **Výdej** — pohyb. Kroky, minuty, kilometry nebo opakování; z množství a
|
- **Výdej** — pohyb. Kroky, minuty, kilometry nebo opakování; z množství a
|
||||||
spotřeby na 100 jednotek se dopočtou spálené kalorie (10 000 kroků ≈ 400 kcal).
|
spotřeby na 100 jednotek se dopočtou spálené kalorie (10 000 kroků ≈ 400 kcal).
|
||||||
|
U silových cviků jde zadat i **počet sérií** — 3 × 40 kliků se tak neplete
|
||||||
|
se 120 v kuse, i když kalorie vyjdou stejně.
|
||||||
|
|
||||||
Opakované tréninky se dají uložit jako **šablonu** — *Workout day 1* pak stačí
|
Volitelně se dá k dni zapsat i **délka spánku**. Slouží jako kontext — do
|
||||||
jedním kliknutím založit do dne celý, místo zadávání cviku po cviku. Založené
|
energetické bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin
|
||||||
položky jsou samostatné, takže je jde doupravit, aniž by se změnila šablona.
|
včetně spánku a žádná standardní rovnice s délkou spánku nepočítá.
|
||||||
|
|
||||||
Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`. Klidový výdej
|
Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`.
|
||||||
(bazální metabolismus) se nastavuje v *Nastavení* — bez něj se porovnává jen
|
|
||||||
jídlo proti pohybu a nejde o skutečný deficit, na což aplikace upozorní.
|
## Šablony tréninků
|
||||||
|
|
||||||
|
Opakované tréninky se ukládají jako šablony na stránce **Šablony** — v přehledu dne
|
||||||
|
pak stačí jedno kliknutí a celý trénink se založí, místo zadávání cviku po cviku.
|
||||||
|
Do šablony jde zařadit i **jinou šablonu**, třeba rozcvičku, kterou máte v každém
|
||||||
|
tréninku; při použití se její cviky rozbalí. Zanoření je kvůli jednoduchosti jen
|
||||||
|
na jednu úroveň a aplikace to hlídá.
|
||||||
|
|
||||||
|
Založené položky jsou samostatné aktivity, takže je jde v dni doupravit, aniž by
|
||||||
|
se změnila šablona.
|
||||||
|
|
||||||
|
Šablonu jde vytvořit i **opačným směrem** — z toho, co už máte v dni zapsané.
|
||||||
|
U jídla ikonou záložky v řádku, u tréninku tlačítkem *Uložit jako šablonu* v záložce
|
||||||
|
Výdej (vybere se, které cviky zahrnout). Při zakládání jídla nebo aktivity je
|
||||||
|
v dialogu i zaškrtávátko *Uložit i jako šablonu*.
|
||||||
|
|
||||||
|
Na druhé záložce jsou **šablony jídel** — opakovaně jedené jídlo (název, zdroj,
|
||||||
|
kalorie, gramáž, cena), které se v přehledu dne založí jedním kliknutím. Ty se
|
||||||
|
neskládají z jiných. U domácích jídel jde do poznámky zapsat postup přípravy.
|
||||||
|
|
||||||
|
## Osobní údaje
|
||||||
|
|
||||||
|
Na stránce **Osobní údaje** se z pohlaví, roku narození, výšky a hmotnosti spočítá
|
||||||
|
klidový výdej (bazální metabolismus) rovnicí Mifflin–St Jeor. Kdo má hodnotu
|
||||||
|
změřenou přesněji, může ji zadat ručně — ta pak má přednost.
|
||||||
|
|
||||||
|
Výpočet se záměrně nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky
|
||||||
|
TDEE: pohyb si aplikace eviduje zvlášť a přičítá ho, takže vynásobení by ho
|
||||||
|
započítalo dvakrát.
|
||||||
|
|
||||||
## Kalorie a gramáž
|
## Kalorie a gramáž
|
||||||
|
|
||||||
|
|||||||
@@ -681,6 +681,12 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
// Počet sérií je skoro vždy jednociferný, stačí užší pole než na množství
|
||||||
|
.workout-item-sets {
|
||||||
|
width: 74px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.workout-item-quantity {
|
.workout-item-quantity {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -699,6 +705,7 @@ body {
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.workout-item-sets,
|
||||||
.workout-item-quantity,
|
.workout-item-quantity,
|
||||||
.workout-item-unit,
|
.workout-item-unit,
|
||||||
.workout-item-rate {
|
.workout-item-rate {
|
||||||
@@ -721,3 +728,46 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ikona u skládané šablony ve výběru
|
||||||
|
.workout-nested-icon {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--ft-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Výběr cviků při ukládání dne jako šablony
|
||||||
|
.template-pick-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--ft-border-light);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-pick-quantity {
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-pick-calories {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 72px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zaškrtávátko v patičce dialogu stojí vlevo, před souhrnem a tlačítky
|
||||||
|
.modal-template-check {
|
||||||
|
margin: 0 16px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import Header from "./components/Header";
|
|||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import DayPage from "./pages/DayPage";
|
import DayPage from "./pages/DayPage";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
import { DEN_URL, STATISTIKY_URL } from "./routes";
|
import TemplatesPage from "./pages/TemplatesPage";
|
||||||
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
|
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -21,6 +23,8 @@ export default function AppRoutes() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path={DEN_URL} element={<DayPage />} />
|
<Route path={DEN_URL} element={<DayPage />} />
|
||||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||||
|
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
||||||
|
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+64
-4
@@ -182,14 +182,74 @@ export function formatQuantity(quantity: number, unitShort: string): string {
|
|||||||
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
|
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */
|
/**
|
||||||
export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined {
|
* Dopočte spálené kalorie z množství, počtu sérií a sazby na 100 jednotek.
|
||||||
|
* Musí dávat stejné výsledky jako `deriveActivityCalories` na serveru.
|
||||||
|
*/
|
||||||
|
export const deriveActivityCalories = (
|
||||||
|
quantity?: number,
|
||||||
|
caloriesPer100Units?: number,
|
||||||
|
sets?: number,
|
||||||
|
): number | undefined => {
|
||||||
if (quantity == null || !caloriesPer100Units) return undefined;
|
if (quantity == null || !caloriesPer100Units) return undefined;
|
||||||
return Math.round(quantity / 100 * caloriesPer100Units);
|
return Math.round(quantity * Math.max(sets ?? 1, 1) / 100 * caloriesPer100Units);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */
|
/** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */
|
||||||
export function formatBalance(kcal: number): string {
|
export function formatBalance(kcal: number): string {
|
||||||
const sign = kcal < 0 ? '−' : '+';
|
const sign = kcal < 0 ? '−' : '+';
|
||||||
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
|
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Naformátuje délku spánku z minut, např. 450 → "7 h 30 min". */
|
||||||
|
export const formatSleep = (minutes?: number): string => {
|
||||||
|
if (!minutes) return '—';
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rest = minutes % 60;
|
||||||
|
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Převede minuty na hodiny s jedním desetinným místem pro editační pole. */
|
||||||
|
export const formatSleepInput = (minutes?: number): string =>
|
||||||
|
minutes ? (minutes / 60).toFixed(1).replace('.', ',') : '';
|
||||||
|
|
||||||
|
/** Převede hodiny zadané uživatelem (i s desetinnou čárkou) na minuty. */
|
||||||
|
export const parseSleepInput = (value: string): number | undefined => {
|
||||||
|
const trimmed = value.trim().replace(',', '.');
|
||||||
|
if (!trimmed.length) return undefined;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 60) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Naformátuje hmotnost z gramů na kilogramy, např. 90000 → "90,0 kg". */
|
||||||
|
export const formatWeightKg = (grams?: number): string =>
|
||||||
|
grams == null ? '—' : `${(grams / 1000).toFixed(1).replace('.', ',')} kg`;
|
||||||
|
|
||||||
|
/** Převede kilogramy zadané uživatelem na gramy. */
|
||||||
|
export const parseWeightKgInput = (value: string): number | undefined => {
|
||||||
|
const trimmed = value.trim().replace(',', '.');
|
||||||
|
if (!trimmed.length) return undefined;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 1000) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Převede gramy na kilogramy pro editační pole. */
|
||||||
|
export const formatWeightKgInput = (grams?: number): string =>
|
||||||
|
grams == null ? '' : (grams / 1000).toFixed(1).replace('.', ',');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naformátuje množství aktivity i se sériemi.
|
||||||
|
*
|
||||||
|
* Bez sérií vrací "40 opak.", se sériemi "3 × 40 opak. (120)" — celkové množství
|
||||||
|
* v závorce, protože z něj vychází spálené kalorie, ale rozpad na série je to,
|
||||||
|
* co odlišuje tři série s pauzami od jedné dlouhé.
|
||||||
|
*/
|
||||||
|
export const formatQuantityWithSets = (quantity: number, unitShort: string, sets?: number): string => {
|
||||||
|
const one = formatQuantity(quantity, unitShort);
|
||||||
|
if (!sets || sets <= 1) return one;
|
||||||
|
return `${sets} × ${one} (${(quantity * sets).toLocaleString('cs-CZ')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vrátí celkové množství aktivity — počet sérií krát množství v jedné. */
|
||||||
|
export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined =>
|
||||||
|
quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ActivityEntry } from "../../../types";
|
||||||
|
import { ACTIVITY_UNIT_SHORT } from "../enums";
|
||||||
|
import { formatCalories, formatQuantityWithSets } from "../Utils";
|
||||||
|
import RowActions from "./RowActions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entry: ActivityEntry,
|
||||||
|
onEdit: () => void,
|
||||||
|
onDelete: () => void,
|
||||||
|
onSaveTemplate: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jeden řádek seznamu pohybových aktivit v přehledu dne. */
|
||||||
|
const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
||||||
|
<div className="meal-row">
|
||||||
|
<div className="meal-main">
|
||||||
|
<div className="meal-name">{entry.name}</div>
|
||||||
|
<div className="meal-meta">
|
||||||
|
<span className="tag">
|
||||||
|
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
||||||
|
</span>
|
||||||
|
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
||||||
|
</div>
|
||||||
|
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
||||||
|
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="aktivitu" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ActivityRow;
|
||||||
@@ -2,15 +2,16 @@ import { useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon, faGear, faUser } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import { DEN_URL, STATISTIKY_URL } from "../routes";
|
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
|
||||||
/** Položky hlavní navigace. */
|
/** Položky hlavní navigace. */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ url: DEN_URL, label: 'Můj den' },
|
{ url: DEN_URL, label: 'Můj den' },
|
||||||
|
{ url: SABLONY_URL, label: 'Šablony' },
|
||||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -53,7 +54,11 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(SABLONY_URL)}>Šablony tréninků</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(PROFIL_URL)}>
|
||||||
|
<FontAwesomeIcon icon={faUser} /> Osobní údaje
|
||||||
|
</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faGear} /> Nastavení
|
<FontAwesomeIcon icon={faGear} /> Nastavení
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MealEntry } from "../../../types";
|
||||||
|
import { formatCalories, formatPrice, formatWeight } from "../Utils";
|
||||||
|
import RowActions from "./RowActions";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entry: MealEntry,
|
||||||
|
onEdit: () => void,
|
||||||
|
onDelete: () => void,
|
||||||
|
onSaveTemplate: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||||
|
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
||||||
|
<div className="meal-row">
|
||||||
|
<div className="meal-main">
|
||||||
|
<div className="meal-name">{entry.name}</div>
|
||||||
|
<div className="meal-meta">
|
||||||
|
{entry.source && <span className="tag">{entry.source}</span>}
|
||||||
|
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
||||||
|
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
||||||
|
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
||||||
|
</div>
|
||||||
|
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||||
|
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MealRow;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPenToSquare, faTrash, faBookmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onEdit: () => void,
|
||||||
|
onDelete: () => void,
|
||||||
|
/** Když je vyplněné, přibude tlačítko pro uložení záznamu jako šablony. */
|
||||||
|
onSaveTemplate?: () => void,
|
||||||
|
/** Doplní se do popisku pro čtečky, např. "Upravit jídlo". */
|
||||||
|
label: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tlačítka pro úpravu a smazání řádku seznamu. */
|
||||||
|
const RowActions = ({ onEdit, onDelete, onSaveTemplate, label }: Readonly<Props>) => (
|
||||||
|
<div className="meal-actions">
|
||||||
|
{onSaveTemplate && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onSaveTemplate()}
|
||||||
|
title="Uložit jako šablonu"
|
||||||
|
aria-label={`Uložit ${label} jako šablonu`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label={`Upravit ${label}`}>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon danger" onClick={() => onDelete()} title="Smazat" aria-label={`Smazat ${label}`}>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RowActions;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, Form, Modal } from "react-bootstrap";
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
|
import {
|
||||||
|
ActivityEntry, ActivityUnit, DayOverview,
|
||||||
|
addActivity, createWorkoutTemplateFromActivities, updateActivity,
|
||||||
|
} from "../../../../types";
|
||||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
||||||
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
|
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ function emptyForm(date: string) {
|
|||||||
name: '',
|
name: '',
|
||||||
unit: ActivityUnit.KROKY as ActivityUnit,
|
unit: ActivityUnit.KROKY as ActivityUnit,
|
||||||
quantity: '',
|
quantity: '',
|
||||||
|
sets: '',
|
||||||
caloriesPer100Units: '',
|
caloriesPer100Units: '',
|
||||||
calories: '',
|
calories: '',
|
||||||
note: '',
|
note: '',
|
||||||
@@ -37,15 +41,18 @@ function emptyForm(date: string) {
|
|||||||
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||||
const [form, setForm] = useState(() => emptyForm(date));
|
const [form, setForm] = useState(() => emptyForm(date));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
setAlsoTemplate(false);
|
||||||
setForm(entry
|
setForm(entry
|
||||||
? {
|
? {
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
unit: entry.unit,
|
unit: entry.unit,
|
||||||
quantity: String(entry.quantity),
|
quantity: String(entry.quantity),
|
||||||
|
sets: entry.sets != null ? String(entry.sets) : '',
|
||||||
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
||||||
calories: entry.calories != null ? String(entry.calories) : '',
|
calories: entry.calories != null ? String(entry.calories) : '',
|
||||||
note: entry.note ?? '',
|
note: entry.note ?? '',
|
||||||
@@ -70,8 +77,9 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const quantity = parseIntInput(form.quantity);
|
const quantity = parseIntInput(form.quantity);
|
||||||
|
const sets = parseIntInput(form.sets);
|
||||||
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
|
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
|
||||||
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
|
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
|
||||||
const manualCalories = parseIntInput(form.calories);
|
const manualCalories = parseIntInput(form.calories);
|
||||||
const effectiveCalories = derivedCalories ?? manualCalories;
|
const effectiveCalories = derivedCalories ?? manualCalories;
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
unit: form.unit,
|
unit: form.unit,
|
||||||
quantity,
|
quantity,
|
||||||
|
sets,
|
||||||
caloriesPer100Units,
|
caloriesPer100Units,
|
||||||
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
||||||
calories: derivedCalories != null ? undefined : manualCalories,
|
calories: derivedCalories != null ? undefined : manualCalories,
|
||||||
@@ -93,6 +102,17 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
const response = entry
|
const response = entry
|
||||||
? await updateActivity({ body: { id: entry.id, ...body } })
|
? await updateActivity({ body: { id: entry.id, ...body } })
|
||||||
: await addActivity({ body });
|
: await addActivity({ body });
|
||||||
|
if (response.data && alsoTemplate) {
|
||||||
|
const saved = entry
|
||||||
|
? response.data.activities.entries.find(item => item.id === entry.id)
|
||||||
|
: [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||||
|
if (saved) {
|
||||||
|
// Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony
|
||||||
|
await createWorkoutTemplateFromActivities({
|
||||||
|
body: { date: saved.date, name: saved.name, ids: [saved.id] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
onSaved(response.data);
|
onSaved(response.data);
|
||||||
@@ -135,14 +155,26 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
<fieldset className="meal-fieldset mb-3">
|
<fieldset className="meal-fieldset mb-3">
|
||||||
<legend>Množství a spotřeba</legend>
|
<legend>Množství a spotřeba</legend>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<Form.Label>Série</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.sets}
|
||||||
|
placeholder="1"
|
||||||
|
onChange={event => update({ sets: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
<Form.Label>Množství</Form.Label>
|
<Form.Label>{(sets ?? 1) > 1 ? 'Na sérii' : 'Množství'}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.quantity}
|
value={form.quantity}
|
||||||
placeholder="10000"
|
placeholder="10000"
|
||||||
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
|
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
|
||||||
/>
|
/>
|
||||||
|
{(sets ?? 1) > 1 && quantity != null && (
|
||||||
|
<Form.Text>celkem {(quantity * sets!).toLocaleString('cs-CZ')}</Form.Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-3">
|
||||||
<Form.Label>Jednotka</Form.Label>
|
<Form.Label>Jednotka</Form.Label>
|
||||||
@@ -155,8 +187,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-2">
|
||||||
<Form.Label>Kcal na 100 jedn.</Form.Label>
|
<Form.Label>Kcal/100</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.caloriesPer100Units}
|
value={form.caloriesPer100Units}
|
||||||
@@ -164,7 +196,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-3">
|
<div className="col-sm-2">
|
||||||
<Form.Label>Spáleno</Form.Label>
|
<Form.Label>Spáleno</Form.Label>
|
||||||
{derivedCalories != null ? (
|
{derivedCalories != null ? (
|
||||||
<div className="derived-value">{formatCalories(derivedCalories)}</div>
|
<div className="derived-value">{formatCalories(derivedCalories)}</div>
|
||||||
@@ -195,6 +227,13 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
<Form.Check
|
||||||
|
className="modal-template-check"
|
||||||
|
id="activity-also-template"
|
||||||
|
checked={alsoTemplate}
|
||||||
|
label="Uložit i jako šablonu"
|
||||||
|
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||||
|
/>
|
||||||
<div className="meal-modal-summary">
|
<div className="meal-modal-summary">
|
||||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
||||||
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
|
import { deriveAmounts, formatCalories, formatPrice, formatWeight } from "../../Utils";
|
||||||
|
import { SABLONY_URL } from "../../routes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
/** Den, do kterého se jídlo založí. */
|
||||||
|
date: string,
|
||||||
|
onClose: () => void,
|
||||||
|
/** Zavolá se po založení jídla s aktualizovaným přehledem dne. */
|
||||||
|
onApplied: (day: DayOverview) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popíše šablonu jednou řádkou — gramáž, kalorie a cena, co je vyplněné. */
|
||||||
|
const describe = (template: MealTemplate): string => {
|
||||||
|
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||||
|
const calories = derived.calories ?? template.calories;
|
||||||
|
return [
|
||||||
|
template.source,
|
||||||
|
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
||||||
|
calories != null ? formatCalories(calories) : undefined,
|
||||||
|
template.price != null ? formatPrice(template.price) : undefined,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výběr šablony jídla k založení do dne.
|
||||||
|
*
|
||||||
|
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
|
||||||
|
* vybrat hotové jídlo. Chod lze při zakládání přepsat, protože totéž jídlo může
|
||||||
|
* být jednou oběd a podruhé večeře.
|
||||||
|
*/
|
||||||
|
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||||
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
|
const [mealType, setMealType] = useState<MealType | ''>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setLoading(true);
|
||||||
|
setMealType('');
|
||||||
|
getMealTemplates().then(response => {
|
||||||
|
setTemplates(response.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const apply = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await applyMealTemplate({
|
||||||
|
body: { id, date, ...(mealType ? { mealType } : {}) },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
onApplied(response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Použít šablonu jídla</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<Spinner animation="border" variant="success" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !templates.length && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||||
|
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||||
|
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !!templates.length && (
|
||||||
|
<>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Založit jako</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={mealType}
|
||||||
|
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||||
|
>
|
||||||
|
<option value="">— chod ze šablony —</option>
|
||||||
|
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
{templates.map(template => (
|
||||||
|
<div className="workout-row" key={template.id}>
|
||||||
|
<div className="workout-info">
|
||||||
|
<div className="workout-name">{template.name}</div>
|
||||||
|
<div className="workout-items">{describe(template)}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
||||||
|
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||||
|
>
|
||||||
|
Spravovat šablony
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplyMealModal;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Modal, Spinner } from "react-bootstrap";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
||||||
|
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||||
|
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||||
|
import { SABLONY_URL } from "../../routes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
/** Den, do kterého se šablona použije. */
|
||||||
|
date: string,
|
||||||
|
onClose: () => void,
|
||||||
|
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
|
||||||
|
onApplied: (day: DayOverview) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popíše položky šablony jednou řádkou. */
|
||||||
|
const describeItems = (template: WorkoutTemplate): string =>
|
||||||
|
template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||||
|
? (item.templateRefName ?? 'šablona')
|
||||||
|
: `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}`
|
||||||
|
).join(' · ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výběr šablony tréninku k založení do dne.
|
||||||
|
*
|
||||||
|
* Zakládání a úpravy šablon se řeší na vlastní stránce, sem chodí uživatel jen
|
||||||
|
* vybrat hotovou šablonu a rozbalit ji do dne.
|
||||||
|
*/
|
||||||
|
const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||||
|
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setLoading(true);
|
||||||
|
getWorkoutTemplates().then(response => {
|
||||||
|
setTemplates(response.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const apply = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await applyWorkoutTemplate({ body: { id, date } });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
onApplied(response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Použít šablonu tréninku</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<Spinner animation="border" variant="success" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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 Šablony.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && templates.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" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="workout-items">{describeItems(template)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||||
|
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
||||||
|
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
||||||
|
>
|
||||||
|
Spravovat šablony
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApplyWorkoutModal;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Form, Modal } from "react-bootstrap";
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
|
import {
|
||||||
|
DayRecord, MealEntry, MealType, UserSettings,
|
||||||
|
addMeal, createMealTemplateFromMeal, getSettings, updateMeal,
|
||||||
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
import {
|
import {
|
||||||
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
|
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
|
||||||
@@ -50,10 +53,12 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
const [form, setForm] = useState(() => emptyForm(date));
|
const [form, setForm] = useState(() => emptyForm(date));
|
||||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
||||||
|
|
||||||
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
setAlsoTemplate(false);
|
||||||
setForm(entry
|
setForm(entry
|
||||||
? {
|
? {
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
@@ -126,6 +131,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
const response = entry
|
const response = entry
|
||||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||||
: await addMeal({ body });
|
: await addMeal({ body });
|
||||||
|
if (response.data && alsoTemplate) {
|
||||||
|
// U nového jídla je ten správný záznam vždy ten poslední založený
|
||||||
|
const saved = entry
|
||||||
|
? response.data.entries.find(item => item.id === entry.id)
|
||||||
|
: [...response.data.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||||
|
if (saved) {
|
||||||
|
await createMealTemplateFromMeal({ body: { id: saved.id, date: saved.date } });
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
onSaved(response.data);
|
onSaved(response.data);
|
||||||
@@ -295,6 +309,13 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
<Form.Check
|
||||||
|
className="modal-template-check"
|
||||||
|
id="meal-also-template"
|
||||||
|
checked={alsoTemplate}
|
||||||
|
label="Uložit i jako šablonu"
|
||||||
|
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||||
|
/>
|
||||||
<div className="meal-modal-summary">
|
<div className="meal-modal-summary">
|
||||||
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
||||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Form, Modal } from "react-bootstrap";
|
||||||
|
import { ActivityEntry, createWorkoutTemplateFromActivities } from "../../../../types";
|
||||||
|
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||||
|
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
/** Den, ze kterého se aktivity berou. */
|
||||||
|
date: string,
|
||||||
|
/** Aktivity daného dne, ze kterých lze vybírat. */
|
||||||
|
activities: ActivityEntry[],
|
||||||
|
/** Aktivity zaškrtnuté při otevření. Bez nich se zaškrtnou všechny. */
|
||||||
|
preselected?: string[],
|
||||||
|
onClose: () => void,
|
||||||
|
/** Zavolá se po úspěšném vytvoření šablony. */
|
||||||
|
onSaved: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uložení aktivit dne jako šablony tréninku.
|
||||||
|
*
|
||||||
|
* Opačný směr než použití šablony: odcvičený trénink jde takhle povýšit na
|
||||||
|
* šablonu a příště ho založit jedním kliknutím. Aktivity ve dni zůstanou.
|
||||||
|
*/
|
||||||
|
const SaveWorkoutTemplateModal = ({
|
||||||
|
isOpen, date, activities, preselected, onClose, onSaved,
|
||||||
|
}: Readonly<Props>) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const initial = preselected?.length ? preselected : activities.map(entry => entry.id);
|
||||||
|
setSelected(initial);
|
||||||
|
setError(undefined);
|
||||||
|
// U jediného cviku se jako název nabídne jeho jméno, jinak nechá uživatel na sobě
|
||||||
|
setName(initial.length === 1
|
||||||
|
? (activities.find(entry => entry.id === initial[0])?.name ?? '')
|
||||||
|
: '');
|
||||||
|
}, [isOpen, activities, preselected]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => setSelected(current =>
|
||||||
|
current.includes(id) ? current.filter(item => item !== id) : [...current, id]);
|
||||||
|
|
||||||
|
const canSave = name.trim().length > 0 && selected.length > 0 && !saving;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(undefined);
|
||||||
|
const response = await createWorkoutTemplateFromActivities({
|
||||||
|
body: { date, name: name.trim(), ids: selected },
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (response.data) {
|
||||||
|
onSaved();
|
||||||
|
} else {
|
||||||
|
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Uložit jako šablonu tréninku</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.Control
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
placeholder="Např. Workout day 3"
|
||||||
|
onChange={event => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Label>Zahrnout cviky</Form.Label>
|
||||||
|
{activities.map(entry => (
|
||||||
|
<div className="template-pick-row" key={entry.id}>
|
||||||
|
<Form.Check
|
||||||
|
id={`pick-${entry.id}`}
|
||||||
|
checked={selected.includes(entry.id)}
|
||||||
|
label={entry.name}
|
||||||
|
onChange={() => toggle(entry.id)}
|
||||||
|
/>
|
||||||
|
<span className="template-pick-quantity">
|
||||||
|
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
||||||
|
</span>
|
||||||
|
<span className="template-pick-calories">{formatCalories(entry.calories)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button>
|
||||||
|
<Button onClick={() => save()} disabled={!canSave}>
|
||||||
|
{saving ? 'Ukládám…' : 'Vytvořit šablonu'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SaveWorkoutTemplateModal;
|
||||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Form, Modal } from "react-bootstrap";
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
|
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
|
||||||
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
|
import { formatPriceInput, parsePriceInput } from "../../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -20,17 +20,13 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
|||||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
const [newSource, setNewSource] = useState('');
|
const [newSource, setNewSource] = useState('');
|
||||||
const [newRate, setNewRate] = useState('');
|
const [newRate, setNewRate] = useState('');
|
||||||
const [basal, setBasal] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setNewSource('');
|
setNewSource('');
|
||||||
setNewRate('');
|
setNewRate('');
|
||||||
getSettings().then(response => {
|
getSettings().then(response => setSettings(response.data));
|
||||||
setSettings(response.data);
|
|
||||||
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
|
|
||||||
});
|
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const save = async (source: string, pricePer100g: number | null) => {
|
const save = async (source: string, pricePer100g: number | null) => {
|
||||||
@@ -42,16 +38,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
|
|
||||||
const saveBasal = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
|
|
||||||
setSaving(false);
|
|
||||||
if (response.data) {
|
|
||||||
setSettings(response.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRate = async () => {
|
const addRate = async () => {
|
||||||
const rate = parsePriceInput(newRate);
|
const rate = parsePriceInput(newRate);
|
||||||
if (!newSource.trim().length || !rate) return;
|
if (!newSource.trim().length || !rate) return;
|
||||||
@@ -66,24 +52,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
|||||||
<Modal.Title>Nastavení</Modal.Title>
|
<Modal.Title>Nastavení</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<h3 className="settings-section-title">Klidový výdej</h3>
|
|
||||||
<p className="text-body-secondary small">
|
|
||||||
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
|
|
||||||
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
|
|
||||||
</p>
|
|
||||||
<div className="rate-form mb-4">
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={basal}
|
|
||||||
placeholder="např. 1600"
|
|
||||||
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">kcal / den</span>
|
|
||||||
</div>
|
|
||||||
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="settings-section-title">Cena za 100 g</h3>
|
<h3 className="settings-section-title">Cena za 100 g</h3>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button, Form, Modal } from "react-bootstrap";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import {
|
|
||||||
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
|
|
||||||
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
|
||||||
} from "../../../../types";
|
|
||||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
|
|
||||||
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean,
|
|
||||||
/** Den, do kterého se šablona použije. */
|
|
||||||
date: string,
|
|
||||||
onClose: () => void,
|
|
||||||
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
|
|
||||||
onApplied: (day: DayOverview) => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
|
|
||||||
type DraftItem = {
|
|
||||||
name: string,
|
|
||||||
unit: ActivityUnit,
|
|
||||||
quantity: string,
|
|
||||||
caloriesPer100Units: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
function emptyItem(): DraftItem {
|
|
||||||
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Správa šablon tréninků a jejich použití na den.
|
|
||||||
*
|
|
||||||
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
|
|
||||||
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
|
|
||||||
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
|
|
||||||
*/
|
|
||||||
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
|
|
||||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
|
||||||
const [editedId, setEditedId] = useState<string | undefined>();
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setEditedId(undefined);
|
|
||||||
setName('');
|
|
||||||
setItems([emptyItem()]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
resetForm();
|
|
||||||
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const updateItem = (index: number, changes: Partial<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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Převede rozpracované položky na ty, které jdou uložit. */
|
|
||||||
const toItems = (): WorkoutTemplateItem[] => items
|
|
||||||
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
|
|
||||||
.map(item => ({
|
|
||||||
name: item.name.trim(),
|
|
||||||
unit: item.unit,
|
|
||||||
quantity: parseIntInput(item.quantity)!,
|
|
||||||
...(parseIntInput(item.caloriesPer100Units) != null
|
|
||||||
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!canSave) return;
|
|
||||||
setBusy(true);
|
|
||||||
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
|
|
||||||
setBusy(false);
|
|
||||||
if (response.data) {
|
|
||||||
setTemplates(response.data);
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
setBusy(true);
|
|
||||||
const response = await deleteWorkoutTemplate({ body: { id } });
|
|
||||||
setBusy(false);
|
|
||||||
if (response.data) {
|
|
||||||
setTemplates(response.data);
|
|
||||||
if (editedId === id) resetForm();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const apply = async (id: string) => {
|
|
||||||
setBusy(true);
|
|
||||||
const response = await applyWorkoutTemplate({ body: { id, date } });
|
|
||||||
setBusy(false);
|
|
||||||
if (response.data) {
|
|
||||||
onApplied(response.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Načte šablonu do formuláře k úpravě. */
|
|
||||||
const edit = (template: WorkoutTemplate) => {
|
|
||||||
setEditedId(template.id);
|
|
||||||
setName(template.name);
|
|
||||||
setItems(template.items.map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
unit: item.unit,
|
|
||||||
quantity: String(item.quantity),
|
|
||||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
|
||||||
})));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Průběžný odhad kalorií rozpracované šablony. */
|
|
||||||
const draftCalories = items.reduce((sum, item) =>
|
|
||||||
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title>Šablony tréninků</Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{templates.length === 0 && (
|
|
||||||
<p className="text-body-secondary small fst-italic">
|
|
||||||
Zatím nemáte žádnou šablonu. Sestavte si ji níže — příště pak stačí jedno kliknutí.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templates.map(template => (
|
|
||||||
<div className="workout-row" key={template.id}>
|
|
||||||
<div className="workout-info">
|
|
||||||
<div className="workout-name">{template.name}</div>
|
|
||||||
<div className="workout-items">
|
|
||||||
{template.items.map(item =>
|
|
||||||
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
|
|
||||||
).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
|
||||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
|
|
||||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
|
||||||
</Button>
|
|
||||||
<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="Smazat"
|
|
||||||
aria-label="Smazat šablonu"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<fieldset className="meal-fieldset mt-3">
|
|
||||||
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
|
|
||||||
|
|
||||||
<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}>
|
|
||||||
<Form.Control
|
|
||||||
list="activity-presets-workout"
|
|
||||||
value={item.name}
|
|
||||||
placeholder="Cvik"
|
|
||||||
onChange={event => applyPreset(index, event.target.value)}
|
|
||||||
/>
|
|
||||||
<Form.Control
|
|
||||||
className="workout-item-quantity"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={item.quantity}
|
|
||||||
placeholder="60"
|
|
||||||
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, '') })}
|
|
||||||
/>
|
|
||||||
<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-workout">
|
|
||||||
{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, emptyItem()])}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faPlus} /> Další cvik
|
|
||||||
</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>
|
|
||||||
</fieldset>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+46
-3
@@ -1,4 +1,4 @@
|
|||||||
import { ActivityUnit, MealType } from "../../types";
|
import { ActivityUnit, MealType, Pohlavi } from "../../types";
|
||||||
|
|
||||||
/** Lidsky čitelné názvy typů jídel. */
|
/** Lidsky čitelné názvy typů jídel. */
|
||||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||||
@@ -38,12 +38,12 @@ export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
|
|||||||
OPAKOVANI: 'opakování',
|
OPAKOVANI: 'opakování',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Zkratka jednotky za číslo, např. "10 000 kroků". */
|
/** Zkratka jednotky za číslo, např. "10 000 kroků". U opakování nesmí být '×', kolidovalo by se zápisem sérií. */
|
||||||
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
|
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
|
||||||
KROKY: 'kroků',
|
KROKY: 'kroků',
|
||||||
MINUTY: 'min',
|
MINUTY: 'min',
|
||||||
KM: 'km',
|
KM: 'km',
|
||||||
OPAKOVANI: '×',
|
OPAKOVANI: 'opak.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Jednotky v pořadí, v jakém se nabízejí. */
|
/** Jednotky v pořadí, v jakém se nabízejí. */
|
||||||
@@ -71,3 +71,46 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
|
|||||||
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
|
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
|
||||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Záložky přehledu dne. */
|
||||||
|
export enum DayTab {
|
||||||
|
PRIJEM = 'PRIJEM',
|
||||||
|
VYDEJ = 'VYDEJ',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DAY_TAB_NAMES: Record<DayTab, string> = {
|
||||||
|
PRIJEM: 'Příjem',
|
||||||
|
VYDEJ: 'Výdej',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rozsahy, za které lze zobrazit statistiky. */
|
||||||
|
export enum StatsRange {
|
||||||
|
MESIC = 'MESIC',
|
||||||
|
ROK = 'ROK',
|
||||||
|
VSE = 'VSE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATS_RANGE_NAMES: Record<StatsRange, string> = {
|
||||||
|
MESIC: 'Měsíc',
|
||||||
|
ROK: 'Rok',
|
||||||
|
VSE: 'Vše',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Lidsky čitelné názvy pohlaví. */
|
||||||
|
export const POHLAVI_NAMES: Record<Pohlavi, string> = {
|
||||||
|
MUZ: 'Muž',
|
||||||
|
ZENA: 'Žena',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POHLAVI_IN_ORDER: Pohlavi[] = [Pohlavi.MUZ, Pohlavi.ZENA];
|
||||||
|
|
||||||
|
/** Záložky stránky se šablonami. */
|
||||||
|
export enum TemplateTab {
|
||||||
|
TRENINKY = 'TRENINKY',
|
||||||
|
JIDLA = 'JIDLA',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
||||||
|
TRENINKY: 'Tréninky',
|
||||||
|
JIDLA: 'Jídla',
|
||||||
|
};
|
||||||
|
|||||||
@@ -198,3 +198,36 @@
|
|||||||
.meal-price.burn {
|
.meal-price.burn {
|
||||||
color: var(--ft-warning);
|
color: var(--ft-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spánek se zadává přímo v dlaždici, ať to není další dialog
|
||||||
|
.sleep-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.sleep-input {
|
||||||
|
width: 84px;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: auto;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px dashed var(--ft-border);
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--ft-text);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-bottom-color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sleep-unit {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+117
-90
@@ -1,26 +1,28 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Spinner } from "react-bootstrap";
|
import { Alert, Button, Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
|
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
|
||||||
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
|
faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
|
|
||||||
import {
|
import {
|
||||||
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
||||||
} from "../enums";
|
deleteActivity, deleteMeal, getDayOverview, saveSleep,
|
||||||
|
} from "../../../types";
|
||||||
|
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
import {
|
import {
|
||||||
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
|
formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
||||||
getDayOfWeek, getHumanDate, shiftDate,
|
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
||||||
} from "../Utils";
|
} from "../Utils";
|
||||||
import MealModal from "../components/modals/MealModal";
|
import MealModal from "../components/modals/MealModal";
|
||||||
import ActivityModal from "../components/modals/ActivityModal";
|
import ActivityModal from "../components/modals/ActivityModal";
|
||||||
import WorkoutModal from "../components/modals/WorkoutModal";
|
import ApplyWorkoutModal from "../components/modals/ApplyWorkoutModal";
|
||||||
|
import ApplyMealModal from "../components/modals/ApplyMealModal";
|
||||||
|
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
|
||||||
|
import MealRow from "../components/MealRow";
|
||||||
|
import ActivityRow from "../components/ActivityRow";
|
||||||
import "./DayPage.scss";
|
import "./DayPage.scss";
|
||||||
|
|
||||||
/** Záložky dne — co do těla přišlo a co se vydalo. */
|
|
||||||
type Tab = 'prijem' | 'vydej';
|
|
||||||
|
|
||||||
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
|
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
|
||||||
function emptyOverview(date: string): DayOverview {
|
function emptyOverview(date: string): DayOverview {
|
||||||
return {
|
return {
|
||||||
@@ -34,13 +36,18 @@ function emptyOverview(date: string): DayOverview {
|
|||||||
export default function DayPage() {
|
export default function DayPage() {
|
||||||
const [date, setDate] = useState<string>(() => formatDate(new Date()));
|
const [date, setDate] = useState<string>(() => formatDate(new Date()));
|
||||||
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
|
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
|
||||||
const [tab, setTab] = useState<Tab>('prijem');
|
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
|
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
|
||||||
const [mealModalOpen, setMealModalOpen] = useState(false);
|
const [mealModalOpen, setMealModalOpen] = useState(false);
|
||||||
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
||||||
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
||||||
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
||||||
|
const [mealTemplateModalOpen, setMealTemplateModalOpen] = useState(false);
|
||||||
|
const [sleepInput, setSleepInput] = useState('');
|
||||||
|
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
||||||
|
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
||||||
|
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||||
|
|
||||||
const loadDay = useCallback(async (forDate: string) => {
|
const loadDay = useCallback(async (forDate: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -51,6 +58,17 @@ export default function DayPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadDay(date); }, [date, loadDay]);
|
useEffect(() => { loadDay(date); }, [date, loadDay]);
|
||||||
|
|
||||||
|
// Pole spánku se plní z načteného dne; uživatelovu rozepsanou hodnotu přepíše jen při změně dne
|
||||||
|
useEffect(() => { setSleepInput(formatSleepInput(day.sleepMinutes)); }, [day.date, day.sleepMinutes]);
|
||||||
|
|
||||||
|
/** Uloží spánek při opuštění pole — průběžné ukládání po každé číslici nedává smysl. */
|
||||||
|
const commitSleep = async () => {
|
||||||
|
const minutes = parseSleepInput(sleepInput) ?? null;
|
||||||
|
if (minutes === (day.sleepMinutes ?? null)) return;
|
||||||
|
const response = await saveSleep({ body: { date, sleepMinutes: minutes } });
|
||||||
|
if (response.data) setDay(response.data);
|
||||||
|
};
|
||||||
|
|
||||||
const isToday = date === formatDate(new Date());
|
const isToday = date === formatDate(new Date());
|
||||||
|
|
||||||
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
||||||
@@ -69,6 +87,25 @@ export default function DayPage() {
|
|||||||
loadDay(date);
|
loadDay(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Krátká hláška o úspěchu; sama zmizí, ať nepřekáží. */
|
||||||
|
const showNotice = (text: string) => {
|
||||||
|
setSavedNotice(text);
|
||||||
|
setTimeout(() => setSavedNotice(undefined), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMealAsTemplate = async (entry: MealEntry) => {
|
||||||
|
const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } });
|
||||||
|
if (response.data) {
|
||||||
|
showNotice(`„${entry.name}" uloženo jako šablonu jídla.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Otevře dialog pro uložení tréninku, se zaškrtnutou jednou aktivitou nebo všemi. */
|
||||||
|
const openSaveWorkout = (entry?: ActivityEntry) => {
|
||||||
|
setPreselectedActivities(entry ? [entry.id] : []);
|
||||||
|
setSaveWorkoutOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const removeActivity = async (entry: ActivityEntry) => {
|
const removeActivity = async (entry: ActivityEntry) => {
|
||||||
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
|
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
|
||||||
if (response.data) setDay(response.data);
|
if (response.data) setDay(response.data);
|
||||||
@@ -78,12 +115,22 @@ export default function DayPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Můj den</h1>
|
<h1>Můj den</h1>
|
||||||
{tab === 'prijem' ? (
|
{tab === DayTab.PRIJEM ? (
|
||||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
<div className="header-actions">
|
||||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
||||||
</Button>
|
<FontAwesomeIcon icon={faBowlFood} /> Šablony
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||||
|
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
{!!day.activities.entries.length && (
|
||||||
|
<Button variant="outline-secondary" onClick={() => openSaveWorkout()}>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} /> Uložit jako šablonu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faDumbbell} /> Šablony
|
<FontAwesomeIcon icon={faDumbbell} /> Šablony
|
||||||
</Button>
|
</Button>
|
||||||
@@ -156,21 +203,41 @@ export default function DayPage() {
|
|||||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
|
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
|
||||||
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
|
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faBed} /> Spánek</div>
|
||||||
|
<div className="sleep-input-row">
|
||||||
|
<input
|
||||||
|
className="form-control sleep-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={sleepInput}
|
||||||
|
placeholder="7,5"
|
||||||
|
aria-label="Délka spánku v hodinách"
|
||||||
|
onChange={event => setSleepInput(event.target.value)}
|
||||||
|
onBlur={() => commitSleep()}
|
||||||
|
/>
|
||||||
|
<span className="sleep-unit">h</span>
|
||||||
|
</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
||||||
|
|
||||||
<div className="day-tabs">
|
<div className="day-tabs">
|
||||||
<button
|
<button
|
||||||
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
|
className={`day-tab ${tab === DayTab.PRIJEM ? 'active' : ''}`}
|
||||||
onClick={() => setTab('prijem')}
|
onClick={() => setTab(DayTab.PRIJEM)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUtensils} /> Příjem
|
<FontAwesomeIcon icon={faUtensils} /> {DAY_TAB_NAMES[DayTab.PRIJEM]}
|
||||||
<span className="day-tab-count">{day.meals.entries.length}</span>
|
<span className="day-tab-count">{day.meals.entries.length}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
|
className={`day-tab ${tab === DayTab.VYDEJ ? 'active' : ''}`}
|
||||||
onClick={() => setTab('vydej')}
|
onClick={() => setTab(DayTab.VYDEJ)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPersonRunning} /> Výdej
|
<FontAwesomeIcon icon={faPersonRunning} /> {DAY_TAB_NAMES[DayTab.VYDEJ]}
|
||||||
<span className="day-tab-count">{day.activities.entries.length}</span>
|
<span className="day-tab-count">{day.activities.entries.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +248,7 @@ export default function DayPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && tab === 'prijem' && (
|
{!loading && tab === DayTab.PRIJEM && (
|
||||||
<>
|
<>
|
||||||
{!day.meals.entries.length && (
|
{!day.meals.entries.length && (
|
||||||
<div className="card-panel empty-state">
|
<div className="card-panel empty-state">
|
||||||
@@ -208,6 +275,7 @@ export default function DayPage() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||||
onDelete={() => removeMeal(entry)}
|
onDelete={() => removeMeal(entry)}
|
||||||
|
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +284,7 @@ export default function DayPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && tab === 'vydej' && (
|
{!loading && tab === DayTab.VYDEJ && (
|
||||||
<>
|
<>
|
||||||
{!day.activities.entries.length && (
|
{!day.activities.entries.length && (
|
||||||
<div className="card-panel empty-state">
|
<div className="card-panel empty-state">
|
||||||
@@ -235,6 +303,7 @@ export default function DayPage() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
||||||
onDelete={() => removeActivity(entry)}
|
onDelete={() => removeActivity(entry)}
|
||||||
|
onSaveTemplate={() => openSaveWorkout(entry)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +342,29 @@ export default function DayPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkoutModal
|
<SaveWorkoutTemplateModal
|
||||||
|
isOpen={saveWorkoutOpen}
|
||||||
|
date={date}
|
||||||
|
activities={day.activities.entries}
|
||||||
|
preselected={preselectedActivities}
|
||||||
|
onClose={() => setSaveWorkoutOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setSaveWorkoutOpen(false);
|
||||||
|
showNotice('Šablona tréninku vytvořena.');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApplyMealModal
|
||||||
|
isOpen={mealTemplateModalOpen}
|
||||||
|
date={date}
|
||||||
|
onClose={() => setMealTemplateModalOpen(false)}
|
||||||
|
onApplied={saved => {
|
||||||
|
setDay(saved);
|
||||||
|
setMealTemplateModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApplyWorkoutModal
|
||||||
isOpen={workoutModalOpen}
|
isOpen={workoutModalOpen}
|
||||||
date={date}
|
date={date}
|
||||||
onClose={() => setWorkoutModalOpen(false)}
|
onClose={() => setWorkoutModalOpen(false)}
|
||||||
@@ -285,67 +376,3 @@ export default function DayPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type MealRowProps = {
|
|
||||||
entry: MealEntry,
|
|
||||||
onEdit: () => void,
|
|
||||||
onDelete: () => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Jeden řádek seznamu jídel. */
|
|
||||||
function MealRow({ entry, onEdit, onDelete }: Readonly<MealRowProps>) {
|
|
||||||
return (
|
|
||||||
<div className="meal-row">
|
|
||||||
<div className="meal-main">
|
|
||||||
<div className="meal-name">{entry.name}</div>
|
|
||||||
<div className="meal-meta">
|
|
||||||
{entry.source && <span className="tag">{entry.source}</span>}
|
|
||||||
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
|
||||||
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
|
||||||
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
|
||||||
</div>
|
|
||||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
|
||||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivityRowProps = {
|
|
||||||
entry: ActivityEntry,
|
|
||||||
onEdit: () => void,
|
|
||||||
onDelete: () => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Jeden řádek seznamu aktivit. */
|
|
||||||
function ActivityRow({ entry, onEdit, onDelete }: Readonly<ActivityRowProps>) {
|
|
||||||
return (
|
|
||||||
<div className="meal-row">
|
|
||||||
<div className="meal-main">
|
|
||||||
<div className="meal-name">{entry.name}</div>
|
|
||||||
<div className="meal-meta">
|
|
||||||
<span className="tag">{formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])}</span>
|
|
||||||
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
|
||||||
</div>
|
|
||||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
|
||||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="aktivitu" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tlačítka pro úpravu a smazání řádku. */
|
|
||||||
function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) {
|
|
||||||
return (
|
|
||||||
<div className="meal-actions">
|
|
||||||
<button className="btn-icon" onClick={onEdit} title="Upravit" aria-label={`Upravit ${label}`}>
|
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
|
||||||
</button>
|
|
||||||
<button className="btn-icon danger" onClick={onDelete} title="Smazat" aria-label={`Smazat ${label}`}>
|
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faTrash, faPenToSquare, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { MealTemplate, MealType, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../../../types";
|
||||||
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
|
import {
|
||||||
|
deriveAmounts, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
||||||
|
parseIntInput, parsePriceInput,
|
||||||
|
} from "../Utils";
|
||||||
|
|
||||||
|
/** Prázdný formulář nové šablony jídla. */
|
||||||
|
const emptyForm = () => ({
|
||||||
|
name: '',
|
||||||
|
source: '',
|
||||||
|
mealType: '' as MealType | '',
|
||||||
|
weight: '',
|
||||||
|
caloriesPer100g: '',
|
||||||
|
calories: '',
|
||||||
|
price: '',
|
||||||
|
pricePer100g: '',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel se správou šablon jídel.
|
||||||
|
*
|
||||||
|
* Na rozdíl od šablon tréninků se neskládají z jiných — jsou to ploché záznamy.
|
||||||
|
* Poznámka slouží i na postup přípravy u domácích jídel; strukturované suroviny
|
||||||
|
* mohou přibýt později, aniž by se model musel měnit.
|
||||||
|
*/
|
||||||
|
const MealTemplatesPanel = () => {
|
||||||
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editedId, setEditedId] = useState<string | undefined>();
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getMealTemplates();
|
||||||
|
setTemplates(response.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const update = (changes: Partial<ReturnType<typeof emptyForm>>) =>
|
||||||
|
setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditedId(undefined);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = form.name.trim().length > 0 && !busy;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
const response = await saveMealTemplate({
|
||||||
|
body: {
|
||||||
|
...(editedId ? { id: editedId } : {}),
|
||||||
|
name: form.name.trim(),
|
||||||
|
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||||
|
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||||
|
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||||
|
weight: parseIntInput(form.weight),
|
||||||
|
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||||
|
calories: parseIntInput(form.calories),
|
||||||
|
price: parsePriceInput(form.price),
|
||||||
|
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
setTemplates(response.data);
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await deleteMealTemplate({ body: { id } });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
setTemplates(response.data);
|
||||||
|
if (editedId === id) resetForm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = (template: MealTemplate) => {
|
||||||
|
setEditedId(template.id);
|
||||||
|
setError(undefined);
|
||||||
|
setForm({
|
||||||
|
name: template.name,
|
||||||
|
source: template.source ?? '',
|
||||||
|
mealType: template.mealType ?? '',
|
||||||
|
weight: template.weight != null ? String(template.weight) : '',
|
||||||
|
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||||
|
calories: template.calories != null ? String(template.calories) : '',
|
||||||
|
price: formatPriceInput(template.price),
|
||||||
|
pricePer100g: formatPriceInput(template.pricePer100g),
|
||||||
|
note: template.note ?? '',
|
||||||
|
});
|
||||||
|
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Živý náhled toho, co šablona při použití založí. */
|
||||||
|
const preview = deriveAmounts(
|
||||||
|
parsePriceInput(form.price),
|
||||||
|
parsePriceInput(form.pricePer100g),
|
||||||
|
parseIntInput(form.weight),
|
||||||
|
parseIntInput(form.caloriesPer100g));
|
||||||
|
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-body-secondary small">
|
||||||
|
Šablona jídla je opakovaně jedené jídlo, které v přehledu dne založíte jedním
|
||||||
|
kliknutím. Stačí název; gramáž, kalorie a cena se vyplňují podle toho, co o jídle
|
||||||
|
víte. U domácích jídel jde do poznámky zapsat i postup přípravy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<Spinner animation="border" variant="success" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !templates.length && (
|
||||||
|
<div className="card-panel empty-state">
|
||||||
|
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||||
|
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||||
|
<p className="empty-description">Sestavte si ji ve formuláři níže.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && templates.map(template => {
|
||||||
|
const derived = deriveAmounts(
|
||||||
|
template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||||
|
const calories = derived.calories ?? template.calories;
|
||||||
|
return (
|
||||||
|
<div className="card-panel template-card" key={template.id}>
|
||||||
|
<div className="template-head">
|
||||||
|
<div className="template-title">
|
||||||
|
{template.name}
|
||||||
|
{template.mealType && (
|
||||||
|
<span className="tag">
|
||||||
|
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{template.source && <span className="tag">{template.source}</span>}
|
||||||
|
{derived.weight != null && <span className="tag">{formatWeight(derived.weight)}</span>}
|
||||||
|
{template.note && <span className="tag">s postupem</span>}
|
||||||
|
</div>
|
||||||
|
{calories != null && (
|
||||||
|
<span className="template-calories">{formatCalories(calories)}</span>
|
||||||
|
)}
|
||||||
|
{template.price != null && (
|
||||||
|
<span className="meal-template-price">{formatPrice(template.price)}</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="Smazat"
|
||||||
|
aria-label="Smazat šablonu"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{template.note && <p className="meal-template-note">{template.note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="card-panel template-form">
|
||||||
|
<h2 className="chart-title">{editedId ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Název</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={form.name}
|
||||||
|
placeholder="Např. Těstovinový salát"
|
||||||
|
onChange={event => update({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Zdroj</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={form.source}
|
||||||
|
placeholder="Chefie, doma…"
|
||||||
|
onChange={event => update({ source: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Chod</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={form.mealType}
|
||||||
|
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
||||||
|
>
|
||||||
|
<option value="">— vybrat při použití —</option>
|
||||||
|
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="meal-fieldset mb-3">
|
||||||
|
<legend>Kalorie, gramáž a cena</legend>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Gramáž</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.weight}
|
||||||
|
placeholder="350"
|
||||||
|
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Energie na 100 g</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.caloriesPer100g}
|
||||||
|
placeholder="160"
|
||||||
|
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kcal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Cena</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.price}
|
||||||
|
placeholder="129,00"
|
||||||
|
onChange={event => update({ price: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">Kč</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Cena za 100 g</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.pricePer100g}
|
||||||
|
placeholder="volitelné"
|
||||||
|
onChange={event => update({ pricePer100g: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">Kč</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kalorie celé porce dávají smysl jen tam, kde se nedají dopočítat z gramáže */}
|
||||||
|
{preview.calories == null && (
|
||||||
|
<div className="row g-3 mt-0">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Kalorie porce</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.calories}
|
||||||
|
placeholder="volitelné"
|
||||||
|
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kcal</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Poznámka / postup</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
rows={3}
|
||||||
|
value={form.note}
|
||||||
|
placeholder="U domácích jídel sem lze zapsat recept…"
|
||||||
|
onChange={event => update({ note: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<div className="workout-form-footer">
|
||||||
|
{previewCalories != null && (
|
||||||
|
<span className="workout-draft-calories">
|
||||||
|
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
||||||
|
{formatCalories(previewCalories)}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealTemplatesPanel;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faFire, faCalculator } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Pohlavi, UserSettings, getSettings, saveBasalCalories, saveProfile } from "../../../types";
|
||||||
|
import { POHLAVI_IN_ORDER, POHLAVI_NAMES } from "../enums";
|
||||||
|
import { formatCalories, formatWeightKgInput, parseIntInput, parseWeightKgInput } from "../Utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Osobní údaje a klidový výdej.
|
||||||
|
*
|
||||||
|
* Z pohlaví, věku, výšky a hmotnosti se rovnicí Mifflin–St Jeor spočítá bazální
|
||||||
|
* metabolismus, který pak vstupuje do bilance dne. Kdo má změřenou vlastní
|
||||||
|
* hodnotu, může ji zadat ručně a ta má přednost.
|
||||||
|
*/
|
||||||
|
const ProfilePage = () => {
|
||||||
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sex, setSex] = useState('');
|
||||||
|
const [birthYear, setBirthYear] = useState('');
|
||||||
|
const [heightCm, setHeightCm] = useState('');
|
||||||
|
const [weightKg, setWeightKg] = useState('');
|
||||||
|
const [manualBasal, setManualBasal] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
/** Naplní formulář z nastavení vráceného serverem. */
|
||||||
|
const fillForm = (data?: UserSettings) => {
|
||||||
|
setSettings(data);
|
||||||
|
setSex(data?.profile?.sex ?? '');
|
||||||
|
setBirthYear(data?.profile?.birthYear != null ? String(data.profile.birthYear) : '');
|
||||||
|
setHeightCm(data?.profile?.heightCm != null ? String(data.profile.heightCm) : '');
|
||||||
|
setWeightKg(formatWeightKgInput(data?.profile?.weightGrams));
|
||||||
|
setManualBasal(data?.basalCalories != null ? String(data.basalCalories) : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getSettings();
|
||||||
|
fillForm(response.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const saveAll = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(undefined);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
const profileResponse = await saveProfile({
|
||||||
|
body: {
|
||||||
|
...(sex ? { sex: sex as Pohlavi } : {}),
|
||||||
|
...(parseIntInput(birthYear) != null ? { birthYear: parseIntInput(birthYear) } : {}),
|
||||||
|
...(parseIntInput(heightCm) != null ? { heightCm: parseIntInput(heightCm) } : {}),
|
||||||
|
...(parseWeightKgInput(weightKg) != null ? { weightGrams: parseWeightKgInput(weightKg) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!profileResponse.data) {
|
||||||
|
setSaving(false);
|
||||||
|
setError((profileResponse.error as { error?: string })?.error ?? 'Údaje se nepodařilo uložit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruční klidový výdej se ukládá zvlášť; prázdné pole ho odstraní
|
||||||
|
const basalResponse = await saveBasalCalories({ body: { basalCalories: parseIntInput(manualBasal) ?? null } });
|
||||||
|
setSaving(false);
|
||||||
|
if (!basalResponse.data) {
|
||||||
|
setError((basalResponse.error as { error?: string })?.error ?? 'Klidový výdej se nepodařilo uložit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillForm(basalResponse.data);
|
||||||
|
setSaved(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<Spinner animation="border" variant="success" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const computed = settings?.computedBasalCalories;
|
||||||
|
const effective = settings?.effectiveBasalCalories;
|
||||||
|
const manualWins = settings?.basalCalories != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Osobní údaje</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-body-secondary small">
|
||||||
|
Z těchto údajů se spočítá klidový výdej (bazální metabolismus) rovnicí
|
||||||
|
Mifflin–St Jeor. Ten pak v přehledu dne tvoří základ výdeje, ke kterému
|
||||||
|
se přičítá zaznamenaný pohyb.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="card-panel mb-3">
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Pohlaví</Form.Label>
|
||||||
|
<Form.Select value={sex} onChange={event => setSex(event.target.value)}>
|
||||||
|
<option value="">— nevyplněno —</option>
|
||||||
|
{POHLAVI_IN_ORDER.map(value => (
|
||||||
|
<option key={value} value={value}>{POHLAVI_NAMES[value]}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Rok narození</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={birthYear}
|
||||||
|
placeholder="1990"
|
||||||
|
onChange={event => setBirthYear(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Výška</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={heightCm}
|
||||||
|
placeholder="185"
|
||||||
|
onChange={event => setHeightCm(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">cm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Hmotnost</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={weightKg}
|
||||||
|
placeholder="90,0"
|
||||||
|
onChange={event => setWeightKg(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-grid">
|
||||||
|
<div className="summary-tile">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faCalculator} /> Spočítaný klidový výdej</div>
|
||||||
|
<div className="summary-value">{formatCalories(computed)}</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{computed != null ? 'Mifflin–St Jeor, odhad ±10 %' : 'vyplňte všechny čtyři údaje'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-tile accent">
|
||||||
|
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Použije se v bilanci</div>
|
||||||
|
<div className="summary-value">{formatCalories(effective)}</div>
|
||||||
|
<div className="summary-hint">
|
||||||
|
{manualWins ? 'ručně zadaná hodnota' : (computed != null ? 'ze spočítané hodnoty' : 'zatím nic')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-panel mb-3">
|
||||||
|
<h2 className="chart-title">Ruční klidový výdej</h2>
|
||||||
|
<p className="text-body-secondary small">
|
||||||
|
Máte-li hodnotu změřenou přesněji, zadejte ji sem — přebije spočítanou.
|
||||||
|
Prázdné pole znamená, že se použije výpočet z údajů výše.
|
||||||
|
</p>
|
||||||
|
<div className="rate-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={manualBasal}
|
||||||
|
placeholder={computed != null ? String(computed) : 'např. 1800'}
|
||||||
|
onChange={event => setManualBasal(event.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kcal / den</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||||
|
{saved && !error && <Alert variant="success" className="py-2 small">Uloženo.</Alert>}
|
||||||
|
|
||||||
|
<Button onClick={() => saveAll()} disabled={saving}>
|
||||||
|
{saving ? 'Ukládám…' : 'Uložit'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-body-secondary small mt-4">
|
||||||
|
Poznámka k výpočtu: klidový výdej je hodnota za celých 24 hodin včetně spánku.
|
||||||
|
Záměrně se nenásobí koeficientem fyzické aktivity, jak to dělají kalkulačky TDEE —
|
||||||
|
pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení
|
||||||
|
by ho započítalo dvakrát.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||||
import { MEAL_TYPE_NAMES } from "../enums";
|
import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
|
||||||
import {
|
import {
|
||||||
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
|
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
|
||||||
getHumanMonth, getMonthEnd, getMonthStart,
|
getHumanMonth, getMonthEnd, getMonthStart,
|
||||||
@@ -14,15 +14,6 @@ import {
|
|||||||
import ImportModal from "../components/modals/ImportModal";
|
import ImportModal from "../components/modals/ImportModal";
|
||||||
import "./StatsPage.scss";
|
import "./StatsPage.scss";
|
||||||
|
|
||||||
/** Rozsahy, za které lze statistiky zobrazit. */
|
|
||||||
type RangeMode = 'month' | 'year' | 'all';
|
|
||||||
|
|
||||||
const RANGE_LABELS: Record<RangeMode, string> = {
|
|
||||||
month: 'Měsíc',
|
|
||||||
year: 'Rok',
|
|
||||||
all: 'Vše',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
|
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
|
||||||
const EARLIEST_YEAR = 2015;
|
const EARLIEST_YEAR = 2015;
|
||||||
|
|
||||||
@@ -43,7 +34,7 @@ function formatChartPrice(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const [mode, setMode] = useState<RangeMode>('month');
|
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
|
||||||
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
||||||
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
||||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||||
@@ -52,10 +43,10 @@ export default function StatsPage() {
|
|||||||
|
|
||||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
if (mode === 'month') {
|
if (mode === StatsRange.MESIC) {
|
||||||
return { from: getMonthStart(month), to: getMonthEnd(month) };
|
return { from: getMonthStart(month), to: getMonthEnd(month) };
|
||||||
}
|
}
|
||||||
if (mode === 'year') {
|
if (mode === StatsRange.ROK) {
|
||||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||||
}
|
}
|
||||||
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
|
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
|
||||||
@@ -73,14 +64,14 @@ export default function StatsPage() {
|
|||||||
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
if (!stats) return [];
|
if (!stats) return [];
|
||||||
if (mode === 'month') {
|
if (mode === StatsRange.MESIC) {
|
||||||
return stats.byDay.map(total => ({
|
return stats.byDay.map(total => ({
|
||||||
label: formatDateString(total.period).slice(0, 5),
|
label: formatDateString(total.period).slice(0, 5),
|
||||||
price: total.price / 100,
|
price: total.price / 100,
|
||||||
calories: total.calories,
|
calories: total.calories,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (mode === 'year') {
|
if (mode === StatsRange.ROK) {
|
||||||
return stats.byMonth.map(total => ({
|
return stats.byMonth.map(total => ({
|
||||||
label: getHumanMonth(total.period).split(' ')[0],
|
label: getHumanMonth(total.period).split(' ')[0],
|
||||||
price: total.price / 100,
|
price: total.price / 100,
|
||||||
@@ -117,17 +108,17 @@ export default function StatsPage() {
|
|||||||
|
|
||||||
<div className="range-picker card-panel">
|
<div className="range-picker card-panel">
|
||||||
<div className="range-modes">
|
<div className="range-modes">
|
||||||
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
|
{Object.values(StatsRange).map(value => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
className={`range-mode ${mode === value ? 'active' : ''}`}
|
className={`range-mode ${mode === value ? 'active' : ''}`}
|
||||||
onClick={() => setMode(value)}
|
onClick={() => setMode(value)}
|
||||||
>
|
>
|
||||||
{RANGE_LABELS[value]}
|
{STATS_RANGE_NAMES[value]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{mode === 'month' && (
|
{mode === StatsRange.MESIC && (
|
||||||
<input
|
<input
|
||||||
type="month"
|
type="month"
|
||||||
className="form-control range-input"
|
className="form-control range-input"
|
||||||
@@ -135,7 +126,7 @@ export default function StatsPage() {
|
|||||||
onChange={event => event.target.value && setMonth(event.target.value)}
|
onChange={event => event.target.value && setMonth(event.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mode === 'year' && (
|
{mode === StatsRange.ROK && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control range-input"
|
className="form-control range-input"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
.template-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
|
||||||
|
.template-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-title {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ft-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-calories {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.87rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item-icon {
|
||||||
|
color: var(--ft-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ft-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item-quantity {
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item-calories {
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 72px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-form {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odkaz na vnořenou šablonu se v editaci odlišuje ikonou před výběrem
|
||||||
|
.workout-item-nested-icon {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--ft-primary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-template-price {
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postup přípravy si drží zalomení řádků tak, jak ho uživatel zapsal
|
||||||
|
.meal-template-note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faDumbbell, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
||||||
|
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||||
|
import MealTemplatesPanel from "./MealTemplatesPanel";
|
||||||
|
import "./TemplatesPage.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Šablony — tréninky a jídla vedle sebe.
|
||||||
|
*
|
||||||
|
* Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají
|
||||||
|
* jinou strukturu (tréninky se skládají z jiných šablon, jídla ne), proto mají
|
||||||
|
* vlastní panely a společné jen záložky.
|
||||||
|
*/
|
||||||
|
const TemplatesPage = () => {
|
||||||
|
const [tab, setTab] = useState<TemplateTab>(TemplateTab.TRENINKY);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Šablony</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="day-tabs">
|
||||||
|
<button
|
||||||
|
className={`day-tab ${tab === TemplateTab.TRENINKY ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(TemplateTab.TRENINKY)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`day-tab ${tab === TemplateTab.JIDLA ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(TemplateTab.JIDLA)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBowlFood} /> {TEMPLATE_TAB_NAMES[TemplateTab.JIDLA]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === TemplateTab.TRENINKY ? <WorkoutTemplatesPanel /> : <MealTemplatesPanel />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplatesPage;
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {
|
||||||
|
ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||||
|
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||||
|
} from "../../../types";
|
||||||
|
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
|
||||||
|
import { deriveActivityCalories, formatCalories, formatQuantityWithSets, parseIntInput } from "../Utils";
|
||||||
|
|
||||||
|
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
|
||||||
|
type DraftItem = {
|
||||||
|
kind: WorkoutItemKind,
|
||||||
|
name: string,
|
||||||
|
unit: ActivityUnit,
|
||||||
|
quantity: string,
|
||||||
|
sets: string,
|
||||||
|
caloriesPer100Units: string,
|
||||||
|
templateRefId: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyExercise = (): DraftItem => ({
|
||||||
|
kind: WorkoutItemKind.CVIK,
|
||||||
|
name: '',
|
||||||
|
unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: '',
|
||||||
|
sets: '',
|
||||||
|
caloriesPer100Units: '',
|
||||||
|
templateRefId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyReference = (templateRefId: string): DraftItem => ({
|
||||||
|
...emptyExercise(),
|
||||||
|
kind: WorkoutItemKind.SABLONA,
|
||||||
|
templateRefId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel se správou šablon tréninků.
|
||||||
|
*
|
||||||
|
* Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu,
|
||||||
|
* jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu
|
||||||
|
* úroveň, takže šablona, která už něco skládá, se sama vnořit nedá.
|
||||||
|
*/
|
||||||
|
const WorkoutTemplatesPanel = () => {
|
||||||
|
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editedId, setEditedId] = useState<string | undefined>();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [items, setItems] = useState<DraftItem[]>([emptyExercise()]);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getWorkoutTemplates();
|
||||||
|
setTemplates(response.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditedId(undefined);
|
||||||
|
setName('');
|
||||||
|
setItems([emptyExercise()]);
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (index: number, changes: Partial<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) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
setTemplates(response.data);
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
// Server odmítá typicky kvůli pravidlu o jedné úrovni zanoření
|
||||||
|
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
const response = await deleteWorkoutTemplate({ body: { id } });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
setTemplates(response.data);
|
||||||
|
if (editedId === id) resetForm();
|
||||||
|
} else {
|
||||||
|
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo smazat');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Načte šablonu do formuláře k úpravě. */
|
||||||
|
const edit = (template: WorkoutTemplate) => {
|
||||||
|
setEditedId(template.id);
|
||||||
|
setName(template.name);
|
||||||
|
setError(undefined);
|
||||||
|
setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA
|
||||||
|
? emptyReference(item.templateRefId ?? '')
|
||||||
|
: {
|
||||||
|
kind: WorkoutItemKind.CVIK,
|
||||||
|
name: item.name ?? '',
|
||||||
|
unit: item.unit ?? ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: item.quantity != null ? String(item.quantity) : '',
|
||||||
|
sets: item.sets != null ? String(item.sets) : '',
|
||||||
|
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||||
|
templateRefId: '',
|
||||||
|
}));
|
||||||
|
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Průběžný odhad kalorií rozpracované šablony, včetně vnořených. */
|
||||||
|
const draftCalories = items.reduce((sum, item) => {
|
||||||
|
if (item.kind === WorkoutItemKind.SABLONA) {
|
||||||
|
return sum + (templates.find(t => t.id === item.templateRefId)?.estimatedCalories ?? 0);
|
||||||
|
}
|
||||||
|
return sum + (deriveActivityCalories(
|
||||||
|
parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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ň.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<Spinner animation="border" variant="success" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
<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>
|
||||||
|
<span className="template-item-calories">
|
||||||
|
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<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, '') })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkoutTemplatesPanel;
|
||||||
@@ -6,3 +6,5 @@
|
|||||||
*/
|
*/
|
||||||
export const DEN_URL = '/';
|
export const DEN_URL = '/';
|
||||||
export const STATISTIKY_URL = '/statistiky';
|
export const STATISTIKY_URL = '/statistiky';
|
||||||
|
export const SABLONY_URL = '/sablony';
|
||||||
|
export const PROFIL_URL = '/profil';
|
||||||
|
|||||||
@@ -79,12 +79,25 @@ export type NormalizedActivityInput = {
|
|||||||
date: string;
|
date: string;
|
||||||
name: string;
|
name: string;
|
||||||
unit: ActivityUnit;
|
unit: ActivityUnit;
|
||||||
|
/** Množství na jednu sérii. */
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
/** Počet sérií; nevyplněno znamená jednu. */
|
||||||
|
sets?: number;
|
||||||
caloriesPer100Units?: number;
|
caloriesPer100Units?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí celkové množství aktivity, tedy počet sérií krát množství v jedné.
|
||||||
|
*
|
||||||
|
* Série se drží odděleně, protože 3 × 40 opakování s pauzami je jiný trénink
|
||||||
|
* než 120 opakování v kuse — spálené kalorie jsou ale stejné, takže do výpočtu
|
||||||
|
* jde součin.
|
||||||
|
*/
|
||||||
|
export const getTotalQuantity = (quantity: number, sets?: number): number =>
|
||||||
|
quantity * Math.max(sets ?? 1, 1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dopočte spálené kalorie z množství a sazby na 100 jednotek.
|
* Dopočte spálené kalorie z množství a sazby na 100 jednotek.
|
||||||
*
|
*
|
||||||
@@ -93,7 +106,7 @@ export type NormalizedActivityInput = {
|
|||||||
*/
|
*/
|
||||||
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
|
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
|
||||||
const calories = input.caloriesPer100Units
|
const calories = input.caloriesPer100Units
|
||||||
? Math.round(input.quantity / 100 * input.caloriesPer100Units)
|
? Math.round(getTotalQuantity(input.quantity, input.sets) / 100 * input.caloriesPer100Units)
|
||||||
: input.calories;
|
: input.calories;
|
||||||
return { ...input, calories };
|
return { ...input, calories };
|
||||||
}
|
}
|
||||||
@@ -115,11 +128,14 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput
|
|||||||
if (quantity == null) {
|
if (quantity == null) {
|
||||||
throw new BadRequestError("Nebylo předáno množství aktivity");
|
throw new BadRequestError("Nebylo předáno množství aktivity");
|
||||||
}
|
}
|
||||||
|
const sets = normalizeNumber(value.sets, 'sets');
|
||||||
return deriveActivityCalories({
|
return deriveActivityCalories({
|
||||||
date: value.date,
|
date: value.date,
|
||||||
name,
|
name,
|
||||||
unit: value.unit,
|
unit: value.unit,
|
||||||
quantity,
|
quantity,
|
||||||
|
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
|
...(sets && sets > 1 ? { sets } : {}),
|
||||||
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
|
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
|
||||||
calories: normalizeNumber(value.calories, 'calories'),
|
calories: normalizeNumber(value.calories, 'calories'),
|
||||||
note: normalizeText(value.note, 'note'),
|
note: normalizeText(value.note, 'note'),
|
||||||
@@ -193,6 +209,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
|||||||
const updated: ActivityEntry = {
|
const updated: ActivityEntry = {
|
||||||
...original,
|
...original,
|
||||||
...normalized,
|
...normalized,
|
||||||
|
sets: normalized.sets,
|
||||||
caloriesPer100Units: normalized.caloriesPer100Units,
|
caloriesPer100Units: normalized.caloriesPer100Units,
|
||||||
calories: normalized.calories,
|
calories: normalized.calories,
|
||||||
note: normalized.note,
|
note: normalized.note,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
|
|||||||
import { getDay } from "./meals";
|
import { getDay } from "./meals";
|
||||||
import { getActivityDay } from "./activities";
|
import { getActivityDay } from "./activities";
|
||||||
import { getSettings } from "./settings";
|
import { getSettings } from "./settings";
|
||||||
|
import { getSleep } from "./sleep";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spočítá energetickou bilanci dne.
|
* Spočítá energetickou bilanci dne.
|
||||||
@@ -10,11 +11,15 @@ import { getSettings } from "./settings";
|
|||||||
* výdeje porovnává bilance jen jídlo proti pohybu — to není skutečný deficit,
|
* výdeje porovnává bilance jen jídlo proti pohybu — to není skutečný deficit,
|
||||||
* proto se to příznakem `hasBasal` propisuje do UI.
|
* proto se to příznakem `hasBasal` propisuje do UI.
|
||||||
*
|
*
|
||||||
|
* Délka spánku do bilance **nevstupuje**: klidový výdej je hodnota za celých
|
||||||
|
* 24 hodin včetně spánku a žádná ze standardních rovnic (Mifflin–St Jeor,
|
||||||
|
* Harris–Benedict) délku spánku jako proměnnou nemá.
|
||||||
|
*
|
||||||
* @param intake přijaté kcal z jídla
|
* @param intake přijaté kcal z jídla
|
||||||
* @param activityBurn spálené kcal pohybem
|
* @param activityBurn spálené kcal pohybem
|
||||||
* @param basal klidový výdej v kcal za den, nebo nic
|
* @param basal klidový výdej v kcal za den, nebo nic
|
||||||
*/
|
*/
|
||||||
export function buildEnergyBalance(intake: number, activityBurn: number, basal?: number): EnergyBalance {
|
export const buildEnergyBalance = (intake: number, activityBurn: number, basal?: number): EnergyBalance => {
|
||||||
const resolvedBasal = basal ?? 0;
|
const resolvedBasal = basal ?? 0;
|
||||||
const totalBurn = resolvedBasal + activityBurn;
|
const totalBurn = resolvedBasal + activityBurn;
|
||||||
return {
|
return {
|
||||||
@@ -25,24 +30,27 @@ export function buildEnergyBalance(intake: number, activityBurn: number, basal?:
|
|||||||
balance: intake - totalBurn,
|
balance: intake - totalBurn,
|
||||||
hasBasal: !!basal,
|
hasBasal: !!basal,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sestaví kompletní přehled dne — jídlo, pohyb a jejich bilanci.
|
* Sestaví kompletní přehled dne — jídlo, pohyb, spánek a energetickou bilanci.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param date datum ve formátu YYYY-MM-DD
|
* @param date datum ve formátu YYYY-MM-DD
|
||||||
*/
|
*/
|
||||||
export async function getDayOverview(login: string, date: string): Promise<DayOverview> {
|
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
|
||||||
const [meals, activities, settings] = await Promise.all([
|
const [meals, activities, settings, sleepMinutes] = await Promise.all([
|
||||||
getDay(login, date),
|
getDay(login, date),
|
||||||
getActivityDay(login, date),
|
getActivityDay(login, date),
|
||||||
getSettings(login),
|
getSettings(login),
|
||||||
|
getSleep(login, date),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
meals,
|
meals,
|
||||||
activities,
|
activities,
|
||||||
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.basalCalories),
|
// Ručně zadaný klidový výdej vyhrává nad spočítaným z tělesných údajů
|
||||||
|
energy: buildEnergyBalance(meals.totalCalories, activities.totalCalories, settings.effectiveBasalCalories),
|
||||||
|
...(sleepMinutes ? { sleepMinutes } : {}),
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import settingsRoutes from "./routes/settingsRoutes";
|
|||||||
import activityRoutes from "./routes/activityRoutes";
|
import activityRoutes from "./routes/activityRoutes";
|
||||||
import workoutRoutes from "./routes/workoutRoutes";
|
import workoutRoutes from "./routes/workoutRoutes";
|
||||||
import dayRoutes from "./routes/dayRoutes";
|
import dayRoutes from "./routes/dayRoutes";
|
||||||
|
import mealTemplateRoutes from "./routes/mealTemplateRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -144,6 +145,7 @@ app.use("/api/settings", settingsRoutes);
|
|||||||
app.use("/api/activities", activityRoutes);
|
app.use("/api/activities", activityRoutes);
|
||||||
app.use("/api/workouts", workoutRoutes);
|
app.use("/api/workouts", workoutRoutes);
|
||||||
app.use("/api/day", dayRoutes);
|
app.use("/api/day", dayRoutes);
|
||||||
|
app.use("/api/mealTemplates", mealTemplateRoutes);
|
||||||
|
|
||||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
app.get('*splat', (_req, res) => {
|
app.get('*splat', (_req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { MealTemplate, MealType } from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { addMeal, getDay, isMealType } from "./meals";
|
||||||
|
import { normalizeName } from "./calories";
|
||||||
|
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/** Maximální délka textových polí. Poznámka je delší, aby se do ní vešel postup. */
|
||||||
|
const MAX_NAME_LENGTH = 200;
|
||||||
|
const MAX_NOTE_LENGTH = 4000;
|
||||||
|
|
||||||
|
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||||
|
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
*/
|
||||||
|
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||||
|
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||||
|
|
||||||
|
/** Ořízne a znormalizuje volitelný textový vstup. */
|
||||||
|
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestError(`Pole '${name}' musí být text`);
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.length) return undefined;
|
||||||
|
if (trimmed.length > maxLength) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
|
||||||
|
const normalizeNumber = (value: unknown, name: string): number | undefined => {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||||
|
}
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
if (rounded < 0) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
|
||||||
|
}
|
||||||
|
return rounded;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param input data šablony
|
||||||
|
*/
|
||||||
|
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
|
||||||
|
const value = (input ?? {}) as Record<string, unknown>;
|
||||||
|
const name = normalizeText(value.name, 'name');
|
||||||
|
if (!name) {
|
||||||
|
throw new BadRequestError("Nebyl předán název šablony");
|
||||||
|
}
|
||||||
|
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||||
|
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||||
|
}
|
||||||
|
|
||||||
|
const template: MealTemplate = {
|
||||||
|
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||||
|
name,
|
||||||
|
...(value.mealType != null ? { mealType: value.mealType as MealType } : {}),
|
||||||
|
...(normalizeText(value.source, 'source') != null
|
||||||
|
? { source: normalizeText(value.source, 'source') } : {}),
|
||||||
|
...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null
|
||||||
|
? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}),
|
||||||
|
...(normalizeNumber(value.calories, 'calories') != null
|
||||||
|
? { calories: normalizeNumber(value.calories, 'calories') } : {}),
|
||||||
|
...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null
|
||||||
|
? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}),
|
||||||
|
...(normalizeNumber(value.weight, 'weight') != null
|
||||||
|
? { weight: normalizeNumber(value.weight, 'weight') } : {}),
|
||||||
|
...(normalizeNumber(value.price, 'price') != null
|
||||||
|
? { price: normalizeNumber(value.price, 'price') } : {}),
|
||||||
|
...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null
|
||||||
|
? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||||
|
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||||
|
return [...others, template];
|
||||||
|
});
|
||||||
|
return getMealTemplates(login);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smaže šablonu jídla.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param id identifikátor šablony
|
||||||
|
*/
|
||||||
|
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
|
||||||
|
if (typeof id !== 'string' || !id.length) {
|
||||||
|
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||||
|
}
|
||||||
|
let found = false;
|
||||||
|
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||||
|
found = (current ?? []).some(item => item.id === id);
|
||||||
|
return (current ?? []).filter(item => item.id !== id);
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundError("Šablona nebyla nalezena");
|
||||||
|
}
|
||||||
|
return getMealTemplates(login);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Založí do zvoleného dne jídlo ze šablony.
|
||||||
|
*
|
||||||
|
* Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by
|
||||||
|
* to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
|
||||||
|
* u ručně zadaného jídla.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param id identifikátor šablony
|
||||||
|
* @param date den, do kterého se jídlo založí
|
||||||
|
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
|
||||||
|
*/
|
||||||
|
export const applyMealTemplate = async (
|
||||||
|
login: string,
|
||||||
|
id: unknown,
|
||||||
|
date: unknown,
|
||||||
|
mealType?: unknown,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (typeof id !== 'string' || !id.length) {
|
||||||
|
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||||
|
}
|
||||||
|
if (!isValidIsoDate(date)) {
|
||||||
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
if (mealType != null && !isMealType(mealType)) {
|
||||||
|
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||||
|
}
|
||||||
|
const template = (await getMealTemplates(login)).find(item => item.id === id);
|
||||||
|
if (!template) {
|
||||||
|
throw new NotFoundError("Šablona nebyla nalezena");
|
||||||
|
}
|
||||||
|
|
||||||
|
await addMeal(login, {
|
||||||
|
date,
|
||||||
|
mealType: mealType ?? template.mealType ?? MealType.OBED,
|
||||||
|
name: template.name,
|
||||||
|
source: template.source,
|
||||||
|
weight: template.weight,
|
||||||
|
price: template.price,
|
||||||
|
pricePer100g: template.pricePer100g,
|
||||||
|
calories: template.calories,
|
||||||
|
caloriesPer100g: template.caloriesPer100g,
|
||||||
|
note: template.note,
|
||||||
|
});
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
|
||||||
|
*
|
||||||
|
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
||||||
|
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date den, ve kterém jídlo je
|
||||||
|
* @param id identifikátor jídla
|
||||||
|
*/
|
||||||
|
export const createMealTemplateFromMeal = async (
|
||||||
|
login: string,
|
||||||
|
date: unknown,
|
||||||
|
id: unknown,
|
||||||
|
): Promise<MealTemplate[]> => {
|
||||||
|
if (typeof id !== 'string' || !id.length) {
|
||||||
|
throw new BadRequestError("Nebyl předán identifikátor jídla");
|
||||||
|
}
|
||||||
|
if (!isValidIsoDate(date)) {
|
||||||
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
const meal = (await getDay(login, date)).entries.find(entry => entry.id === id);
|
||||||
|
if (!meal) {
|
||||||
|
throw new NotFoundError("Jídlo nebylo nalezeno");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeName(meal.name);
|
||||||
|
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||||
|
|
||||||
|
return saveMealTemplate(login, {
|
||||||
|
...(existing ? { id: existing.id } : {}),
|
||||||
|
name: meal.name,
|
||||||
|
source: meal.source,
|
||||||
|
mealType: meal.mealType,
|
||||||
|
weight: meal.weight,
|
||||||
|
price: meal.price,
|
||||||
|
pricePer100g: meal.pricePer100g,
|
||||||
|
// Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže
|
||||||
|
calories: meal.caloriesPer100g ? undefined : meal.calories,
|
||||||
|
caloriesPer100g: meal.caloriesPer100g,
|
||||||
|
note: meal.note,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { PersonalProfile, Pohlavi } from "../../types/gen/types.gen";
|
||||||
|
import { BadRequestError } from "./utils";
|
||||||
|
|
||||||
|
/** Meze tělesných údajů — chrání před překlepem o řád, ne před neobvyklými hodnotami. */
|
||||||
|
const LIMITS = {
|
||||||
|
birthYear: { min: 1900, max: new Date().getFullYear() },
|
||||||
|
heightCm: { min: 50, max: 250 },
|
||||||
|
weightGrams: { min: 20_000, max: 400_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vrátí true, pokud je hodnota podporovaným pohlavím. */
|
||||||
|
export const isPohlavi = (value: unknown): value is Pohlavi =>
|
||||||
|
value === Pohlavi.MUZ || value === Pohlavi.ZENA;
|
||||||
|
|
||||||
|
/** Ověří volitelné celé číslo v daném rozsahu. */
|
||||||
|
const normalizeRanged = (value: unknown, name: keyof typeof LIMITS): number | undefined => {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||||
|
}
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
const { min, max } = LIMITS[name];
|
||||||
|
if (rounded < min || rounded > max) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' je mimo rozsah (${min}–${max})`);
|
||||||
|
}
|
||||||
|
return rounded;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří a znormalizuje tělesné údaje. Prázdné hodnoty se uloží jako nevyplněné. */
|
||||||
|
export const normalizeProfile = (input: unknown): PersonalProfile => {
|
||||||
|
const value = (input ?? {}) as Record<string, unknown>;
|
||||||
|
if (value.sex != null && !isPohlavi(value.sex)) {
|
||||||
|
throw new BadRequestError("Nebylo předáno platné pohlaví");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(value.sex != null ? { sex: value.sex as Pohlavi } : {}),
|
||||||
|
...(normalizeRanged(value.birthYear, 'birthYear') != null
|
||||||
|
? { birthYear: normalizeRanged(value.birthYear, 'birthYear') } : {}),
|
||||||
|
...(normalizeRanged(value.heightCm, 'heightCm') != null
|
||||||
|
? { heightCm: normalizeRanged(value.heightCm, 'heightCm') } : {}),
|
||||||
|
...(normalizeRanged(value.weightGrams, 'weightGrams') != null
|
||||||
|
? { weightGrams: normalizeRanged(value.weightGrams, 'weightGrams') } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spočítá bazální metabolismus rovnicí Mifflin–St Jeor.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* muž: 10 × kg + 6,25 × cm − 5 × věk + 5
|
||||||
|
* žena: 10 × kg + 6,25 × cm − 5 × věk − 161
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Je to klidový výdej za **celých 24 hodin**, tedy včetně spánku. Záměrně se
|
||||||
|
* nenásobí koeficientem fyzické aktivity (PAL), jak to dělají kalkulačky TDEE —
|
||||||
|
* pohyb si aplikace eviduje zvlášť a přičítá ho k tomuto číslu, takže vynásobení
|
||||||
|
* by ho započítalo dvakrát.
|
||||||
|
*
|
||||||
|
* Výsledek je odhad s běžnou chybou kolem ±10 %, ne měřená hodnota.
|
||||||
|
*
|
||||||
|
* @param profile tělesné údaje uživatele
|
||||||
|
* @returns klidový výdej v kcal za den, nebo nic, když profil není úplný
|
||||||
|
*/
|
||||||
|
export const computeBasalCalories = (profile?: PersonalProfile): number | undefined => {
|
||||||
|
if (!profile?.sex || !profile.birthYear || !profile.heightCm || !profile.weightGrams) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const age = new Date().getFullYear() - profile.birthYear;
|
||||||
|
const base = 10 * (profile.weightGrams / 1000)
|
||||||
|
+ 6.25 * profile.heightCm
|
||||||
|
- 5 * age;
|
||||||
|
return Math.round(base + (profile.sex === Pohlavi.MUZ ? 5 : -161));
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
|
import { saveSleep } from "../sleep";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||||
|
|
||||||
@@ -16,4 +17,14 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Uloží délku spánku pro zvolený den. */
|
||||||
|
router.post("/sleep", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { date, sleepMinutes } = req.body ?? {};
|
||||||
|
const day = await saveSleep(login, date, sleepMinutes);
|
||||||
|
res.status(200).json(await getDayOverview(login, day));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
|
import {
|
||||||
|
applyMealTemplate, createMealTemplateFromMeal, deleteMealTemplate, getMealTemplates, saveMealTemplate,
|
||||||
|
} from "../mealTemplates";
|
||||||
|
import { getDayOverview } from "../dayOverview";
|
||||||
|
import { getLogin } from "../auth";
|
||||||
|
import { parseToken } from "../utils";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/** Vrátí šablony jídel přihlášeného uživatele. */
|
||||||
|
router.get("/", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
res.status(200).json(await getMealTemplates(login));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Založí novou šablonu jídla, nebo upraví existující. */
|
||||||
|
router.post("/save", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
res.status(200).json(await saveMealTemplate(login, req.body));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Smaže šablonu jídla. */
|
||||||
|
router.post("/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
res.status(200).json(await deleteMealTemplate(login, req.body?.id));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Založí jídlo ze šablony do zvoleného dne. */
|
||||||
|
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, date, mealType } = req.body ?? {};
|
||||||
|
const day = await applyMealTemplate(login, id, date, mealType);
|
||||||
|
res.status(200).json(await getDayOverview(login, day));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Vytvoří šablonu z existujícího jídla v přehledu dne. */
|
||||||
|
router.post("/fromMeal", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, date } = req.body ?? {};
|
||||||
|
res.status(200).json(await createMealTemplateFromMeal(login, date, id));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { getSettings, saveBasalCalories, saveSourceRate } from "../settings";
|
import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
|
|
||||||
@@ -30,4 +30,12 @@ router.post("/basal", async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Uloží tělesné údaje uživatele. */
|
||||||
|
router.post("/profile", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
res.status(200).json(await saveProfile(login, req.body));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
import {
|
||||||
|
applyWorkoutTemplate, createWorkoutTemplateFromActivities, deleteWorkoutTemplate,
|
||||||
|
getWorkoutTemplates, saveWorkoutTemplate,
|
||||||
|
} from "../workouts";
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
@@ -41,4 +44,13 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Vytvoří šablonu z aktivit zaznamenaných v přehledu dne. */
|
||||||
|
router.post("/fromActivities", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { date, name, ids } = req.body ?? {};
|
||||||
|
res.status(200).json(await createWorkoutTemplateFromActivities(login, date, name, ids));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+42
-6
@@ -1,7 +1,8 @@
|
|||||||
import { UserSettings } from "../../types/gen/types.gen";
|
import { PersonalProfile, UserSettings } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { BadRequestError } from "./utils";
|
import { BadRequestError } from "./utils";
|
||||||
import { normalizeName } from "./calories";
|
import { normalizeName } from "./calories";
|
||||||
|
import { computeBasalCalories, normalizeProfile } from "./profile";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -27,7 +28,39 @@ function emptySettings(): UserSettings {
|
|||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
*/
|
*/
|
||||||
export async function getSettings(login: string): Promise<UserSettings> {
|
export async function getSettings(login: string): Promise<UserSettings> {
|
||||||
return await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
|
const stored = await storage.getData<UserSettings>(getSettingsKey(login)) ?? emptySettings();
|
||||||
|
return withDerived(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplní nastavení o hodnoty, které se dopočítávají a neukládají.
|
||||||
|
*
|
||||||
|
* Ručně zadaný klidový výdej má přednost před spočítaným z tělesných údajů —
|
||||||
|
* kdo si změřil vlastní, nechce ho mít přebitý odhadem z rovnice.
|
||||||
|
*/
|
||||||
|
function withDerived(settings: UserSettings): UserSettings {
|
||||||
|
const computed = computeBasalCalories(settings.profile);
|
||||||
|
const effective = settings.basalCalories ?? computed;
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
...(computed != null ? { computedBasalCalories: computed } : {}),
|
||||||
|
...(effective != null ? { effectiveBasalCalories: effective } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uloží tělesné údaje uživatele.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param profile tělesné údaje
|
||||||
|
*/
|
||||||
|
export async function saveProfile(login: string, profile: unknown): Promise<UserSettings> {
|
||||||
|
const normalized: PersonalProfile = normalizeProfile(profile);
|
||||||
|
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => ({
|
||||||
|
...(current ?? emptySettings()),
|
||||||
|
profile: normalized,
|
||||||
|
}));
|
||||||
|
return withDerived(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,11 +87,13 @@ export async function saveSourceRate(login: string, source: unknown, pricePer100
|
|||||||
|
|
||||||
const name = source.trim();
|
const name = source.trim();
|
||||||
const key = normalizeName(name);
|
const key = normalizeName(name);
|
||||||
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||||
const rates = (current ?? emptySettings()).sourceRates.filter(item => normalizeName(item.source) !== key);
|
const settings = current ?? emptySettings();
|
||||||
|
const rates = settings.sourceRates.filter(item => normalizeName(item.source) !== key);
|
||||||
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
// Nulová sazba znamená smazání — ukládat ji nemá smysl
|
||||||
return { sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
return { ...settings, sourceRates: rate > 0 ? [...rates, { source: name, pricePer100g: rate }] : rates };
|
||||||
});
|
});
|
||||||
|
return withDerived(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +111,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
|
|||||||
if (value < 0 || value > MAX_BASAL_CALORIES) {
|
if (value < 0 || value > MAX_BASAL_CALORIES) {
|
||||||
throw new BadRequestError("Klidový výdej je mimo rozsah");
|
throw new BadRequestError("Klidový výdej je mimo rozsah");
|
||||||
}
|
}
|
||||||
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
|
||||||
const settings = current ?? emptySettings();
|
const settings = current ?? emptySettings();
|
||||||
// Nula znamená smazání — ukládat ji nemá smysl
|
// Nula znamená smazání — ukládat ji nemá smysl
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
@@ -85,6 +120,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
|
|||||||
const { basalCalories: _removed, ...rest } = settings;
|
const { basalCalories: _removed, ...rest } = settings;
|
||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
return withDerived(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import getStorage from "./storage";
|
||||||
|
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/** Horní mez délky spánku — víc než 24 hodin za den je překlep. */
|
||||||
|
const MAX_SLEEP_MINUTES = 24 * 60;
|
||||||
|
|
||||||
|
/** Klíč úložiště se spánkem jednoho dne jednoho uživatele. */
|
||||||
|
const getSleepKey = (login: string, date: string): string => `sleep:${login}:${date}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí délku spánku daného dne v minutách, pokud je zaznamenaná.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date datum ve formátu YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export const getSleep = async (login: string, date: string): Promise<number | undefined> =>
|
||||||
|
await storage.getData<number>(getSleepKey(login, date));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uloží délku spánku pro zvolený den. Nulová hodnota záznam odstraní.
|
||||||
|
*
|
||||||
|
* Spánek se eviduje jako kontext dne, ne jako složka energetické bilance —
|
||||||
|
* klidový výdej z rovnice Mifflin–St Jeor už pokrývá celých 24 hodin včetně
|
||||||
|
* spánku, takže přičítat nebo odečítat za něj kalorie by bylo dvojí započtení.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date datum ve formátu YYYY-MM-DD
|
||||||
|
* @param sleepMinutes délka spánku v minutách, nebo null pro odstranění
|
||||||
|
* @returns datum, kterého se zápis týkal
|
||||||
|
*/
|
||||||
|
export const saveSleep = async (login: string, date: unknown, sleepMinutes: unknown): Promise<string> => {
|
||||||
|
if (!isValidIsoDate(date)) {
|
||||||
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
if (sleepMinutes != null && (typeof sleepMinutes !== 'number' || !Number.isFinite(sleepMinutes))) {
|
||||||
|
throw new BadRequestError("Délka spánku musí být číslo");
|
||||||
|
}
|
||||||
|
const value = sleepMinutes == null ? 0 : Math.round(sleepMinutes);
|
||||||
|
if (value < 0 || value > MAX_SLEEP_MINUTES) {
|
||||||
|
throw new BadRequestError("Délka spánku je mimo rozsah");
|
||||||
|
}
|
||||||
|
// Nula znamená smazání — ukládat ji nemá smysl
|
||||||
|
await storage.setData(getSleepKey(login, date), value > 0 ? value : undefined);
|
||||||
|
return date;
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
|
import { addActivity, getActivityDay, getTotalQuantity, updateActivity } from "../activities";
|
||||||
|
import { applyWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
||||||
|
import { applyMealTemplate, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||||
|
import { getDay } from "../meals";
|
||||||
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import { ActivityUnit, MealType, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const LOGIN = 'tester';
|
||||||
|
const DATE = '2025-03-03';
|
||||||
|
|
||||||
|
beforeEach(() => resetMemoryStorage());
|
||||||
|
|
||||||
|
describe('série u cviků', () => {
|
||||||
|
|
||||||
|
it('celkové množství je počet sérií krát množství v sérii', () => {
|
||||||
|
expect(getTotalQuantity(40, 3)).toBe(120);
|
||||||
|
expect(getTotalQuantity(40)).toBe(40);
|
||||||
|
// Nula ani záporná hodnota sérií nedává smysl, bere se jako jedna
|
||||||
|
expect(getTotalQuantity(40, 0)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3 × 40 spálí stejně jako 120 v kuse', async () => {
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: '2025-03-04', name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 120, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60);
|
||||||
|
expect((await getActivityDay(LOGIN, '2025-03-04')).totalCalories).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ale série zůstane v záznamu, aby šlo 3 × 40 od 120 rozlišit', async () => {
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||||
|
expect(entry.quantity).toBe(40);
|
||||||
|
expect(entry.sets).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jedna série se neukládá, je to výchozí stav', async () => {
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, sets: 1, caloriesPer100Units: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('úprava umí série přidat i odebrat', async () => {
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
const [created] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||||
|
|
||||||
|
await updateActivity(LOGIN, created.id, {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBe(3);
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(60);
|
||||||
|
|
||||||
|
await updateActivity(LOGIN, created.id, {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 40, caloriesPer100Units: 50,
|
||||||
|
});
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).entries[0].sets).toBeUndefined();
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).totalCalories).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablona počítá odhad ze všech sérií', async () => {
|
||||||
|
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
|
||||||
|
{ kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(templates[0].estimatedCalories).toBe(60);
|
||||||
|
expect(templates[0].items[0].sets).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('použití šablony přenese série do založené aktivity', async () => {
|
||||||
|
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
|
||||||
|
{ kind: WorkoutItemKind.CVIK, name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 40, sets: 3, caloriesPer100Units: 50 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await applyWorkoutTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||||
|
expect(entry.sets).toBe(3);
|
||||||
|
expect(entry.quantity).toBe(40);
|
||||||
|
expect(entry.calories).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('série se rozbalí i z vnořené šablony', async () => {
|
||||||
|
const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [
|
||||||
|
{ kind: WorkoutItemKind.CVIK, name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, quantity: 20, sets: 4, caloriesPer100Units: 40 },
|
||||||
|
]);
|
||||||
|
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Celý trénink', [
|
||||||
|
{ kind: WorkoutItemKind.SABLONA, templateRefId: zaklad.id },
|
||||||
|
]);
|
||||||
|
const celyTrenink = templates.find(t => t.name === 'Celý trénink')!;
|
||||||
|
expect(celyTrenink.estimatedCalories).toBe(32);
|
||||||
|
|
||||||
|
await applyWorkoutTemplate(LOGIN, celyTrenink.id, DATE);
|
||||||
|
|
||||||
|
const [entry] = (await getActivityDay(LOGIN, DATE)).entries;
|
||||||
|
expect(entry.sets).toBe(4);
|
||||||
|
expect(entry.calories).toBe(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('šablony jídel', () => {
|
||||||
|
|
||||||
|
const salat = {
|
||||||
|
name: 'Těstovinový salát',
|
||||||
|
source: 'Chefie',
|
||||||
|
mealType: MealType.OBED,
|
||||||
|
weight: 350,
|
||||||
|
caloriesPer100g: 160,
|
||||||
|
price: 12900,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('uloží šablonu a vrátí ji v seznamu', async () => {
|
||||||
|
const templates = await saveMealTemplate(LOGIN, salat);
|
||||||
|
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0].name).toBe('Těstovinový salát');
|
||||||
|
expect(templates[0].source).toBe('Chefie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('úprava podle id šablonu přepíše, nezaloží druhou', async () => {
|
||||||
|
const [created] = await saveMealTemplate(LOGIN, salat);
|
||||||
|
|
||||||
|
const templates = await saveMealTemplate(LOGIN, { ...salat, id: created.id, name: 'Salát velký' });
|
||||||
|
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0].name).toBe('Salát velký');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('použití založí jídlo do dne a dopočte kalorie', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||||
|
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
const day = await getDay(LOGIN, DATE);
|
||||||
|
expect(day.entries).toHaveLength(1);
|
||||||
|
expect(day.entries[0].name).toBe('Těstovinový salát');
|
||||||
|
expect(day.entries[0].source).toBe('Chefie');
|
||||||
|
// 350 g × 160 kcal/100 g
|
||||||
|
expect(day.entries[0].calories).toBe(560);
|
||||||
|
expect(day.totalPrice).toBe(12900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zvolený chod přebije ten ze šablony', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||||
|
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE, MealType.VECERE);
|
||||||
|
|
||||||
|
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.VECERE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bez chodu ve šabloně i v požadavku se založí jako oběd', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||||
|
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.OBED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablona jen s absolutními kaloriemi funguje taky', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, { name: 'Jablko', calories: 80 });
|
||||||
|
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
expect((await getDay(LOGIN, DATE)).totalCalories).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('založené jídlo je samostatné — smazání šablony ho nesmaže', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, salat);
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
await deleteMealTemplate(LOGIN, template.id);
|
||||||
|
|
||||||
|
expect(await getMealTemplates(LOGIN)).toEqual([]);
|
||||||
|
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unese poznámku s postupem přípravy', async () => {
|
||||||
|
const recept = 'Těstoviny uvařit al dente.\nZeleninu nakrájet.\nSmíchat s dresinkem.';
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, { name: 'Domácí salát', note: recept });
|
||||||
|
|
||||||
|
expect(template.note).toBe(recept);
|
||||||
|
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
expect((await getDay(LOGIN, DATE)).entries[0].note).toBe(recept);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne šablonu bez názvu a neexistující id', async () => {
|
||||||
|
await expect(saveMealTemplate(LOGIN, { name: ' ' })).rejects.toThrow(/název šablony/);
|
||||||
|
await expect(applyMealTemplate(LOGIN, 'neexistuje', DATE)).rejects.toThrow(/nebyla nalezena/);
|
||||||
|
await expect(deleteMealTemplate(LOGIN, 'neexistuje')).rejects.toThrow(/nebyla nalezena/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablony jídel jsou oddělené mezi uživateli', async () => {
|
||||||
|
await saveMealTemplate(LOGIN, salat);
|
||||||
|
|
||||||
|
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
|
import { addActivity, getActivityDay } from "../activities";
|
||||||
|
import { addMeal, getDay } from "../meals";
|
||||||
|
import { createMealTemplateFromMeal, getMealTemplates } from "../mealTemplates";
|
||||||
|
import { createWorkoutTemplateFromActivities, getWorkoutTemplates } from "../workouts";
|
||||||
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import { ActivityUnit, MealType } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const LOGIN = 'tester';
|
||||||
|
const DATE = '2025-03-03';
|
||||||
|
|
||||||
|
beforeEach(() => resetMemoryStorage());
|
||||||
|
|
||||||
|
describe('překlopení jídla do šablony', () => {
|
||||||
|
|
||||||
|
const gyros = {
|
||||||
|
date: DATE, mealType: MealType.OBED, name: 'Kuřecí gyros',
|
||||||
|
source: 'TechTower', price: 17600, pricePer100g: 4400, caloriesPer100g: 172,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('převezme název, zdroj, gramáž i jednotkové hodnoty', async () => {
|
||||||
|
const day = await addMeal(LOGIN, gyros);
|
||||||
|
|
||||||
|
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||||
|
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0]).toMatchObject({
|
||||||
|
name: 'Kuřecí gyros',
|
||||||
|
source: 'TechTower',
|
||||||
|
mealType: MealType.OBED,
|
||||||
|
weight: 400,
|
||||||
|
caloriesPer100g: 172,
|
||||||
|
price: 17600,
|
||||||
|
pricePer100g: 4400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jídlo v dni zůstane beze změny', async () => {
|
||||||
|
const day = await addMeal(LOGIN, gyros);
|
||||||
|
|
||||||
|
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||||
|
|
||||||
|
expect((await getDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opakované uložení téhož jídla šablonu přepíše, nezaloží druhou', async () => {
|
||||||
|
const day = await addMeal(LOGIN, gyros);
|
||||||
|
await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||||
|
|
||||||
|
// Totéž jídlo jindy a s jinou gramáží
|
||||||
|
const later = await addMeal(LOGIN, { ...gyros, date: '2025-03-10', price: 22000 });
|
||||||
|
const templates = await createMealTemplateFromMeal(LOGIN, '2025-03-10', later.entries[0].id);
|
||||||
|
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0].weight).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('u jídla s ručními kaloriemi uloží absolutní hodnotu', async () => {
|
||||||
|
const day = await addMeal(LOGIN, {
|
||||||
|
date: DATE, mealType: MealType.SNIDANE, name: 'Jablko', calories: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = await createMealTemplateFromMeal(LOGIN, DATE, day.entries[0].id);
|
||||||
|
|
||||||
|
expect(templates[0].calories).toBe(80);
|
||||||
|
expect(templates[0].caloriesPer100g).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hlásí chybu u neexistujícího jídla', async () => {
|
||||||
|
await expect(createMealTemplateFromMeal(LOGIN, DATE, 'neexistuje'))
|
||||||
|
.rejects.toThrow(/nebylo nalezeno/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('překlopení aktivit do šablony tréninku', () => {
|
||||||
|
|
||||||
|
const kliky = {
|
||||||
|
date: DATE, name: 'Kliky', unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity: 40, sets: 3, caloriesPer100Units: 50,
|
||||||
|
};
|
||||||
|
const beh = {
|
||||||
|
date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY,
|
||||||
|
quantity: 20, caloriesPer100Units: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('bez výčtu vezme všechny aktivity dne', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
await addActivity(LOGIN, beh);
|
||||||
|
|
||||||
|
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3');
|
||||||
|
|
||||||
|
expect(templates).toHaveLength(1);
|
||||||
|
expect(templates[0].items.map(item => item.name)).toEqual(['Kliky', 'Běh']);
|
||||||
|
// 3 × 40 × 0,5 + 20 × 10
|
||||||
|
expect(templates[0].estimatedCalories).toBe(260);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('přenese i počet sérií', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
|
||||||
|
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||||
|
|
||||||
|
expect(templates[0].items[0].sets).toBe(3);
|
||||||
|
expect(templates[0].items[0].quantity).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('s výčtem vezme jen vybrané aktivity', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
await addActivity(LOGIN, beh);
|
||||||
|
const entries = (await getActivityDay(LOGIN, DATE)).entries;
|
||||||
|
const klikyId = entries.find(entry => entry.name === 'Kliky')!.id;
|
||||||
|
|
||||||
|
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Jen kliky', [klikyId]);
|
||||||
|
|
||||||
|
expect(templates[0].items).toHaveLength(1);
|
||||||
|
expect(templates[0].items[0].name).toBe('Kliky');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktivity v dni zůstanou beze změny', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
|
||||||
|
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Workout day 3');
|
||||||
|
|
||||||
|
expect((await getActivityDay(LOGIN, DATE)).entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktivita bez sazby dá položku bez odhadu kalorií', async () => {
|
||||||
|
await addActivity(LOGIN, {
|
||||||
|
date: DATE, name: 'Plavání', unit: ActivityUnit.MINUTY, quantity: 30, calories: 250,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Bazén');
|
||||||
|
|
||||||
|
expect(templates[0].items[0].caloriesPer100Units).toBeUndefined();
|
||||||
|
expect(templates[0].estimatedCalories).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne den bez aktivit a den bez názvu', async () => {
|
||||||
|
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, 'Prázdný'))
|
||||||
|
.rejects.toThrow(/nejsou žádné aktivity/);
|
||||||
|
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
await expect(createWorkoutTemplateFromActivities(LOGIN, DATE, ' '))
|
||||||
|
.rejects.toThrow(/název šablony/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vytvořená šablona jde rovnou použít', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
const [template] = await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||||
|
|
||||||
|
const { applyWorkoutTemplate } = await import("../workouts");
|
||||||
|
await applyWorkoutTemplate(LOGIN, template.id, '2025-03-20');
|
||||||
|
|
||||||
|
const [entry] = (await getActivityDay(LOGIN, '2025-03-20')).entries;
|
||||||
|
expect(entry.name).toBe('Kliky');
|
||||||
|
expect(entry.sets).toBe(3);
|
||||||
|
expect(entry.calories).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablony jsou oddělené mezi uživateli', async () => {
|
||||||
|
await addActivity(LOGIN, kliky);
|
||||||
|
await createWorkoutTemplateFromActivities(LOGIN, DATE, 'Silový blok');
|
||||||
|
|
||||||
|
expect(await getWorkoutTemplates('nekdo-jiny')).toEqual([]);
|
||||||
|
expect(await getMealTemplates('nekdo-jiny')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
|
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
|
||||||
|
import { getActivityDay } from "../activities";
|
||||||
|
import { getSettings, saveBasalCalories, saveProfile } from "../settings";
|
||||||
|
import { computeBasalCalories } from "../profile";
|
||||||
|
import { getDayOverview } from "../dayOverview";
|
||||||
|
import { saveSleep } from "../sleep";
|
||||||
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import { ActivityUnit, Pohlavi, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const LOGIN = 'tester';
|
||||||
|
const DATE = '2025-03-03';
|
||||||
|
|
||||||
|
/** Cvik jako položka šablony. */
|
||||||
|
const cvik = (name: string, quantity: number, rate: number) => ({
|
||||||
|
kind: WorkoutItemKind.CVIK,
|
||||||
|
name,
|
||||||
|
unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity,
|
||||||
|
caloriesPer100Units: rate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Odkaz na jinou šablonu. */
|
||||||
|
const odkaz = (templateRefId: string) => ({ kind: WorkoutItemKind.SABLONA, templateRefId });
|
||||||
|
|
||||||
|
beforeEach(() => resetMemoryStorage());
|
||||||
|
|
||||||
|
describe('skládání šablon z jiných šablon', () => {
|
||||||
|
|
||||||
|
it('šablona složená z jiné sečte kalorie obou', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
|
||||||
|
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
|
||||||
|
odkaz(rozcvicka.id),
|
||||||
|
cvik('Kliky', 60, 50),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workout = templates.find(t => t.name === 'Workout day 1')!;
|
||||||
|
// 40 dřepů × 0,4 kcal + 60 kliků × 0,5 kcal
|
||||||
|
expect(workout.estimatedCalories).toBe(46);
|
||||||
|
expect(workout.hasNested).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doplní název a kalorie vnořené šablony', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||||
|
|
||||||
|
const workout = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Workout day 1')!;
|
||||||
|
expect(workout.items[0].templateRefName).toBe('Rozcvička');
|
||||||
|
expect(workout.items[0].templateRefCalories).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('použití rozbalí vnořenou šablonu na její cviky', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [
|
||||||
|
cvik('Dřepy', 40, 40),
|
||||||
|
cvik('Výpady', 20, 30),
|
||||||
|
]);
|
||||||
|
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
|
||||||
|
odkaz(rozcvicka.id),
|
||||||
|
cvik('Kliky', 60, 50),
|
||||||
|
]);
|
||||||
|
const workout = templates.find(t => t.name === 'Workout day 1')!;
|
||||||
|
|
||||||
|
await applyWorkoutTemplate(LOGIN, workout.id, DATE);
|
||||||
|
|
||||||
|
const day = await getActivityDay(LOGIN, DATE);
|
||||||
|
expect(day.entries.map(e => e.name)).toEqual(['Dřepy', 'Výpady', 'Kliky']);
|
||||||
|
expect(day.totalCalories).toBe(52);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('počítá, kolik jiných šablon danou šablonu používá', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||||
|
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 2', [odkaz(rozcvicka.id)]);
|
||||||
|
|
||||||
|
const updated = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Rozcvička')!;
|
||||||
|
expect(updated.usedByCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nedovolí zanoření o dvě úrovně', async () => {
|
||||||
|
const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [cvik('Dřepy', 40, 40)]);
|
||||||
|
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Střední', [odkaz(zaklad.id)]);
|
||||||
|
const stredni = templates.find(t => t.name === 'Střední')!;
|
||||||
|
|
||||||
|
// "Střední" už sama skládá jinou šablonu, takže ji nelze vnořit dál
|
||||||
|
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Vrchní', [odkaz(stredni.id)]))
|
||||||
|
.rejects.toThrow(/jen na jednu úroveň/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nedovolí začít vnořovat v šabloně, kterou už někdo skládá', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
const [jiny] = await saveWorkoutTemplate(LOGIN, undefined, 'Jiný blok', [cvik('Kliky', 60, 50)]);
|
||||||
|
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||||
|
|
||||||
|
// Rozcvičku už skládá Workout day 1, takže do ní nelze vnořit další šablonu
|
||||||
|
await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', [odkaz(jiny.id)]))
|
||||||
|
.rejects.toThrow(/jen na jednu úroveň/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nedovolí odkaz sám na sebe', async () => {
|
||||||
|
const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
|
||||||
|
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)]))
|
||||||
|
.rejects.toThrow(/sama na sebe/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nedovolí odkaz na neexistující šablonu', async () => {
|
||||||
|
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')]))
|
||||||
|
.rejects.toThrow(/nebyla nalezena/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
|
||||||
|
|
||||||
|
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablonu bez vazeb smazat lze', async () => {
|
||||||
|
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
|
||||||
|
|
||||||
|
expect(await deleteWorkoutTemplate(LOGIN, rozcvicka.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablonám uloženým bez druhu položky doplní CVIK', async () => {
|
||||||
|
// Podoba dat z doby, kdy skládání z jiných šablon ještě neexistovalo
|
||||||
|
const { default: getStorage } = await import("../storage");
|
||||||
|
await getStorage().setData('workouts:' + LOGIN, [{
|
||||||
|
id: 'stara', name: 'Stará šablona',
|
||||||
|
items: [{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 }],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const [template] = await getWorkoutTemplates(LOGIN);
|
||||||
|
expect(template.items[0].kind).toBe(WorkoutItemKind.CVIK);
|
||||||
|
expect(template.estimatedCalories).toBe(30);
|
||||||
|
expect(template.hasNested).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tělesné údaje a bazální metabolismus', () => {
|
||||||
|
|
||||||
|
/** Muž, 90 kg, 185 cm, ročník 1990. */
|
||||||
|
const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 };
|
||||||
|
|
||||||
|
it('spočítá klidový výdej rovnicí Mifflin–St Jeor', () => {
|
||||||
|
const age = new Date().getFullYear() - 1990;
|
||||||
|
// 10 × 90 + 6,25 × 185 − 5 × věk + 5
|
||||||
|
const expected = Math.round(900 + 1156.25 - 5 * age + 5);
|
||||||
|
|
||||||
|
expect(computeBasalCalories(muz)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('u ženy odečítá 161 místo přičtení 5', () => {
|
||||||
|
const zena = { ...muz, sex: Pohlavi.ZENA };
|
||||||
|
|
||||||
|
expect(computeBasalCalories(muz)! - computeBasalCalories(zena)!).toBe(166);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neúplný profil klidový výdej nespočítá', () => {
|
||||||
|
expect(computeBasalCalories(undefined)).toBeUndefined();
|
||||||
|
expect(computeBasalCalories({ sex: Pohlavi.MUZ, heightCm: 185 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uložený profil se propíše do nastavení i do bilance dne', async () => {
|
||||||
|
await saveProfile(LOGIN, muz);
|
||||||
|
|
||||||
|
const settings = await getSettings(LOGIN);
|
||||||
|
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
|
||||||
|
expect(settings.effectiveBasalCalories).toBe(computeBasalCalories(muz));
|
||||||
|
|
||||||
|
const overview = await getDayOverview(LOGIN, DATE);
|
||||||
|
expect(overview.energy.basal).toBe(computeBasalCalories(muz));
|
||||||
|
expect(overview.energy.hasBasal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ručně zadaný klidový výdej má přednost před spočítaným', async () => {
|
||||||
|
await saveProfile(LOGIN, muz);
|
||||||
|
await saveBasalCalories(LOGIN, 1800);
|
||||||
|
|
||||||
|
const settings = await getSettings(LOGIN);
|
||||||
|
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
|
||||||
|
expect(settings.effectiveBasalCalories).toBe(1800);
|
||||||
|
expect((await getDayOverview(LOGIN, DATE)).energy.basal).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('po smazání ručního údaje se vrátí spočítaný z profilu', async () => {
|
||||||
|
await saveProfile(LOGIN, muz);
|
||||||
|
await saveBasalCalories(LOGIN, 1800);
|
||||||
|
await saveBasalCalories(LOGIN, 0);
|
||||||
|
|
||||||
|
expect((await getSettings(LOGIN)).effectiveBasalCalories).toBe(computeBasalCalories(muz));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne nesmyslné tělesné údaje', async () => {
|
||||||
|
await expect(saveProfile(LOGIN, { ...muz, heightCm: 5 })).rejects.toThrow(/mimo rozsah/);
|
||||||
|
await expect(saveProfile(LOGIN, { ...muz, weightGrams: 5_000_000 })).rejects.toThrow(/mimo rozsah/);
|
||||||
|
await expect(saveProfile(LOGIN, { ...muz, sex: 'JINE' })).rejects.toThrow(/pohlaví/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uložení profilu nepřepíše sazby podniků', async () => {
|
||||||
|
const { saveSourceRate } = await import("../settings");
|
||||||
|
await saveSourceRate(LOGIN, 'TechTower', 4400);
|
||||||
|
|
||||||
|
await saveProfile(LOGIN, muz);
|
||||||
|
|
||||||
|
expect((await getSettings(LOGIN)).sourceRates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spánek', () => {
|
||||||
|
|
||||||
|
it('uloží se ke dni a vrátí v přehledu', async () => {
|
||||||
|
await saveSleep(LOGIN, DATE, 450);
|
||||||
|
|
||||||
|
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBe(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulová hodnota záznam odstraní', async () => {
|
||||||
|
await saveSleep(LOGIN, DATE, 450);
|
||||||
|
await saveSleep(LOGIN, DATE, 0);
|
||||||
|
|
||||||
|
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('do energetické bilance nevstupuje', async () => {
|
||||||
|
await saveProfile(LOGIN, { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 });
|
||||||
|
const bezSpanku = await getDayOverview(LOGIN, DATE);
|
||||||
|
|
||||||
|
await saveSleep(LOGIN, DATE, 450);
|
||||||
|
const seSpankem = await getDayOverview(LOGIN, DATE);
|
||||||
|
|
||||||
|
// Klidový výdej pokrývá celých 24 hodin včetně spánku, bilance se měnit nesmí
|
||||||
|
expect(seSpankem.energy).toEqual(bezSpanku.energy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne nesmyslnou délku', async () => {
|
||||||
|
await expect(saveSleep(LOGIN, DATE, 2000)).rejects.toThrow(/mimo rozsah/);
|
||||||
|
await expect(saveSleep(LOGIN, 'včera', 450)).rejects.toThrow(/platné datum/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('je oddělený mezi uživateli', async () => {
|
||||||
|
await saveSleep(LOGIN, DATE, 450);
|
||||||
|
|
||||||
|
expect((await getDayOverview('nekdo-jiny', DATE)).sleepMinutes).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+206
-44
@@ -1,7 +1,9 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen";
|
import {
|
||||||
|
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
|
||||||
|
} from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { addActivities, isActivityUnit } from "./activities";
|
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -10,38 +12,99 @@ const storage = getStorage();
|
|||||||
const MAX_ITEMS = 50;
|
const MAX_ITEMS = 50;
|
||||||
|
|
||||||
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
|
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
|
||||||
function getKey(login: string): string {
|
const getKey = (login: string): string => `workouts:${login}`;
|
||||||
return `workouts:${login}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Spočítá odhad spálených kcal za celou šablonu. */
|
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */
|
||||||
function estimateCalories(items: WorkoutTemplateItem[]): number {
|
type StoredTemplate = {
|
||||||
return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0);
|
id: string;
|
||||||
}
|
name: string;
|
||||||
|
items: WorkoutTemplateItem[];
|
||||||
|
};
|
||||||
|
|
||||||
/** Doplní šabloně dopočítaný odhad kalorií. */
|
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
|
||||||
function withEstimate(template: Omit<WorkoutTemplate, 'estimatedCalories'>): WorkoutTemplate {
|
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA;
|
||||||
return { ...template, estimatedCalories: estimateCalories(template.items) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uložená podoba šablony — odhad kalorií se dopočítává, neukládá se. */
|
/**
|
||||||
type StoredTemplate = Omit<WorkoutTemplate, 'estimatedCalories'>;
|
* Doplní položkám druh, pokud chybí.
|
||||||
|
*
|
||||||
|
* Šablony uložené dřív, než přibylo skládání z jiných šablon, pole `kind`
|
||||||
|
* nemají — všechny jejich položky jsou cviky.
|
||||||
|
*/
|
||||||
|
const withKind = (template: StoredTemplate): StoredTemplate => ({
|
||||||
|
...template,
|
||||||
|
items: template.items.map(item => ({ ...item, kind: item.kind ?? WorkoutItemKind.CVIK })),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spočítá kcal jedné položky druhu CVIK, včetně všech jejích sérií. */
|
||||||
|
const itemCalories = (item: WorkoutTemplateItem): number =>
|
||||||
|
Math.round(getTotalQuantity(item.quantity ?? 0, item.sets) / 100 * (item.caloriesPer100Units ?? 0));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplní šabloně dopočítávaná pole — odhad kalorií (včetně vnořených šablon),
|
||||||
|
* názvy vnořených šablon a informaci, kolik jiných šablon ji používá.
|
||||||
|
*/
|
||||||
|
const withDerived = (template: StoredTemplate, all: StoredTemplate[]): WorkoutTemplate => {
|
||||||
|
const byId = new Map(all.map(item => [item.id, item]));
|
||||||
|
let calories = 0;
|
||||||
|
let hasNested = false;
|
||||||
|
|
||||||
|
const items = template.items.map(item => {
|
||||||
|
if (!isTemplateRef(item)) {
|
||||||
|
calories += itemCalories(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
hasNested = true;
|
||||||
|
const nested = item.templateRefId ? byId.get(item.templateRefId) : undefined;
|
||||||
|
// Vnořená šablona smí obsahovat jen cviky, takže stačí sečíst její položky
|
||||||
|
const nestedCalories = (nested?.items ?? []).reduce((sum, nestedItem) => sum + itemCalories(nestedItem), 0);
|
||||||
|
calories += nestedCalories;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(nested ? { templateRefName: nested.name } : {}),
|
||||||
|
templateRefCalories: nestedCalories,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
items,
|
||||||
|
estimatedCalories: calories,
|
||||||
|
hasNested,
|
||||||
|
usedByCount: all.filter(other => other.id !== template.id
|
||||||
|
&& other.items.some(item => isTemplateRef(item) && item.templateRefId === template.id)).length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Načte uložené šablony uživatele se doplněným druhem položek. */
|
||||||
|
const loadTemplates = async (login: string): Promise<StoredTemplate[]> =>
|
||||||
|
(await storage.getData<StoredTemplate[]>(getKey(login)) ?? []).map(withKind);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí šablony tréninků uživatele seřazené podle názvu.
|
* Vrátí šablony tréninků uživatele seřazené podle názvu.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
*/
|
*/
|
||||||
export async function getWorkoutTemplates(login: string): Promise<WorkoutTemplate[]> {
|
export const getWorkoutTemplates = async (login: string): Promise<WorkoutTemplate[]> => {
|
||||||
const templates = await storage.getData<StoredTemplate[]>(getKey(login)) ?? [];
|
const stored = await loadTemplates(login);
|
||||||
return templates
|
return stored
|
||||||
.map(withEstimate)
|
.map(template => withDerived(template, stored))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Ověří a znormalizuje jednu položku šablony. */
|
/** Ověří a znormalizuje jednu položku šablony. */
|
||||||
function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||||
const value = (raw ?? {}) as Record<string, unknown>;
|
const value = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const kind = value.kind === WorkoutItemKind.SABLONA ? WorkoutItemKind.SABLONA : WorkoutItemKind.CVIK;
|
||||||
|
|
||||||
|
if (kind === WorkoutItemKind.SABLONA) {
|
||||||
|
if (typeof value.templateRefId !== 'string' || !value.templateRefId.length) {
|
||||||
|
throw new BadRequestError(`Položka ${index + 1} neodkazuje na žádnou šablonu`);
|
||||||
|
}
|
||||||
|
// Název a odhad kalorií vnořené šablony si server dopočítá sám
|
||||||
|
return { kind, templateRefId: value.templateRefId };
|
||||||
|
}
|
||||||
|
|
||||||
const name = typeof value.name === 'string' ? value.name.trim() : '';
|
const name = typeof value.name === 'string' ? value.name.trim() : '';
|
||||||
if (!name.length) {
|
if (!name.length) {
|
||||||
throw new BadRequestError(`Položka ${index + 1} nemá název`);
|
throw new BadRequestError(`Položka ${index + 1} nemá název`);
|
||||||
@@ -53,15 +116,56 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
|||||||
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
|
throw new BadRequestError(`Položka '${name}' nemá platné množství`);
|
||||||
}
|
}
|
||||||
const rate = value.caloriesPer100Units;
|
const rate = value.caloriesPer100Units;
|
||||||
|
const sets = value.sets;
|
||||||
return {
|
return {
|
||||||
|
kind,
|
||||||
name,
|
name,
|
||||||
unit: value.unit,
|
unit: value.unit as ActivityUnit,
|
||||||
quantity: Math.round(value.quantity),
|
quantity: Math.round(value.quantity),
|
||||||
|
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
|
...(typeof sets === 'number' && Number.isFinite(sets) && sets > 1
|
||||||
|
? { sets: Math.round(sets) }
|
||||||
|
: {}),
|
||||||
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
|
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
|
||||||
? { caloriesPer100Units: Math.round(rate) }
|
? { caloriesPer100Units: Math.round(rate) }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ověří, že se zanořením šablon nepřekročí jedna úroveň.
|
||||||
|
*
|
||||||
|
* Kontroluje se obojí směr, protože obě cesty vedou ke dvěma úrovním:
|
||||||
|
* šablona nesmí odkazovat na šablonu, která sama něco vnořuje, a zároveň
|
||||||
|
* nesmí sama začít vnořovat, pokud ji už někdo jiný používá.
|
||||||
|
*/
|
||||||
|
const checkNesting = (id: string, items: WorkoutTemplateItem[], all: StoredTemplate[]): void => {
|
||||||
|
const byId = new Map(all.map(template => [template.id, template]));
|
||||||
|
const nestedRefs = items.filter(isTemplateRef);
|
||||||
|
|
||||||
|
for (const item of nestedRefs) {
|
||||||
|
if (item.templateRefId === id) {
|
||||||
|
throw new BadRequestError("Šablona nemůže odkazovat sama na sebe");
|
||||||
|
}
|
||||||
|
const nested = byId.get(item.templateRefId!);
|
||||||
|
if (!nested) {
|
||||||
|
throw new NotFoundError("Vnořená šablona nebyla nalezena");
|
||||||
|
}
|
||||||
|
if (nested.items.some(isTemplateRef)) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`Šablona '${nested.name}' už sama skládá jiné šablony, nelze ji vnořit — zanoření je povolené jen na jednu úroveň`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nestedRefs.length) {
|
||||||
|
const usedBy = all.find(template => template.id !== id
|
||||||
|
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
|
||||||
|
if (usedBy) {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`Tuto šablonu už skládá šablona '${usedBy.name}', takže do ní nelze vnořit další — zanoření je povolené jen na jednu úroveň`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Založí novou šablonu, nebo přepíše existující podle předaného id.
|
* Založí novou šablonu, nebo přepíše existující podle předaného id.
|
||||||
@@ -71,12 +175,12 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
|
|||||||
* @param name název šablony
|
* @param name název šablony
|
||||||
* @param items položky šablony
|
* @param items položky šablony
|
||||||
*/
|
*/
|
||||||
export async function saveWorkoutTemplate(
|
export const saveWorkoutTemplate = async (
|
||||||
login: string,
|
login: string,
|
||||||
id: unknown,
|
id: unknown,
|
||||||
name: unknown,
|
name: unknown,
|
||||||
items: unknown,
|
items: unknown,
|
||||||
): Promise<WorkoutTemplate[]> {
|
): Promise<WorkoutTemplate[]> => {
|
||||||
if (typeof name !== 'string' || !name.trim().length) {
|
if (typeof name !== 'string' || !name.trim().length) {
|
||||||
throw new BadRequestError("Nebyl předán název šablony");
|
throw new BadRequestError("Nebyl předán název šablony");
|
||||||
}
|
}
|
||||||
@@ -86,42 +190,49 @@ export async function saveWorkoutTemplate(
|
|||||||
if (items.length > MAX_ITEMS) {
|
if (items.length > MAX_ITEMS) {
|
||||||
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
|
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized: StoredTemplate = {
|
const normalized: StoredTemplate = {
|
||||||
id: typeof id === 'string' && id.length ? id : randomUUID(),
|
id: typeof id === 'string' && id.length ? id : randomUUID(),
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
items: items.map(normalizeItem),
|
items: items.map(normalizeItem),
|
||||||
};
|
};
|
||||||
|
checkNesting(normalized.id, normalized.items, await loadTemplates(login));
|
||||||
|
|
||||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
||||||
const others = (current ?? []).filter(template => template.id !== normalized.id);
|
const others = (current ?? []).filter(template => template.id !== normalized.id);
|
||||||
return [...others, normalized];
|
return [...others, normalized];
|
||||||
});
|
});
|
||||||
return getWorkoutTemplates(login);
|
return getWorkoutTemplates(login);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smaže šablonu tréninku.
|
* Smaže šablonu tréninku. Šablonu, kterou skládá jiná, smazat nelze —
|
||||||
|
* jinak by ve složené šabloně zůstal odkaz do prázdna.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param id identifikátor šablony
|
* @param id identifikátor šablony
|
||||||
*/
|
*/
|
||||||
export async function deleteWorkoutTemplate(login: string, id: unknown): Promise<WorkoutTemplate[]> {
|
export const deleteWorkoutTemplate = async (login: string, id: unknown): Promise<WorkoutTemplate[]> => {
|
||||||
if (typeof id !== 'string' || !id.length) {
|
if (typeof id !== 'string' || !id.length) {
|
||||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||||
}
|
}
|
||||||
let found = false;
|
const all = await loadTemplates(login);
|
||||||
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
|
if (!all.some(template => template.id === id)) {
|
||||||
found = (current ?? []).some(template => template.id === id);
|
|
||||||
return (current ?? []).filter(template => template.id !== id);
|
|
||||||
});
|
|
||||||
if (!found) {
|
|
||||||
throw new NotFoundError("Šablona nebyla nalezena");
|
throw new NotFoundError("Šablona nebyla nalezena");
|
||||||
}
|
}
|
||||||
|
const usedBy = all.find(template => template.id !== id
|
||||||
|
&& template.items.some(item => isTemplateRef(item) && item.templateRefId === id));
|
||||||
|
if (usedBy) {
|
||||||
|
throw new BadRequestError(`Šablonu nelze smazat, protože ji skládá šablona '${usedBy.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateData<StoredTemplate[]>(getKey(login),
|
||||||
|
current => (current ?? []).filter(template => template.id !== id));
|
||||||
return getWorkoutTemplates(login);
|
return getWorkoutTemplates(login);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Založí do zvoleného dne všechny položky šablony.
|
* Založí do zvoleného dne všechny cviky šablony, včetně těch z vnořených šablon.
|
||||||
*
|
*
|
||||||
* Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat,
|
* Vzniknou z nich běžné aktivity, takže je pak jde jednotlivě upravit nebo smazat,
|
||||||
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
|
* aniž by to ovlivnilo šablonu. `templateId` u nich zůstane jen jako informace,
|
||||||
@@ -131,31 +242,82 @@ export async function deleteWorkoutTemplate(login: string, id: unknown): Promise
|
|||||||
* @param id identifikátor šablony
|
* @param id identifikátor šablony
|
||||||
* @param date den, do kterého se položky založí
|
* @param date den, do kterého se položky založí
|
||||||
*/
|
*/
|
||||||
export async function applyWorkoutTemplate(login: string, id: unknown, date: unknown): Promise<string> {
|
export const applyWorkoutTemplate = async (login: string, id: unknown, date: unknown): Promise<string> => {
|
||||||
if (typeof id !== 'string' || !id.length) {
|
if (typeof id !== 'string' || !id.length) {
|
||||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||||
}
|
}
|
||||||
if (!isValidIsoDate(date)) {
|
if (!isValidIsoDate(date)) {
|
||||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
}
|
}
|
||||||
const template = (await getWorkoutTemplates(login)).find(item => item.id === id);
|
const all = await loadTemplates(login);
|
||||||
|
const template = all.find(item => item.id === id);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new NotFoundError("Šablona nebyla nalezena");
|
throw new NotFoundError("Šablona nebyla nalezena");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Odkazy na vnořené šablony se rozbalí na jejich cviky; hlubší zanoření nemůže vzniknout
|
||||||
|
const byId = new Map(all.map(item => [item.id, item]));
|
||||||
|
const exercises = template.items.flatMap(item =>
|
||||||
|
isTemplateRef(item) ? (byId.get(item.templateRefId!)?.items ?? []) : [item]);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const entries: ActivityEntry[] = template.items.map(item => ({
|
const entries: ActivityEntry[] = exercises.map(item => ({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
date,
|
date,
|
||||||
name: item.name,
|
name: item.name!,
|
||||||
unit: item.unit,
|
unit: item.unit!,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity!,
|
||||||
|
...(item.sets != null ? { sets: item.sets } : {}),
|
||||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||||
calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)),
|
calories: itemCalories(item),
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await addActivities(login, date, entries);
|
await addActivities(login, date, entries);
|
||||||
return date;
|
return date;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
|
||||||
|
*
|
||||||
|
* Slouží k opačnému směru než {@link applyWorkoutTemplate}: trénink odcvičený
|
||||||
|
* "na truc" jde takhle povýšit na šablonu a příště použít jedním kliknutím.
|
||||||
|
* Aktivity zůstanou v dni beze změny.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date den, ze kterého se aktivity berou
|
||||||
|
* @param name název nové šablony
|
||||||
|
* @param ids identifikátory aktivit; bez nich se použijí všechny aktivity dne
|
||||||
|
*/
|
||||||
|
export const createWorkoutTemplateFromActivities = async (
|
||||||
|
login: string,
|
||||||
|
date: unknown,
|
||||||
|
name: unknown,
|
||||||
|
ids?: unknown,
|
||||||
|
): Promise<WorkoutTemplate[]> => {
|
||||||
|
if (!isValidIsoDate(date)) {
|
||||||
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
if (ids != null && !Array.isArray(ids)) {
|
||||||
|
throw new BadRequestError("Seznam aktivit musí být pole identifikátorů");
|
||||||
|
}
|
||||||
|
const selected = ids == null ? undefined : new Set(ids.map(String));
|
||||||
|
const activities = (await getActivityDay(login, date)).entries
|
||||||
|
.filter(entry => !selected || selected.has(entry.id));
|
||||||
|
if (!activities.length) {
|
||||||
|
throw new BadRequestError("V tomto dni nejsou žádné aktivity, ze kterých by šla šablona vytvořit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = activities.map(entry => ({
|
||||||
|
kind: WorkoutItemKind.CVIK,
|
||||||
|
name: entry.name,
|
||||||
|
unit: entry.unit,
|
||||||
|
quantity: entry.quantity,
|
||||||
|
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||||
|
// Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá
|
||||||
|
...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return saveWorkoutTemplate(login, undefined, name, items);
|
||||||
|
};
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ paths:
|
|||||||
# Přehled dne (/api/day)
|
# Přehled dne (/api/day)
|
||||||
/day:
|
/day:
|
||||||
$ref: "./paths/day/overview.yml"
|
$ref: "./paths/day/overview.yml"
|
||||||
|
/day/sleep:
|
||||||
|
$ref: "./paths/day/sleep.yml"
|
||||||
|
|
||||||
# Pohyb (/api/activities)
|
# Pohyb (/api/activities)
|
||||||
/activities/add:
|
/activities/add:
|
||||||
@@ -48,6 +50,20 @@ paths:
|
|||||||
$ref: "./paths/workouts/delete.yml"
|
$ref: "./paths/workouts/delete.yml"
|
||||||
/workouts/apply:
|
/workouts/apply:
|
||||||
$ref: "./paths/workouts/apply.yml"
|
$ref: "./paths/workouts/apply.yml"
|
||||||
|
/workouts/fromActivities:
|
||||||
|
$ref: "./paths/workouts/fromActivities.yml"
|
||||||
|
|
||||||
|
# Šablony jídel (/api/mealTemplates)
|
||||||
|
/mealTemplates:
|
||||||
|
$ref: "./paths/mealTemplates/list.yml"
|
||||||
|
/mealTemplates/save:
|
||||||
|
$ref: "./paths/mealTemplates/save.yml"
|
||||||
|
/mealTemplates/delete:
|
||||||
|
$ref: "./paths/mealTemplates/delete.yml"
|
||||||
|
/mealTemplates/apply:
|
||||||
|
$ref: "./paths/mealTemplates/apply.yml"
|
||||||
|
/mealTemplates/fromMeal:
|
||||||
|
$ref: "./paths/mealTemplates/fromMeal.yml"
|
||||||
|
|
||||||
# Kalorie (/api/calories)
|
# Kalorie (/api/calories)
|
||||||
/calories/search:
|
/calories/search:
|
||||||
@@ -58,6 +74,8 @@ paths:
|
|||||||
$ref: "./paths/settings/settings.yml"
|
$ref: "./paths/settings/settings.yml"
|
||||||
/settings/basal:
|
/settings/basal:
|
||||||
$ref: "./paths/settings/basal.yml"
|
$ref: "./paths/settings/basal.yml"
|
||||||
|
/settings/profile:
|
||||||
|
$ref: "./paths/settings/profile.yml"
|
||||||
|
|
||||||
# Importy (/api/import)
|
# Importy (/api/import)
|
||||||
/import/luncher:
|
/import/luncher:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
post:
|
||||||
|
operationId: saveSleep
|
||||||
|
summary: Uloží délku spánku pro zvolený den.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [date]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
sleepMinutes:
|
||||||
|
description: Délka spánku v minutách. Null nebo 0 záznam odstraní.
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný přehled dne
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayOverview"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
post:
|
||||||
|
operationId: applyMealTemplate
|
||||||
|
summary: Založí jídlo ze šablony do zvoleného dne.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, date]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor šablony
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
description: Den, do kterého se jídlo založí
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
mealType:
|
||||||
|
description: Chod. Bez něj se použije chod ze šablony, jinak oběd.
|
||||||
|
allOf:
|
||||||
|
- $ref: "../../schemas/_index.yml#/MealType"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný přehled dne
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayOverview"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
|
"404":
|
||||||
|
description: Šablona neexistuje
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteMealTemplate
|
||||||
|
summary: Smaže šablonu jídla.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam šablon
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealTemplate"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
post:
|
||||||
|
operationId: createMealTemplateFromMeal
|
||||||
|
summary: Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, date]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor jídla, ze kterého se šablona vytvoří
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
description: Den, ve kterém jídlo je
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam šablon
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealTemplate"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
|
"404":
|
||||||
|
description: Jídlo neexistuje
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
get:
|
||||||
|
operationId: getMealTemplates
|
||||||
|
summary: Vrátí šablony jídel přihlášeného uživatele.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Šablony seřazené podle názvu
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealTemplate"
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
post:
|
||||||
|
operationId: saveMealTemplate
|
||||||
|
summary: Založí novou šablonu jídla, nebo upraví existující.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealTemplateInput"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam šablon
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealTemplate"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
post:
|
||||||
|
operationId: saveProfile
|
||||||
|
summary: Uloží tělesné údaje uživatele a přepočte z nich klidový výdej.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/PersonalProfile"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizované nastavení včetně dopočteného klidového výdeje
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/UserSettings"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
post:
|
||||||
|
operationId: createWorkoutTemplateFromActivities
|
||||||
|
summary: |
|
||||||
|
Vytvoří šablonu tréninku z aktivit zaznamenaných v přehledu dne.
|
||||||
|
Bez výčtu identifikátorů se použijí všechny aktivity daného dne.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [date, name]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
description: Den, ze kterého se aktivity berou
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
name:
|
||||||
|
description: Název nové šablony
|
||||||
|
type: string
|
||||||
|
ids:
|
||||||
|
description: |
|
||||||
|
Identifikátory aktivit, které se do šablony dostanou.
|
||||||
|
Bez nich se použijí všechny aktivity dne.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam šablon
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/WorkoutTemplate"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
+187
-9
@@ -444,8 +444,21 @@ UserSettings:
|
|||||||
$ref: "#/SourceRate"
|
$ref: "#/SourceRate"
|
||||||
basalCalories:
|
basalCalories:
|
||||||
description: |
|
description: |
|
||||||
Klidový výdej (bazální metabolismus) v kcal za den. Bez něj se bilance dne
|
Ručně zadaný klidový výdej v kcal za den. Má přednost před hodnotou
|
||||||
počítá jen jako jídlo proti pohybu a nejde o skutečný deficit.
|
spočítanou z tělesných údajů. Bez obojího se bilance dne počítá jen
|
||||||
|
jako jídlo proti pohybu a nejde o skutečný deficit.
|
||||||
|
type: integer
|
||||||
|
profile:
|
||||||
|
$ref: "#/PersonalProfile"
|
||||||
|
computedBasalCalories:
|
||||||
|
description: |
|
||||||
|
Klidový výdej spočítaný z tělesných údajů rovnicí Mifflin–St Jeor.
|
||||||
|
Doplňuje server, při ukládání se ignoruje. Chybí, pokud profil není úplný.
|
||||||
|
type: integer
|
||||||
|
effectiveBasalCalories:
|
||||||
|
description: |
|
||||||
|
Klidový výdej, který se reálně použije v bilanci dne — ručně zadaný,
|
||||||
|
jinak spočítaný z profilu. Doplňuje server.
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
ActivityUnit:
|
ActivityUnit:
|
||||||
@@ -482,7 +495,15 @@ ActivityEntry:
|
|||||||
unit:
|
unit:
|
||||||
$ref: "#/ActivityUnit"
|
$ref: "#/ActivityUnit"
|
||||||
quantity:
|
quantity:
|
||||||
description: Množství v dané jednotce — počet kroků, minut, kilometrů nebo opakování
|
description: |
|
||||||
|
Množství v dané jednotce na jednu sérii — počet kroků, minut, kilometrů
|
||||||
|
nebo opakování.
|
||||||
|
type: integer
|
||||||
|
sets:
|
||||||
|
description: |
|
||||||
|
Počet sérií. Celkové množství je sets × quantity, z něj se počítají kalorie.
|
||||||
|
Chybí-li, počítá se s jednou sérií. Drží se odděleně od množství, protože
|
||||||
|
3 × 40 opakování s pauzami je jiný trénink než 120 opakování v kuse.
|
||||||
type: integer
|
type: integer
|
||||||
caloriesPer100Units:
|
caloriesPer100Units:
|
||||||
description: |
|
description: |
|
||||||
@@ -528,8 +549,12 @@ ActivityInput:
|
|||||||
unit:
|
unit:
|
||||||
$ref: "#/ActivityUnit"
|
$ref: "#/ActivityUnit"
|
||||||
quantity:
|
quantity:
|
||||||
description: Množství v dané jednotce
|
description: Množství v dané jednotce na jednu sérii
|
||||||
type: integer
|
type: integer
|
||||||
|
sets:
|
||||||
|
description: Počet sérií. Nevyplněno znamená jednu.
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
caloriesPer100Units:
|
caloriesPer100Units:
|
||||||
description: Spálené kcal na 100 jednotek
|
description: Spálené kcal na 100 jednotek
|
||||||
type: integer
|
type: integer
|
||||||
@@ -564,21 +589,38 @@ ActivityDay:
|
|||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
WorkoutTemplateItem:
|
WorkoutTemplateItem:
|
||||||
description: Jedna položka šablony tréninku
|
description: |
|
||||||
|
Jedna položka šablony tréninku — buď konkrétní cvik, nebo odkaz na jinou
|
||||||
|
šablonu, jejíž cviky se při použití rozbalí.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
- name
|
- kind
|
||||||
- unit
|
|
||||||
- quantity
|
|
||||||
properties:
|
properties:
|
||||||
|
kind:
|
||||||
|
$ref: "#/WorkoutItemKind"
|
||||||
name:
|
name:
|
||||||
|
description: Název cviku. Povinný u položky druhu CVIK.
|
||||||
type: string
|
type: string
|
||||||
unit:
|
unit:
|
||||||
$ref: "#/ActivityUnit"
|
$ref: "#/ActivityUnit"
|
||||||
quantity:
|
quantity:
|
||||||
|
description: Množství v dané jednotce na jednu sérii. Povinné u položky druhu CVIK.
|
||||||
|
type: integer
|
||||||
|
sets:
|
||||||
|
description: Počet sérií. Nevyplněno znamená jednu.
|
||||||
type: integer
|
type: integer
|
||||||
caloriesPer100Units:
|
caloriesPer100Units:
|
||||||
|
description: Spálené kcal na 100 jednotek
|
||||||
|
type: integer
|
||||||
|
templateRefId:
|
||||||
|
description: Identifikátor vnořené šablony. Povinný u položky druhu SABLONA.
|
||||||
|
type: string
|
||||||
|
templateRefName:
|
||||||
|
description: Název vnořené šablony. Doplňuje server, při ukládání se ignoruje.
|
||||||
|
type: string
|
||||||
|
templateRefCalories:
|
||||||
|
description: Odhad kcal vnořené šablony. Doplňuje server, při ukládání se ignoruje.
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
WorkoutTemplate:
|
WorkoutTemplate:
|
||||||
@@ -592,6 +634,8 @@ WorkoutTemplate:
|
|||||||
- name
|
- name
|
||||||
- items
|
- items
|
||||||
- estimatedCalories
|
- estimatedCalories
|
||||||
|
- hasNested
|
||||||
|
- usedByCount
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -603,7 +647,15 @@ WorkoutTemplate:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/WorkoutTemplateItem"
|
$ref: "#/WorkoutTemplateItem"
|
||||||
estimatedCalories:
|
estimatedCalories:
|
||||||
description: Odhad spálených kcal za celou šablonu
|
description: Odhad spálených kcal za celou šablonu včetně vnořených
|
||||||
|
type: integer
|
||||||
|
hasNested:
|
||||||
|
description: |
|
||||||
|
True, pokud šablona obsahuje odkaz na jinou šablonu. Takovou šablonu
|
||||||
|
už nelze vnořit do další — zanoření je povolené jen na jednu úroveň.
|
||||||
|
type: boolean
|
||||||
|
usedByCount:
|
||||||
|
description: Kolik jiných šablon tuto šablonu používá
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
EnergyBalance:
|
EnergyBalance:
|
||||||
@@ -662,3 +714,129 @@ DayOverview:
|
|||||||
$ref: "#/ActivityDay"
|
$ref: "#/ActivityDay"
|
||||||
energy:
|
energy:
|
||||||
$ref: "#/EnergyBalance"
|
$ref: "#/EnergyBalance"
|
||||||
|
sleepMinutes:
|
||||||
|
description: |
|
||||||
|
Délka spánku v minutách. Eviduje se jako kontext dne — do energetické
|
||||||
|
bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin
|
||||||
|
včetně spánku a žádná standardní rovnice délku spánku nezohledňuje.
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
Pohlavi:
|
||||||
|
description: Pohlaví — vstupuje do výpočtu bazálního metabolismu
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- MUZ
|
||||||
|
- ZENA
|
||||||
|
|
||||||
|
PersonalProfile:
|
||||||
|
description: |
|
||||||
|
Tělesné údaje uživatele. Slouží k výpočtu bazálního metabolismu
|
||||||
|
(klidového výdeje) rovnicí Mifflin–St Jeor.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
sex:
|
||||||
|
$ref: "#/Pohlavi"
|
||||||
|
birthYear:
|
||||||
|
description: Rok narození, ze kterého se odvozuje věk
|
||||||
|
type: integer
|
||||||
|
heightCm:
|
||||||
|
description: Výška v centimetrech
|
||||||
|
type: integer
|
||||||
|
weightGrams:
|
||||||
|
description: Hmotnost v gramech. Celé číslo, aby se počítalo bez desetinných chyb.
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
WorkoutItemKind:
|
||||||
|
description: |
|
||||||
|
Druh položky šablony — buď přímo cvik, nebo odkaz na jinou šablonu.
|
||||||
|
Zanoření je povolené jen na jednu úroveň.
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- CVIK
|
||||||
|
- SABLONA
|
||||||
|
|
||||||
|
MealTemplate:
|
||||||
|
description: |
|
||||||
|
Předpřipravené jídlo. Použitím se založí do zvoleného dne, takže opakovaně
|
||||||
|
jedené jídlo není potřeba zadávat znovu. Na rozdíl od šablon tréninků se
|
||||||
|
šablony jídel neskládají z jiných — jsou to ploché záznamy.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
description: Název jídla
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
description: Zdroj jídla (podnik, obchod, "doma", ...)
|
||||||
|
type: string
|
||||||
|
mealType:
|
||||||
|
description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd.
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/MealType"
|
||||||
|
calories:
|
||||||
|
description: |
|
||||||
|
Energetická hodnota celé porce v kcal. Použije se, když jídlo nemá
|
||||||
|
vyplněnou gramáž a energii na 100 g.
|
||||||
|
type: integer
|
||||||
|
caloriesPer100g:
|
||||||
|
description: Energetická hodnota na 100 g v kcal
|
||||||
|
type: integer
|
||||||
|
weight:
|
||||||
|
description: Obvyklá gramáž porce v gramech
|
||||||
|
type: integer
|
||||||
|
price:
|
||||||
|
description: Obvyklá cena v haléřích
|
||||||
|
type: integer
|
||||||
|
pricePer100g:
|
||||||
|
description: Cena za 100 g v haléřích
|
||||||
|
type: integer
|
||||||
|
note:
|
||||||
|
description: |
|
||||||
|
Poznámka nebo postup přípravy. U domácích jídel sem lze zapsat recept
|
||||||
|
volným textem; strukturované suroviny mohou přibýt později.
|
||||||
|
type: string
|
||||||
|
|
||||||
|
MealTemplateInput:
|
||||||
|
description: |
|
||||||
|
Data pro založení nebo úpravu šablony jídla. Oproti {@link MealTemplate} je
|
||||||
|
`id` volitelné — bez něj se šablona založí jako nová.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor upravované šablony. Bez něj se založí nová.
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
mealType:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/MealType"
|
||||||
|
calories:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
caloriesPer100g:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
weight:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
price:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
pricePer100g:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|||||||
Reference in New Issue
Block a user