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

This commit is contained in:
Ondřej Anděl committed 2026-09-15 14:16:42 +02:00
1 parent a5711f3e1e
commit 21b01528e9
51 files changed
+3739 -504

No files matched your search

+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>
);
}