feat: zlepšení šablon, přidány osobní údaje a spánkový režim

This commit is contained in:
Ondřej Anděl
2026-09-15 14:16:42 +02:00
parent a5711f3e1e
commit 21b01528e9
51 changed files with 3739 additions and 504 deletions
+64 -11
View File
@@ -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
+36 -6
View File
@@ -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áž
+50
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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);
+31
View File
@@ -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;
+7 -2
View File
@@ -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>
+30
View File
@@ -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;
+35
View File
@@ -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;
+45 -6
View File
@@ -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;
+22 -1
View File
@@ -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;
+3 -35
View File
@@ -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
View File
@@ -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',
};
+33
View File
@@ -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);
}
}
+114 -87
View File
@@ -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 ? (
<div className="header-actions">
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
<FontAwesomeIcon icon={faBowlFood} /> Šablony
</Button>
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}> <Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo <FontAwesomeIcon icon={faPlus} /> Přidat jídlo
</Button> </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>
<div className="summary-hint">
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
</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>
);
}
+327
View File
@@ -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;
+201
View File
@@ -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;
+10 -19
View File
@@ -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"
+94
View File
@@ -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;
}
+45
View File
@@ -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;
+374
View File
@@ -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;
+2
View File
@@ -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';
+18 -1
View File
@@ -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,
+15 -7
View File
@@ -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 } : {}),
};
}; };
}
+2
View File
@@ -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) => {
+206
View File
@@ -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,
});
};
+73
View File
@@ -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));
};
+11
View File
@@ -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;
+54
View File
@@ -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;
+9 -1
View File
@@ -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;
+13 -1
View File
@@ -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
View File
@@ -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);
} }
/** /**
+47
View File
@@ -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([]);
});
});
+168
View File
@@ -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([]);
});
});
+247
View File
@@ -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();
});
});
+204 -42
View File
@@ -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");
} }
return getWorkoutTemplates(login); 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);
};
/** /**
* 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);
};
+18
View File
@@ -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:
+29
View File
@@ -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
+35
View File
@@ -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
+26
View File
@@ -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
+33
View File
@@ -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
+14
View File
@@ -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
+22
View File
@@ -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
+20
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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