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`,
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
- **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`
- **Auth:** `auth.ts` — JWT + volitelná autentizace z hlavičky reverzní proxy
- **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/)
- **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),
`TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej),
`StatsPage` (statistiky + import)
- **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)
- **Routing:** konstanty adres jsou v `routes.ts`, ne v `AppRoutes.tsx` — hlavička
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>`
Aktivita se měří v jednotce (`ActivityUnit`: KROKY, MINUTY, KM, OPAKOVANI) a
kalorie se dopočtou jako `quantity / 100 * caloriesPer100Units`. **Sto jednotek,
ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal) a aplikace
všude pracuje s celými čísly. Stejná konvence jako u energie jídla na 100 g.
kalorie se dopočtou jako `getTotalQuantity(quantity, sets) / 100 * caloriesPer100Units`.
**Sto jednotek, ne jedna** — u kroků by sazba na jeden krok byla zlomek (~0,04 kcal)
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
běžné aktivity s vazbou `templateId` — jsou samostatné, takže úprava založené
položky šablonu nemění.
`quantity` je množství **na jednu sérii**, `sets` je počet sérií; celkové množství
je jejich součin. Drží se odděleně schválně: 3 × 40 opakování s pauzami je jiný
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á:
`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
jen jídlo proti pohybu — **to není skutečný deficit**, proto se to přes
`hasBasal: false` propisuje do UI, aby to číslo nikoho nemátlo.
Bez nastaveného klidového výdeje porovnává bilance jen jídlo proti pohybu —
**to není skutečný deficit**, proto se to přes `hasBasal: false` propisuje do UI,
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),
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
`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.
- **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).
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čí
jedním kliknutím založit do dne celý, místo zadávání cviku po cviku. Založené
položky jsou samostatné, takže je jde doupravit, aniž by se změnila šablona.
Volitelně se dá k dni zapsat i **délka spánku**. Slouží jako kontext — do
energetické bilance nevstupuje, protože klidový výdej už pokrývá celých 24 hodin
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
(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í.
Nahoře je **bilance dne**: `příjem − (klidový výdej + pohyb)`.
## Š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áž
+50
View File
@@ -681,6 +681,12 @@ body {
gap: 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 {
width: 90px;
flex: 0 0 auto;
@@ -699,6 +705,7 @@ body {
@media (max-width: 640px) {
flex-wrap: wrap;
.workout-item-sets,
.workout-item-quantity,
.workout-item-unit,
.workout-item-rate {
@@ -721,3 +728,46 @@ body {
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 DayPage from "./pages/DayPage";
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() {
const auth = useAuth();
@@ -21,6 +23,8 @@ export default function AppRoutes() {
<Routes>
<Route path={DEN_URL} element={<DayPage />} />
<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 />} />
</Routes>
</div>
+64 -4
View File
@@ -182,14 +182,74 @@ export function formatQuantity(quantity: number, unitShort: string): string {
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;
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". */
export function formatBalance(kcal: number): string {
const sign = kcal < 0 ? '−' : '+';
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 { useLocation, useNavigate } from "react-router";
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 { 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";
/** Položky hlavní navigace. */
const NAV_ITEMS = [
{ url: DEN_URL, label: 'Můj den' },
{ url: SABLONY_URL, label: 'Šablony' },
{ url: STATISTIKY_URL, label: 'Statistiky' },
];
@@ -53,7 +54,11 @@ export default function Header() {
</button>
<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(SABLONY_URL)}>Šablony tréninků</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)}>
<FontAwesomeIcon icon={faGear} /> Nastavení
</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 { 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 { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
@@ -21,6 +24,7 @@ function emptyForm(date: string) {
name: '',
unit: ActivityUnit.KROKY as ActivityUnit,
quantity: '',
sets: '',
caloriesPer100Units: '',
calories: '',
note: '',
@@ -37,15 +41,18 @@ function emptyForm(date: string) {
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
const [form, setForm] = useState(() => emptyForm(date));
const [saving, setSaving] = useState(false);
const [alsoTemplate, setAlsoTemplate] = useState(false);
useEffect(() => {
if (!isOpen) return;
setAlsoTemplate(false);
setForm(entry
? {
date: entry.date,
name: entry.name,
unit: entry.unit,
quantity: String(entry.quantity),
sets: entry.sets != null ? String(entry.sets) : '',
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
calories: entry.calories != null ? String(entry.calories) : '',
note: entry.note ?? '',
@@ -70,8 +77,9 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
};
const quantity = parseIntInput(form.quantity);
const sets = parseIntInput(form.sets);
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
const manualCalories = parseIntInput(form.calories);
const effectiveCalories = derivedCalories ?? manualCalories;
@@ -85,6 +93,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
name: form.name.trim(),
unit: form.unit,
quantity,
sets,
caloriesPer100Units,
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
calories: derivedCalories != null ? undefined : manualCalories,
@@ -93,6 +102,17 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
const response = entry
? await updateActivity({ body: { id: entry.id, ...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);
if (response.data) {
onSaved(response.data);
@@ -135,14 +155,26 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
<fieldset className="meal-fieldset mb-3">
<legend>Množství a spotřeba</legend>
<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">
<Form.Label>Množství</Form.Label>
<Form.Label>{(sets ?? 1) > 1 ? 'Na sérii' : 'Množství'}</Form.Label>
<Form.Control
inputMode="numeric"
value={form.quantity}
placeholder="10000"
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 className="col-sm-3">
<Form.Label>Jednotka</Form.Label>
@@ -155,8 +187,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
))}
</Form.Select>
</div>
<div className="col-sm-3">
<Form.Label>Kcal na 100 jedn.</Form.Label>
<div className="col-sm-2">
<Form.Label>Kcal/100</Form.Label>
<Form.Control
inputMode="numeric"
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, '') })}
/>
</div>
<div className="col-sm-3">
<div className="col-sm-2">
<Form.Label>Spáleno</Form.Label>
{derivedCalories != null ? (
<div className="derived-value">{formatCalories(derivedCalories)}</div>
@@ -195,6 +227,13 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
</Form>
</Modal.Body>
<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">
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
</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 { 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 {
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 [settings, setSettings] = useState<UserSettings | undefined>();
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
useEffect(() => {
if (!isOpen) return;
setAlsoTemplate(false);
setForm(entry
? {
date: entry.date,
@@ -126,6 +131,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
const response = entry
? await updateMeal({ body: { id: entry.id, ...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);
if (response.data) {
onSaved(response.data);
@@ -295,6 +309,13 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
</Form>
</Modal.Body>
<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">
{derived.weight != null && <span>{formatWeight(derived.weight)}</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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
import { UserSettings, getSettings, saveSourceRate } from "../../../../types";
import { formatPriceInput, parsePriceInput } from "../../Utils";
type Props = {
isOpen: boolean,
@@ -20,17 +20,13 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
const [settings, setSettings] = useState<UserSettings | undefined>();
const [newSource, setNewSource] = useState('');
const [newRate, setNewRate] = useState('');
const [basal, setBasal] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
setNewSource('');
setNewRate('');
getSettings().then(response => {
setSettings(response.data);
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
});
getSettings().then(response => setSettings(response.data));
}, [isOpen]);
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 rate = parsePriceInput(newRate);
if (!newSource.trim().length || !rate) return;
@@ -66,24 +52,6 @@ export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
<Modal.Title>Nastavení</Modal.Title>
</Modal.Header>
<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>
<p className="text-body-secondary small">
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. */
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
@@ -38,12 +38,12 @@ export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
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> = {
KROKY: 'kroků',
MINUTY: 'min',
KM: 'km',
OPAKOVANI: '×',
OPAKOVANI: 'opak.',
};
/** 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: '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 {
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 { Button, Spinner } from "react-bootstrap";
import { Alert, Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
faChevronLeft, faChevronRight, faPlus, faUtensils, faPersonRunning,
faCoins, faFire, faScaleBalanced, faDumbbell, faBed, faBowlFood, faBookmark,
} from "@fortawesome/free-solid-svg-icons";
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
import {
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
} from "../enums";
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
deleteActivity, deleteMeal, getDayOverview, saveSleep,
} from "../../../types";
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
import {
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
getDayOfWeek, getHumanDate, shiftDate,
formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
} from "../Utils";
import MealModal from "../components/modals/MealModal";
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";
/** 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. */
function emptyOverview(date: string): DayOverview {
return {
@@ -34,13 +36,18 @@ function emptyOverview(date: string): DayOverview {
export default function DayPage() {
const [date, setDate] = useState<string>(() => formatDate(new 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 [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
const [mealModalOpen, setMealModalOpen] = useState(false);
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
const [activityModalOpen, setActivityModalOpen] = 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) => {
setLoading(true);
@@ -51,6 +58,17 @@ export default function DayPage() {
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());
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
@@ -69,6 +87,25 @@ export default function DayPage() {
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 response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
if (response.data) setDay(response.data);
@@ -78,12 +115,22 @@ export default function DayPage() {
<>
<div className="page-header">
<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); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
</Button>
</div>
) : (
<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)}>
<FontAwesomeIcon icon={faDumbbell} /> Šablony
</Button>
@@ -156,21 +203,41 @@ export default function DayPage() {
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faBed} /> Spánek</div>
<div className="sleep-input-row">
<input
className="form-control sleep-input"
inputMode="decimal"
value={sleepInput}
placeholder="7,5"
aria-label="Délka spánku v hodinách"
onChange={event => setSleepInput(event.target.value)}
onBlur={() => commitSleep()}
/>
<span className="sleep-unit">h</span>
</div>
<div className="summary-hint">
{day.sleepMinutes ? formatSleep(day.sleepMinutes) : 'do bilance nevstupuje'}
</div>
</div>
</div>
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
<div className="day-tabs">
<button
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
onClick={() => setTab('prijem')}
className={`day-tab ${tab === DayTab.PRIJEM ? 'active' : ''}`}
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>
</button>
<button
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
onClick={() => setTab('vydej')}
className={`day-tab ${tab === DayTab.VYDEJ ? 'active' : ''}`}
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>
</button>
</div>
@@ -181,7 +248,7 @@ export default function DayPage() {
</div>
)}
{!loading && tab === 'prijem' && (
{!loading && tab === DayTab.PRIJEM && (
<>
{!day.meals.entries.length && (
<div className="card-panel empty-state">
@@ -208,6 +275,7 @@ export default function DayPage() {
entry={entry}
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
onDelete={() => removeMeal(entry)}
onSaveTemplate={() => saveMealAsTemplate(entry)}
/>
))}
</div>
@@ -216,7 +284,7 @@ export default function DayPage() {
</>
)}
{!loading && tab === 'vydej' && (
{!loading && tab === DayTab.VYDEJ && (
<>
{!day.activities.entries.length && (
<div className="card-panel empty-state">
@@ -235,6 +303,7 @@ export default function DayPage() {
entry={entry}
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
onDelete={() => removeActivity(entry)}
onSaveTemplate={() => openSaveWorkout(entry)}
/>
))}
</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}
date={date}
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,
} from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { MEAL_TYPE_NAMES } from "../enums";
import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
import {
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
getHumanMonth, getMonthEnd, getMonthStart,
@@ -14,15 +14,6 @@ import {
import ImportModal from "../components/modals/ImportModal";
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. */
const EARLIEST_YEAR = 2015;
@@ -43,7 +34,7 @@ function formatChartPrice(value: unknown): string {
}
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 [year, setYear] = useState<number>(() => new Date().getFullYear());
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. */
const range = useMemo(() => {
if (mode === 'month') {
if (mode === StatsRange.MESIC) {
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: `${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. */
const series = useMemo(() => {
if (!stats) return [];
if (mode === 'month') {
if (mode === StatsRange.MESIC) {
return stats.byDay.map(total => ({
label: formatDateString(total.period).slice(0, 5),
price: total.price / 100,
calories: total.calories,
}));
}
if (mode === 'year') {
if (mode === StatsRange.ROK) {
return stats.byMonth.map(total => ({
label: getHumanMonth(total.period).split(' ')[0],
price: total.price / 100,
@@ -117,17 +108,17 @@ export default function StatsPage() {
<div className="range-picker card-panel">
<div className="range-modes">
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
{Object.values(StatsRange).map(value => (
<button
key={value}
className={`range-mode ${mode === value ? 'active' : ''}`}
onClick={() => setMode(value)}
>
{RANGE_LABELS[value]}
{STATS_RANGE_NAMES[value]}
</button>
))}
</div>
{mode === 'month' && (
{mode === StatsRange.MESIC && (
<input
type="month"
className="form-control range-input"
@@ -135,7 +126,7 @@ export default function StatsPage() {
onChange={event => event.target.value && setMonth(event.target.value)}
/>
)}
{mode === 'year' && (
{mode === StatsRange.ROK && (
<input
type="number"
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 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;
name: string;
unit: ActivityUnit;
/** Množství na jednu sérii. */
quantity: number;
/** Počet sérií; nevyplněno znamená jednu. */
sets?: number;
caloriesPer100Units?: number;
calories?: number;
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.
*
@@ -93,7 +106,7 @@ export type NormalizedActivityInput = {
*/
export function deriveActivityCalories(input: NormalizedActivityInput): NormalizedActivityInput {
const calories = input.caloriesPer100Units
? Math.round(input.quantity / 100 * input.caloriesPer100Units)
? Math.round(getTotalQuantity(input.quantity, input.sets) / 100 * input.caloriesPer100Units)
: input.calories;
return { ...input, calories };
}
@@ -115,11 +128,14 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput
if (quantity == null) {
throw new BadRequestError("Nebylo předáno množství aktivity");
}
const sets = normalizeNumber(value.sets, 'sets');
return deriveActivityCalories({
date: value.date,
name,
unit: value.unit,
quantity,
// Jedna série je výchozí stav, ukládat ji zvlášť nemá smysl
...(sets && sets > 1 ? { sets } : {}),
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
calories: normalizeNumber(value.calories, 'calories'),
note: normalizeText(value.note, 'note'),
@@ -193,6 +209,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
const updated: ActivityEntry = {
...original,
...normalized,
sets: normalized.sets,
caloriesPer100Units: normalized.caloriesPer100Units,
calories: normalized.calories,
note: normalized.note,
+15 -7
View File
@@ -2,6 +2,7 @@ import { DayOverview, EnergyBalance } from "../../types/gen/types.gen";
import { getDay } from "./meals";
import { getActivityDay } from "./activities";
import { getSettings } from "./settings";
import { getSleep } from "./sleep";
/**
* 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,
* 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 activityBurn spálené kcal pohybem
* @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 totalBurn = resolvedBasal + activityBurn;
return {
@@ -25,24 +30,27 @@ export function buildEnergyBalance(intake: number, activityBurn: number, basal?:
balance: intake - totalBurn,
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 date datum ve formátu YYYY-MM-DD
*/
export async function getDayOverview(login: string, date: string): Promise<DayOverview> {
const [meals, activities, settings] = await Promise.all([
export const getDayOverview = async (login: string, date: string): Promise<DayOverview> => {
const [meals, activities, settings, sleepMinutes] = await Promise.all([
getDay(login, date),
getActivityDay(login, date),
getSettings(login),
getSleep(login, date),
]);
return {
date,
meals,
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 workoutRoutes from "./routes/workoutRoutes";
import dayRoutes from "./routes/dayRoutes";
import mealTemplateRoutes from "./routes/mealTemplateRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
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/workouts", workoutRoutes);
app.use("/api/day", dayRoutes);
app.use("/api/mealTemplates", mealTemplateRoutes);
app.use(express.static(path.join(process.cwd(), 'public')));
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 { getDayOverview } from "../dayOverview";
import { saveSleep } from "../sleep";
import { getLogin } from "../auth";
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
@@ -16,4 +17,14 @@ router.get("/", async (req: Request, res: Response, next: NextFunction) => {
} 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;
+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 { getSettings, saveBasalCalories, saveSourceRate } from "../settings";
import { getSettings, saveBasalCalories, saveProfile, saveSourceRate } from "../settings";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
@@ -30,4 +30,12 @@ router.post("/basal", async (req: Request, res: Response, next: NextFunction) =>
} 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;
+13 -1
View File
@@ -1,5 +1,8 @@
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 { getLogin } from "../auth";
import { parseToken } from "../utils";
@@ -41,4 +44,13 @@ router.post("/apply", async (req: Request, res: Response, next: NextFunction) =>
} 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;
+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 { BadRequestError } from "./utils";
import { normalizeName } from "./calories";
import { computeBasalCalories, normalizeProfile } from "./profile";
const storage = getStorage();
@@ -27,7 +28,39 @@ function emptySettings(): UserSettings {
* @param login přihlašovací jméno uživatele
*/
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 key = normalizeName(name);
return await storage.updateData<UserSettings>(getSettingsKey(login), current => {
const rates = (current ?? emptySettings()).sourceRates.filter(item => normalizeName(item.source) !== key);
const saved = await storage.updateData<UserSettings>(getSettingsKey(login), current => {
const settings = current ?? emptySettings();
const rates = settings.sourceRates.filter(item => normalizeName(item.source) !== key);
// 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) {
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();
// Nula znamená smazání — ukládat ji nemá smysl
if (value > 0) {
@@ -85,6 +120,7 @@ export async function saveBasalCalories(login: string, basalCalories: unknown):
const { basalCalories: _removed, ...rest } = settings;
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 { ActivityEntry, WorkoutTemplate, WorkoutTemplateItem } from "../../types/gen/types.gen";
import {
ActivityEntry, ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
} from "../../types/gen/types.gen";
import getStorage from "./storage";
import { addActivities, isActivityUnit } from "./activities";
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
const storage = getStorage();
@@ -10,38 +12,99 @@ const storage = getStorage();
const MAX_ITEMS = 50;
/** Klíč úložiště se šablonami tréninků jednoho uživatele. */
function getKey(login: string): string {
return `workouts:${login}`;
}
const getKey = (login: string): string => `workouts:${login}`;
/** Spočítá odhad spálených kcal za celou šablonu. */
function estimateCalories(items: WorkoutTemplateItem[]): number {
return items.reduce((sum, item) => sum + Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)), 0);
}
/** Uložená podoba šablony — dopočítávaná pole se neukládají. */
type StoredTemplate = {
id: string;
name: string;
items: WorkoutTemplateItem[];
};
/** Doplní šabloně dopočítaný odhad kalorií. */
function withEstimate(template: Omit<WorkoutTemplate, 'estimatedCalories'>): WorkoutTemplate {
return { ...template, estimatedCalories: estimateCalories(template.items) };
}
/** Vrátí true, pokud je položka odkazem na jinou šablonu. */
const isTemplateRef = (item: WorkoutTemplateItem): boolean => item.kind === WorkoutItemKind.SABLONA;
/** 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.
*
* @param login přihlašovací jméno uživatele
*/
export async function getWorkoutTemplates(login: string): Promise<WorkoutTemplate[]> {
const templates = await storage.getData<StoredTemplate[]>(getKey(login)) ?? [];
return templates
.map(withEstimate)
export const getWorkoutTemplates = async (login: string): Promise<WorkoutTemplate[]> => {
const stored = await loadTemplates(login);
return stored
.map(template => withDerived(template, stored))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
}
};
/** 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 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() : '';
if (!name.length) {
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í`);
}
const rate = value.caloriesPer100Units;
const sets = value.sets;
return {
kind,
name,
unit: value.unit,
unit: value.unit as ActivityUnit,
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
? { 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.
@@ -71,12 +175,12 @@ function normalizeItem(raw: unknown, index: number): WorkoutTemplateItem {
* @param name název šablony
* @param items položky šablony
*/
export async function saveWorkoutTemplate(
export const saveWorkoutTemplate = async (
login: string,
id: unknown,
name: unknown,
items: unknown,
): Promise<WorkoutTemplate[]> {
): Promise<WorkoutTemplate[]> => {
if (typeof name !== 'string' || !name.trim().length) {
throw new BadRequestError("Nebyl předán název šablony");
}
@@ -86,42 +190,49 @@ export async function saveWorkoutTemplate(
if (items.length > MAX_ITEMS) {
throw new BadRequestError(`Šablona může mít nejvýše ${MAX_ITEMS} položek`);
}
const normalized: StoredTemplate = {
id: typeof id === 'string' && id.length ? id : randomUUID(),
name: name.trim(),
items: items.map(normalizeItem),
};
checkNesting(normalized.id, normalized.items, await loadTemplates(login));
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
const others = (current ?? []).filter(template => template.id !== normalized.id);
return [...others, normalized];
});
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 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) {
throw new BadRequestError("Nebyl předán identifikátor šablony");
}
let found = false;
await storage.updateData<StoredTemplate[]>(getKey(login), current => {
found = (current ?? []).some(template => template.id === id);
return (current ?? []).filter(template => template.id !== id);
});
if (!found) {
const all = await loadTemplates(login);
if (!all.some(template => template.id === id)) {
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,
* 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 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) {
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");
}
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) {
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 entries: ActivityEntry[] = template.items.map(item => ({
const entries: ActivityEntry[] = exercises.map(item => ({
id: randomUUID(),
date,
name: item.name,
unit: item.unit,
quantity: item.quantity,
name: item.name!,
unit: item.unit!,
quantity: item.quantity!,
...(item.sets != null ? { sets: item.sets } : {}),
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
calories: Math.round(item.quantity / 100 * (item.caloriesPer100Units ?? 0)),
calories: itemCalories(item),
templateId: template.id,
createdAt: now,
}));
await addActivities(login, date, entries);
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)
/day:
$ref: "./paths/day/overview.yml"
/day/sleep:
$ref: "./paths/day/sleep.yml"
# Pohyb (/api/activities)
/activities/add:
@@ -48,6 +50,20 @@ paths:
$ref: "./paths/workouts/delete.yml"
/workouts/apply:
$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)
/calories/search:
@@ -58,6 +74,8 @@ paths:
$ref: "./paths/settings/settings.yml"
/settings/basal:
$ref: "./paths/settings/basal.yml"
/settings/profile:
$ref: "./paths/settings/profile.yml"
# Importy (/api/import)
/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"
basalCalories:
description: |
Klidový výdej (bazální metabolismus) v kcal za den. Bez něj se bilance dne
počítá jen jako jídlo proti pohybu a nejde o skutečný deficit.
Ručně zadaný klidový výdej v kcal za den. Má přednost před hodnotou
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
ActivityUnit:
@@ -482,7 +495,15 @@ ActivityEntry:
unit:
$ref: "#/ActivityUnit"
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
caloriesPer100Units:
description: |
@@ -528,8 +549,12 @@ ActivityInput:
unit:
$ref: "#/ActivityUnit"
quantity:
description: Množství v dané jednotce
description: Množství v dané jednotce na jednu sérii
type: integer
sets:
description: Počet sérií. Nevyplněno znamená jednu.
type: integer
nullable: true
caloriesPer100Units:
description: Spálené kcal na 100 jednotek
type: integer
@@ -564,21 +589,38 @@ ActivityDay:
type: integer
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
additionalProperties: false
required:
- name
- unit
- quantity
- kind
properties:
kind:
$ref: "#/WorkoutItemKind"
name:
description: Název cviku. Povinný u položky druhu CVIK.
type: string
unit:
$ref: "#/ActivityUnit"
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
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
WorkoutTemplate:
@@ -592,6 +634,8 @@ WorkoutTemplate:
- name
- items
- estimatedCalories
- hasNested
- usedByCount
properties:
id:
type: string
@@ -603,7 +647,15 @@ WorkoutTemplate:
items:
$ref: "#/WorkoutTemplateItem"
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
EnergyBalance:
@@ -662,3 +714,129 @@ DayOverview:
$ref: "#/ActivityDay"
energy:
$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