chore: graf db

feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
Ondřej Anděl committed 2026-10-22 12:11:05 +02:00
1 parent 865c2c3b22
commit 7641e26c9f
43 files changed
+3459 -599

No files matched your search

+10
View File
@@ -796,6 +796,16 @@ body {
}
}
// Název plánu nad jeho dny ve výběru šablony
.workout-plan-title {
margin: 16px 0 2px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
}
// Ikona u skládané šablony ve výběru
.workout-nested-icon {
margin-left: 8px;
+5
View File
@@ -73,6 +73,11 @@ export function getDayOfWeek(date: string): string {
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date));
}
const SHORT_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' });
/** Vrátí krátký zápis data s dnem v týdnu, např. "po 3. 3." — pro odkazy mezi blízkými dny. */
export const formatShortDate = (date: string): string => SHORT_DATE_FORMAT.format(parseIsoDate(date));
/** Vrátí čitelný zápis data, např. "3. března 2025". */
export function getHumanDate(date: string): string {
return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
+9 -2
View File
@@ -1,6 +1,6 @@
import { ActivityEntry } from "../../../types";
import { ACTIVITY_UNIT_SHORT } from "../enums";
import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils";
import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets, formatShortDate } from "../Utils";
import RowActions from "./RowActions";
import MultiplierControl from "./MultiplierControl";
@@ -24,7 +24,14 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChan
{!!entry.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(entry.extraWeightGrams)}</span>
)}
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
{entry.replacesDate && (
<span className="tag tag-import" title="Náhrada cviku vynechaného v plánu">
náhrada za {formatShortDate(entry.replacesDate)}
</span>
)}
{entry.templateId && !entry.replacesDate && (
<span className="tag tag-import">{entry.planDayId ? 'z plánu' : 'z tréninku'}</span>
)}
</div>
{entry.note && <div className="meal-note">{entry.note}</div>}
</div>
@@ -0,0 +1,82 @@
import { useState } from "react";
import { Alert, Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faRotate } from "@fortawesome/free-solid-svg-icons";
import { DayOverview, PendingReplacement, replaceSkippedExercise } from "../../../types";
import { ACTIVITY_UNIT_SHORT } from "../enums";
import { formatCalories, formatQuantityWithSets, formatShortDate } from "../Utils";
type Props = {
replacements: PendingReplacement[],
date: string,
/** Zavolá se s aktualizovaným přehledem dne po náhradě. */
onReplaced: (day: DayOverview) => void,
};
/** Klíč náhrady — záznam použití plánu a pořadí cviku v něm. */
const getKey = (replacement: PendingReplacement): string => `${replacement.planId}:${replacement.itemIndex}`;
/**
* Cviky vynechané v předchozích dnech plánu, které jde v tento den ještě
* nahradit. Náhrada založí cvik do dne a ve statistikách se u původního
* vynechání ukáže odkaz na ni.
*/
const PendingReplacementsPanel = ({ replacements, date, onReplaced }: Readonly<Props>) => {
const [busyKey, setBusyKey] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
if (!replacements.length) return null;
const replace = async (replacement: PendingReplacement) => {
setBusyKey(getKey(replacement));
setError(undefined);
const response = await replaceSkippedExercise({
body: {
date,
sourceDate: replacement.sourceDate,
planId: replacement.planId,
itemIndex: replacement.itemIndex,
},
});
setBusyKey(undefined);
if (response.data) {
onReplaced(response.data);
} else {
setError((response.error as { error?: string })?.error ?? 'Cvik se nepodařilo nahradit');
}
};
return (
<div className="card-panel pending-replacements">
<h2 className="pending-replacements-title">
<FontAwesomeIcon icon={faRotate} /> Vynechané cviky k náhradě
</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{replacements.map(replacement => (
<div className="pending-replacement-row" key={getKey(replacement)}>
<div className="pending-replacement-main">
<div className="pending-replacement-name">{replacement.item.name}</div>
<div className="pending-replacement-meta">
{formatQuantityWithSets(
replacement.item.quantity, ACTIVITY_UNIT_SHORT[replacement.item.unit], replacement.item.sets)}
{' · '}vynecháno {formatShortDate(replacement.sourceDate)}
{' '}({replacement.templateName}{replacement.dayName ? ` · ${replacement.dayName}` : ''})
{' · '}nahradit do {formatShortDate(replacement.deadline)}
</div>
</div>
<span className="template-pick-calories">{formatCalories(replacement.item.calories)}</span>
<Button
size="sm"
variant="outline-primary"
onClick={() => replace(replacement)}
disabled={!!busyKey}
>
Nahradit
</Button>
</div>
))}
</div>
);
};
export default PendingReplacementsPanel;
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBed, faCalendarCheck, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import {
DayOverview, PlannedWorkout, PlannedWorkoutStatus, WorkoutTemplateType, applyWorkoutTemplate,
} from "../../../types";
import { ACTIVITY_UNIT_SHORT } from "../enums";
import { formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils";
type Props = {
planned: PlannedWorkout,
date: string,
/** Zavolá se s aktualizovaným přehledem dne po potvrzení. */
onConfirmed: (day: DayOverview) => void,
};
/** Co se stane, když se dnes necvičí — plovoucí plán volno posouvá, fixní ne. */
const getSkipHint = (planned: PlannedWorkout): string => {
if (planned.type === WorkoutTemplateType.FIXNI) {
return 'Fixní plán se drží dnů v týdnu — necvičený den propadne.';
}
return planned.restAvailable
? 'Když dnes necvičíte, bere se to jako posunuté volno a cyklus pak pokračuje bez další mezery.'
: 'Když dnes necvičíte, trénink se jen odsune na další den.';
};
/**
* Návrh tréninku z plánu na daný den. Cviky jsou předem zaškrtnuté — po
* odcvičení stačí potvrdit, co se nestihlo, se odškrtne a zapíše jako
* vynechané. Hotový den a den volna se ukážou jen jedním řádkem.
*/
const PlannedWorkoutCard = ({ planned, date, onConfirmed }: Readonly<Props>) => {
const [done, setDone] = useState<boolean[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
setDone(planned.items.map(() => true));
setError(undefined);
}, [planned]);
if (planned.status === PlannedWorkoutStatus.SPLNENO) {
return (
<div className="planned-workout planned-workout-done">
<FontAwesomeIcon icon={faCircleCheck} />
<span><strong>{planned.templateName}</strong> · {planned.dayName} — splněno</span>
</div>
);
}
if (planned.status === PlannedWorkoutStatus.VOLNO) {
return (
<div className="planned-workout planned-workout-rest">
<FontAwesomeIcon icon={faBed} />
<span><strong>{planned.templateName}</strong> — podle plánu den volna</span>
</div>
);
}
const doneCount = done.filter(item => item).length;
const plannedCalories = planned.items.reduce((sum, item, index) => sum + (done[index] ? item.calories : 0), 0);
const confirm = async () => {
setBusy(true);
setError(undefined);
const response = await applyWorkoutTemplate({
body: {
id: planned.templateId,
date,
dayId: planned.dayId,
doneIndexes: done.flatMap((item, index) => (item ? [index] : [])),
},
});
setBusy(false);
if (response.data) {
onConfirmed(response.data);
} else {
setError((response.error as { error?: string })?.error ?? 'Trénink se nepodařilo potvrdit');
}
};
const getConfirmLabel = (): string => {
if (!doneCount) return 'Vynechat celý den';
if (doneCount === planned.items.length) return 'Splněno';
return `Splněno ${doneCount} z ${planned.items.length}`;
};
return (
<div className="card-panel planned-workout-card">
<div className="planned-workout-head">
<FontAwesomeIcon icon={faCalendarCheck} className="planned-workout-icon" />
<div className="planned-workout-title">
<strong>{planned.dayName}</strong>
<span className="planned-workout-plan">{planned.templateName}</span>
</div>
<span className="planned-workout-calories">{formatCalories(plannedCalories)}</span>
</div>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{planned.items.map((item, index) => (
// Cviky jsou rozbalený seznam dne plánu, index je i to, co se posílá serveru
<div className="template-pick-row" key={index}>
<Form.Check
id={`plan-${planned.templateId}-${index}`}
checked={done[index] ?? true}
label={item.name}
onChange={() => setDone(current => current.map((value, i) => (i === index ? !value : value)))}
/>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(item.extraWeightGrams)}</span>
)}
<span className="template-pick-quantity">
{formatQuantityWithSets(item.quantity, ACTIVITY_UNIT_SHORT[item.unit], item.sets)}
</span>
<span className="template-pick-calories">{formatCalories(item.calories)}</span>
</div>
))}
<div className="planned-workout-footer">
<span className="planned-workout-hint">
{doneCount < planned.items.length
? 'Odškrtnuté cviky se zapíšou jako vynechané.'
: getSkipHint(planned)}
</span>
<Button
size="sm"
variant={doneCount ? 'primary' : 'outline-secondary'}
onClick={() => confirm()}
disabled={busy}
>
{getConfirmLabel()}
</Button>
</div>
</div>
);
};
export default PlannedWorkoutCard;
+2 -2
View File
@@ -19,8 +19,8 @@ const RowActions = ({ onEdit, onDelete, onSaveTemplate, onSplit, label }: Readon
<button
className="btn-icon"
onClick={() => onSaveTemplate()}
title="Uložit jako šablonu"
aria-label={`Uložit ${label} jako šablonu`}
title="Uložit jako trénink"
aria-label={`Uložit ${label} jako trénink`}
>
<FontAwesomeIcon icon={faBookmark} />
</button>
@@ -0,0 +1,137 @@
import { Button, Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLayerGroup, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { ActivityUnit, WorkoutItemKind, WorkoutTemplate } from "../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../enums";
import { DraftItem, emptyExercise, emptyReference } from "../pages/workoutDraft";
/** Seznam známých cviků pro našeptávání názvu; v DOM je jednou pro celou stránku šablon. */
export const ACTIVITY_PRESETS_LIST_ID = 'activity-presets-templates';
type Props = {
items: DraftItem[],
onChange: (items: DraftItem[]) => void,
/** Šablony, které lze zařadit jako vnořené. */
nestable: WorkoutTemplate[],
/** Proč nejde vnořit šablonu, i když nějaké jsou — tlačítko se pak vypne. */
nestBlockedReason?: string,
};
/**
* Úprava položek šablony nebo dne plánu — cviky a odkazy na volné šablony.
* Výběr známého cviku předvyplní jednotku i orientační sazbu.
*/
const WorkoutItemsEditor = ({ items, onChange, nestable, nestBlockedReason }: Readonly<Props>) => {
const updateItem = (index: number, changes: Partial<DraftItem>) =>
onChange(items.map((item, i) => (i === index ? { ...item, ...changes } : item)));
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
const nestTitle = nestBlockedReason
?? (nestable.length ? undefined : 'Není k dispozici žádný volný trénink, který by šlo vložit');
return (
<>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="workout-item-nested-icon" />
<Form.Select
value={item.templateRefId}
onChange={event => updateItem(index, { templateRefId: event.target.value })}
>
<option value="">— vyberte trénink —</option>
{nestable.map(template => (
<option key={template.id} value={template.id}>
{template.name} ({template.estimatedCalories} kcal)
</option>
))}
</Form.Select>
</>
) : (
<>
<Form.Control
list={ACTIVITY_PRESETS_LIST_ID}
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-sets"
inputMode="numeric"
value={item.sets}
placeholder="série"
title="Počet sérií"
onChange={event => updateItem(index, { sets: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
title="Množství v jedné sérii"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-weight"
inputMode="decimal"
value={item.extraWeight}
placeholder="zátěž kg"
title="Dodatečná zátěž v kg — činky, vesta. Kalorie nemění."
onChange={event => updateItem(index, { extraWeight: event.target.value })}
/>
</>
)}
<button
className="btn-icon danger"
onClick={() => onChange(items.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<div className="workout-items-actions">
<Button variant="outline-secondary" size="sm" onClick={() => onChange([...items, emptyExercise()])}>
<FontAwesomeIcon icon={faPlus} /> Cvik
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => onChange([...items, emptyReference('')])}
disabled={!nestable.length || !!nestBlockedReason}
title={nestTitle}
>
<FontAwesomeIcon icon={faLayerGroup} /> Vložit trénink
</Button>
</div>
</>
);
};
export default WorkoutItemsEditor;
@@ -0,0 +1,42 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLayerGroup } from "@fortawesome/free-solid-svg-icons";
import { WorkoutItemKind, WorkoutTemplateItem } from "../../../types";
import { ACTIVITY_UNIT_SHORT } from "../enums";
import { deriveActivityCalories, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils";
type Props = {
items: WorkoutTemplateItem[],
};
/** Výpis položek šablony nebo dne plánu — cviky s množstvím a odkazy na vnořené šablony. */
const WorkoutItemsList = ({ items }: Readonly<Props>) => (
<ul className="template-items">
{items.map((item, index) => (
// Položky nemají vlastní id, v rámci jedné šablony je index stabilní
<li key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="template-item-icon" />
<span className="template-item-name">{item.templateRefName ?? 'neznámý trénink'}</span>
<span className="template-item-calories">{formatCalories(item.templateRefCalories)}</span>
</>
) : (
<>
<span className="template-item-name">{item.name}</span>
<span className="template-item-quantity">
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
</span>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(item.extraWeightGrams)}</span>
)}
<span className="template-item-calories">
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
</span>
</>
)}
</li>
))}
</ul>
);
export default WorkoutItemsList;
@@ -0,0 +1,102 @@
import { Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown, faArrowUp, faTrash } from "@fortawesome/free-solid-svg-icons";
import { WorkoutTemplate } from "../../../types";
import { WEEKDAY_NAMES } from "../enums";
import { formatCalories } from "../Utils";
import { DraftDay, getDraftCalories } from "../pages/workoutDraft";
import WorkoutItemsEditor from "./WorkoutItemsEditor";
type Props = {
day: DraftDay,
/** Pořadí dne v plánu od nuly. */
index: number,
dayCount: number,
/** Fixní plán vybírá den v týdnu, plovoucí řadí dny šipkami. */
isFixed: boolean,
/** Dny v týdnu, které už mají jiné dny plánu. */
takenWeekdays: number[],
nestable: WorkoutTemplate[],
templates: WorkoutTemplate[],
onChange: (day: DraftDay) => void,
onMove: (offset: number) => void,
onRemove: () => void,
};
/** Úprava jednoho tréninkového dne plánu — název, u fixního plánu den v týdnu, a cviky. */
const WorkoutPlanDayEditor = ({
day, index, dayCount, isFixed, takenWeekdays, nestable, templates, onChange, onMove, onRemove,
}: Readonly<Props>) => {
const calories = getDraftCalories(day.items, templates);
return (
<div className="plan-day-editor">
<div className="plan-day-head">
{isFixed ? (
<Form.Select
className="plan-day-weekday"
value={day.weekday}
aria-label="Den v týdnu"
onChange={event => onChange({ ...day, weekday: Number(event.target.value) })}
>
{WEEKDAY_NAMES.map((name, weekdayIndex) => (
<option
key={name}
value={weekdayIndex + 1}
disabled={takenWeekdays.includes(weekdayIndex + 1)}
>
{name}
</option>
))}
</Form.Select>
) : (
<span className="plan-day-order">{index + 1}.</span>
)}
<Form.Control
value={day.name}
placeholder={isFixed ? WEEKDAY_NAMES[day.weekday - 1] : `Den ${index + 1}`}
aria-label="Název dne"
onChange={event => onChange({ ...day, name: event.target.value })}
/>
{calories > 0 && <span className="plan-day-calories">{formatCalories(calories)}</span>}
{!isFixed && (
<>
<button
className="btn-icon"
onClick={() => onMove(-1)}
disabled={index === 0}
title="Posunout dřív"
aria-label="Posunout den dřív"
>
<FontAwesomeIcon icon={faArrowUp} />
</button>
<button
className="btn-icon"
onClick={() => onMove(1)}
disabled={index === dayCount - 1}
title="Posunout později"
aria-label="Posunout den později"
>
<FontAwesomeIcon icon={faArrowDown} />
</button>
</>
)}
<button
className="btn-icon danger"
onClick={() => onRemove()}
disabled={dayCount === 1}
title="Odebrat den"
aria-label="Odebrat den"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
<WorkoutItemsEditor
items={day.items}
onChange={items => onChange({ ...day, items })}
nestable={nestable}
/>
</div>
);
};
export default WorkoutPlanDayEditor;
@@ -0,0 +1,86 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBed, faLayerGroup, faPenToSquare, faRotate, faTrash } from "@fortawesome/free-solid-svg-icons";
import { WorkoutTemplate, WorkoutTemplateType } from "../../../types";
import { WEEKDAY_NAMES, WORKOUT_TEMPLATE_TYPE_NAMES } from "../enums";
import { formatCalories, formatDateString } from "../Utils";
import WorkoutItemsList from "./WorkoutItemsList";
type Props = {
template: WorkoutTemplate,
busy: boolean,
onEdit: () => void,
onDelete: () => void,
};
/**
* Karta šablony tréninku. Volná šablona ukáže cviky, plán své dny — fixní
* s dnem v týdnu, plovoucí v pořadí a s dnem volna na konci cyklu.
*/
const WorkoutTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
const isPlan = template.type !== WorkoutTemplateType.VOLNY;
const usedCount = template.usedByCount + template.usedInPlanCount;
return (
<div className="card-panel template-card">
<div className="template-head">
<div className="template-title">
{template.name}
{isPlan && <span className="tag tag-plan">{WORKOUT_TEMPLATE_TYPE_NAMES[template.type]}</span>}
{template.hasNested && (
<span className="tag tag-import">
<FontAwesomeIcon icon={faLayerGroup} /> skládaný
</span>
)}
{usedCount > 0 && <span className="tag">součástí {usedCount}×</span>}
</div>
<span className="template-calories" title={isPlan ? 'Odhad za celý cyklus' : undefined}>
{formatCalories(template.estimatedCalories)}
</span>
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => onDelete()}
disabled={busy}
title={usedCount > 0 ? 'Nelze smazat, je součástí jiného tréninku nebo plánu' : 'Smazat'}
aria-label="Smazat"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
{isPlan && (
<div className="template-plan-meta">
{template.startDate && <span>platí od {formatDateString(template.startDate)}</span>}
{template.replaceWithinDays
? <span><FontAwesomeIcon icon={faRotate} /> nahraditelné během {template.replaceWithinDays} dní</span>
: <span>bez náhrad</span>}
</div>
)}
{!isPlan && <WorkoutItemsList items={template.items} />}
{(template.days ?? []).map((day, index) => (
<div className="template-plan-day" key={day.id}>
<div className="template-plan-day-head">
<span className="template-plan-day-label">
{template.type === WorkoutTemplateType.FIXNI
? WEEKDAY_NAMES[(day.weekday ?? 1) - 1]
: `${index + 1}.`}
</span>
<span className="template-plan-day-name">{day.name}</span>
<span className="template-item-calories">{formatCalories(day.estimatedCalories)}</span>
</div>
<WorkoutItemsList items={day.items} />
</div>
))}
{template.type === WorkoutTemplateType.PLOVOUCI && (
<div className="template-plan-day template-plan-rest">
<FontAwesomeIcon icon={faBed} /> Den volna, pak znovu od 1. dne
</div>
)}
</div>
);
};
export default WorkoutTemplateCard;
@@ -0,0 +1,270 @@
import { useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBed, faPlus } from "@fortawesome/free-solid-svg-icons";
import { WorkoutTemplate, WorkoutTemplateType, saveWorkoutTemplate } from "../../../types";
import {
ACTIVITY_PRESETS, WEEKDAY_NAMES, WORKOUT_TEMPLATE_TYPES_IN_ORDER, WORKOUT_TEMPLATE_TYPE_HINTS,
WORKOUT_TEMPLATE_TYPE_NAMES,
} from "../enums";
import { formatCalories, formatDate, parseIntInput } from "../Utils";
import {
DraftDay, emptyDay, emptyExercise, getDraftCalories, toDraftDays, toDraftItems, toTemplateItems,
} from "../pages/workoutDraft";
import WorkoutItemsEditor, { ACTIVITY_PRESETS_LIST_ID } from "./WorkoutItemsEditor";
import WorkoutPlanDayEditor from "./WorkoutPlanDayEditor";
type Props = {
/** Všechny šablony uživatele — kvůli vnořování a odhadu kalorií. */
templates: WorkoutTemplate[],
/** Upravovaná šablona, nebo nic pro novou. */
edited?: WorkoutTemplate,
/** Zavolá se s aktualizovaným seznamem šablon po uložení. */
onSaved: (templates: WorkoutTemplate[]) => void,
onCancel: () => void,
};
/** Nejdelší lhůta pro náhradu, kterou přijme server. */
const MAX_REPLACE_DAYS = 14;
/** První den v týdnu, který v plánu ještě není. */
const getFreeWeekday = (days: DraftDay[]): number =>
[1, 2, 3, 4, 5, 6, 7].find(weekday => !days.some(day => day.weekday === weekday)) ?? 1;
/**
* Formulář šablony tréninku. Volná šablona je seznam cviků; plán (fixní nebo
* plovoucí) má tréninkové dny, lhůtu pro náhradu vynechaných cviků a začátek
* platnosti. Rozpracované cviky volné šablony i dny plánu se drží zvlášť,
* takže přepnutí typu sem a zpět nic nesmaže.
*/
const WorkoutTemplateForm = ({ templates, edited, onSaved, onCancel }: Readonly<Props>) => {
const [name, setName] = useState(edited?.name ?? '');
const [type, setType] = useState<WorkoutTemplateType>(edited?.type ?? WorkoutTemplateType.VOLNY);
const [items, setItems] = useState(() => (edited?.items.length ? toDraftItems(edited.items) : [emptyExercise()]));
const [days, setDays] = useState<DraftDay[]>(() => (edited?.days?.length
? toDraftDays(edited.days)
: [emptyDay('', 1)]));
const [replaceWithinDays, setReplaceWithinDays] = useState(
edited?.replaceWithinDays != null ? String(edited.replaceWithinDays) : '');
const [startDate, setStartDate] = useState(edited?.startDate ?? formatDate(new Date()));
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const isPlan = type !== WorkoutTemplateType.VOLNY;
const isFixed = type === WorkoutTemplateType.FIXNI;
const isUsed = !!edited && (edited.usedByCount > 0 || edited.usedInPlanCount > 0);
/** Volné šablony, které lze zařadit do dne plánu — plán zanoření nepřidává. */
const planNestable = templates.filter(template =>
template.type === WorkoutTemplateType.VOLNY && template.id !== edited?.id);
/**
* Šablony, které lze vnořit do volné šablony. Vypadne ta upravovaná (odkaz
* sám na sebe) a všechny, které samy něco skládají — ty už by přinesly
* druhou úroveň zanoření.
*/
const freeNestable = planNestable.filter(template => !template.hasNested);
const planDays = days.map(day => ({ ...day, templateItems: toTemplateItems(day.items) }));
const replaceDays = parseIntInput(replaceWithinDays);
const replaceValid = replaceDays == null || (replaceDays >= 1 && replaceDays <= MAX_REPLACE_DAYS);
const canSave = name.trim().length > 0 && !busy && (isPlan
? planDays.every(day => day.templateItems.length > 0) && replaceValid && !!startDate
: toTemplateItems(items).length > 0);
const changeDay = (index: number, day: DraftDay) =>
setDays(current => current.map((item, i) => (i === index ? day : item)));
const moveDay = (index: number, offset: number) => setDays(current => {
const result = [...current];
[result[index], result[index + offset]] = [result[index + offset], result[index]];
return result;
});
const addDay = () => setDays(current => [
...current,
emptyDay('', isFixed ? getFreeWeekday(current) : 1),
]);
/** U fixního plánu musí mít každý den jiný den v týdnu — po přepnutí typu se to srovná. */
const changeType = (value: WorkoutTemplateType) => {
setType(value);
if (value === WorkoutTemplateType.FIXNI) {
setDays(current => current.reduce<DraftDay[]>((result, day) => [
...result,
result.some(other => other.weekday === day.weekday) ? { ...day, weekday: getFreeWeekday(result) } : day,
], []).slice(0, WEEKDAY_NAMES.length));
}
};
const save = async () => {
if (!canSave) return;
setBusy(true);
setError(undefined);
const response = await saveWorkoutTemplate({
body: {
id: edited?.id,
name: name.trim(),
type,
...(isPlan
? {
days: planDays.map(day => ({
...(day.id ? { id: day.id } : {}),
name: day.name.trim(),
...(isFixed ? { weekday: day.weekday } : {}),
items: day.templateItems,
})),
...(replaceDays ? { replaceWithinDays: replaceDays } : {}),
startDate,
}
: { items: toTemplateItems(items) }),
},
});
setBusy(false);
if (response.data) {
onSaved(response.data);
} else {
// Server odmítá typicky kvůli pravidlům zanoření nebo dnům plánu
setError((response.error as { error?: string })?.error ?? 'Nepodařilo se uložit');
}
};
const totalCalories = isPlan
? days.reduce((sum, day) => sum + getDraftCalories(day.items, templates), 0)
: getDraftCalories(items, templates);
return (
<div className="card-panel template-form">
<h2 className="chart-title">{isPlan ? (edited ? 'Úprava plánu' : 'Nový plán') : (edited ? 'Úprava tréninku' : 'Nový trénink')}</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
<div className="template-form-head">
<Form.Group className="mb-3 template-form-name">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder={isPlan ? 'Např. Můj plán' : 'Např. Rozcvička'}
onChange={event => setName(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ</Form.Label>
<Form.Select
value={type}
onChange={event => changeType(event.target.value as WorkoutTemplateType)}
>
{WORKOUT_TEMPLATE_TYPES_IN_ORDER.map(item => (
<option
key={item}
value={item}
disabled={isUsed && item !== WorkoutTemplateType.VOLNY}
>
{WORKOUT_TEMPLATE_TYPE_NAMES[item]}
</option>
))}
</Form.Select>
</Form.Group>
</div>
<p className="text-body-secondary small">
{WORKOUT_TEMPLATE_TYPE_HINTS[type]}
{isUsed && ' Trénink je součástí jiného tréninku nebo plánu, plánem se proto stát nemůže.'}
</p>
{!isPlan && (
<WorkoutItemsEditor
items={items}
onChange={setItems}
nestable={freeNestable}
nestBlockedReason={edited && edited.usedByCount > 0
? 'Tento trénink už je vložený do jiného, další vkládání by bylo druhou úrovní'
: undefined}
/>
)}
{isPlan && (
<>
<div className="template-form-head">
<Form.Group className="mb-3">
<Form.Label>Platí od</Form.Label>
<Form.Control
type="date"
value={startDate}
onChange={event => setStartDate(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Nahraditelné během</Form.Label>
<div className="input-with-unit">
<Form.Control
inputMode="numeric"
value={replaceWithinDays}
placeholder="bez náhrad"
isInvalid={!replaceValid}
onChange={event => setReplaceWithinDays(event.target.value.replace(/\D/g, ''))}
/>
<span>dní</span>
</div>
<Form.Text>
Vynechaný cvik jde v této lhůtě dodělat jindy (nejvýš {MAX_REPLACE_DAYS} dní).
</Form.Text>
</Form.Group>
</div>
{days.map((day, index) => (
<WorkoutPlanDayEditor
key={day.key}
day={day}
index={index}
dayCount={days.length}
isFixed={isFixed}
takenWeekdays={days.filter(other => other.key !== day.key).map(other => other.weekday)}
nestable={planNestable}
templates={templates}
onChange={changed => changeDay(index, changed)}
onMove={offset => moveDay(index, offset)}
onRemove={() => setDays(current => current.filter(other => other.key !== day.key))}
/>
))}
{type === WorkoutTemplateType.PLOVOUCI && (
<div className="plan-rest-day">
<FontAwesomeIcon icon={faBed} /> Den volna — po posledním dni, pak se cyklus opakuje
</div>
)}
</>
)}
<datalist id={ACTIVITY_PRESETS_LIST_ID}>
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
{isPlan && (
<Button
variant="outline-secondary"
size="sm"
onClick={() => addDay()}
disabled={isFixed && days.length >= WEEKDAY_NAMES.length}
>
<FontAwesomeIcon icon={faPlus} /> Den
</Button>
)}
{totalCalories > 0 && (
<span className="workout-draft-calories">
{isPlan ? 'za cyklus ' : 'celkem '}{formatCalories(totalCalories)}
</span>
)}
{edited && (
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={() => save()} disabled={!canSave}>
{edited ? 'Uložit změny' : (isPlan ? 'Vytvořit plán' : 'Vytvořit trénink')}
</Button>
</div>
</div>
);
};
export default WorkoutTemplateForm;
@@ -273,7 +273,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
className="modal-template-check"
id="activity-also-template"
checked={alsoTemplate}
label="Uložit i jako šablonu"
label="Uložit i jako trénink"
onChange={event => setAlsoTemplate(event.target.checked)}
/>
<div className="meal-modal-summary">
@@ -3,7 +3,10 @@ import { Button, Modal, Spinner } from "react-bootstrap";
import { useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-icons";
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
import {
DayOverview, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem, WorkoutTemplateType, applyWorkoutTemplate,
getWorkoutTemplates,
} from "../../../../types";
import { ACTIVITY_UNIT_SHORT } from "../../enums";
import { formatCalories, formatQuantityWithSets } from "../../Utils";
import { TRENINKY_URL } from "../../routes";
@@ -17,10 +20,10 @@ type Props = {
onApplied: (day: DayOverview) => void,
};
/** Popíše položky šablony jednou řádkou. */
const describeItems = (template: WorkoutTemplate): string =>
template.items.map(item => item.kind === WorkoutItemKind.SABLONA
? (item.templateRefName ?? 'šablona')
/** Popíše položky šablony nebo dne plánu jednou řádkou. */
const describeItems = (items: WorkoutTemplateItem[]): string =>
items.map(item => item.kind === WorkoutItemKind.SABLONA
? (item.templateRefName ?? 'trénink')
: `${item.name} ${formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}`
).join(' · ');
@@ -28,7 +31,8 @@ const describeItems = (template: WorkoutTemplate): string =>
* Výběr šablony tréninku k založení do dne.
*
* Zakládání a úpravy šablon se řeší na vlastní stránce, sem chodí uživatel jen
* vybrat hotovou šablonu a rozbalit ji do dne.
* vybrat hotovou šablonu a rozbalit ji do dne. Den plánu běžně navrhne přehled
* dne sám; tady jde založit i jiný den plánu, než který je na řadě.
*/
const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
@@ -45,9 +49,9 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
});
}, [isOpen]);
const apply = async (id: string) => {
const apply = async (id: string, dayId?: string) => {
setBusy(true);
const response = await applyWorkoutTemplate({ body: { id, date } });
const response = await applyWorkoutTemplate({ body: { id, date, ...(dayId ? { dayId } : {}) } });
setBusy(false);
if (response.data) {
onApplied(response.data);
@@ -57,7 +61,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
return (
<Modal show={isOpen} onHide={onClose} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Použít šablonu tréninku</Modal.Title>
<Modal.Title>Přidat trénink</Modal.Title>
</Modal.Header>
<Modal.Body>
{loading && (
@@ -69,21 +73,21 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
{!loading && !templates.length && (
<div className="empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
<p className="empty-description">Vytvořit si ji můžete na stránce Tréninky.</p>
<div className="empty-title">Zatím nemáte žádný trénink ani plán</div>
<p className="empty-description">Vytvořit si ho můžete na stránce Tréninky.</p>
</div>
)}
{!loading && templates.map(template => (
{!loading && templates.filter(template => template.type === WorkoutTemplateType.VOLNY).map(template => (
<div className="workout-row" key={template.id}>
<div className="workout-info">
<div className="workout-name">
{template.name}
{template.hasNested && (
<FontAwesomeIcon icon={faLayerGroup} className="workout-nested-icon" title="skládaná šablona" />
<FontAwesomeIcon icon={faLayerGroup} className="workout-nested-icon" title="skládaný trénink" />
)}
</div>
<div className="workout-items">{describeItems(template)}</div>
<div className="workout-items">{describeItems(template.items)}</div>
</div>
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
@@ -91,13 +95,31 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
</Button>
</div>
))}
{!loading && templates.filter(template => template.type !== WorkoutTemplateType.VOLNY).map(plan => (
<div key={plan.id}>
<h3 className="workout-plan-title">{plan.name}</h3>
{(plan.days ?? []).map(day => (
<div className="workout-row" key={day.id}>
<div className="workout-info">
<div className="workout-name">{day.name}</div>
<div className="workout-items">{describeItems(day.items)}</div>
</div>
<span className="workout-calories">{formatCalories(day.estimatedCalories)}</span>
<Button size="sm" variant="outline-primary" onClick={() => apply(plan.id, day.id)} disabled={busy}>
<FontAwesomeIcon icon={faPlay} /> Použít
</Button>
</div>
))}
</div>
))}
</Modal.Body>
<Modal.Footer>
<Button
variant="outline-secondary"
onClick={() => { onClose(); navigate(TRENINKY_URL); }}
>
Spravovat šablony
Spravovat tréninky
</Button>
<Button variant="outline-secondary" onClick={() => onClose()}>Zavřít</Button>
</Modal.Footer>
@@ -58,20 +58,20 @@ const SaveWorkoutTemplateModal = ({
if (response.data) {
onSaved();
} else {
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo vytvořit');
setError((response.error as { error?: string })?.error ?? 'Trénink se nepodařilo vytvořit');
}
};
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>Uložit jako šablonu tréninku</Modal.Title>
<Modal.Title>Uložit jako trénink</Modal.Title>
</Modal.Header>
<Modal.Body>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
<Form.Group className="mb-3">
<Form.Label>Název šablony</Form.Label>
<Form.Label>Název tréninku</Form.Label>
<Form.Control
autoFocus
value={name}
@@ -99,7 +99,7 @@ const SaveWorkoutTemplateModal = ({
<Modal.Footer>
<Button variant="outline-secondary" onClick={() => onClose()}>Zrušit</Button>
<Button onClick={() => save()} disabled={!canSave}>
{saving ? 'Ukládám…' : 'Vytvořit šablonu'}
{saving ? 'Ukládám…' : 'Vytvořit trénink'}
</Button>
</Modal.Footer>
</Modal>
+25
View File
@@ -1,5 +1,6 @@
import {
ActivityUnit, BmiKategorie, ImportMode, IngredientUnit, MealTemplate, MealType, Nutrients, Pohlavi, RecipeFlavor,
WorkoutTemplateType,
} from "../../types";
/** Lidsky čitelné názvy typů jídel. */
@@ -80,6 +81,30 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
];
/** Názvy typů šablon tréninků. */
export const WORKOUT_TEMPLATE_TYPE_NAMES: Record<WorkoutTemplateType, string> = {
VOLNY: 'Volný trénink',
FIXNI: 'Fixní plán',
PLOVOUCI: 'Plovoucí plán',
};
/** Vysvětlení typu šablony pro formulář. */
export const WORKOUT_TEMPLATE_TYPE_HINTS: Record<WorkoutTemplateType, string> = {
VOLNY: 'Cviky, které jde odcvičit kdykoli. Do dne se přidávají ručně tlačítkem Z tréninků.',
FIXNI: 'Pevně dané dny v týdnu. Přehled dne sám navrhne trénink podle dne, ostatní dny jsou volno.',
PLOVOUCI: 'Sekvence dnů zakončená dnem volna. Další den přijde na řadu, až se splní předchozí; '
+ 'volno jde v cyklu jednou posunout dopředu — prostě ten den necvičíte.',
};
export const WORKOUT_TEMPLATE_TYPES_IN_ORDER: WorkoutTemplateType[] = [
WorkoutTemplateType.VOLNY,
WorkoutTemplateType.PLOVOUCI,
WorkoutTemplateType.FIXNI,
];
/** Dny v týdnu podle ISO (index 0 = pondělí = 1) pro fixní plán. */
export const WEEKDAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle'];
/** Záložky příjem/výdej v přehledu dne. */
export enum DayTab {
PRIJEM = 'PRIJEM',
+59 -26
View File
@@ -2,18 +2,19 @@ import { useEffect, useMemo, useState } from "react";
import { Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
faTriangleExclamation,
faArrowRight, faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
faRotateLeft, faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis,
} from "recharts";
import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types";
import { StatsRange } from "../enums";
import { formatBalance, formatCalories } from "../Utils";
import { formatBalance, formatCalories, formatShortDate } from "../Utils";
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
import {
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries,
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries, getSeriesId, getUsageName,
hasUnresolvedNote, listSeriesIds,
} from "./activityChartSeries";
type Props = {
@@ -28,16 +29,28 @@ function deviationClass(deviation: number): string {
return '';
}
/** Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. */
/**
* Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo.
* Nahrazený cvik nese šipku na den, kdy se dodělal.
*/
function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) {
const unresolved = note.missing.length > 0 || note.changed.length > 0;
return (
<div className="plan-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" />
<FontAwesomeIcon
icon={unresolved ? faTriangleExclamation : faArrowRight}
className={unresolved ? 'plan-note-icon' : 'plan-note-icon replaced'}
/>
<div>
{note.date && <span className="plan-note-date">{note.date} </span>}
<strong>{note.templateName}</strong>
{!!note.missing.length && <div>nesplněno: {note.missing.join(', ')}</div>}
{!!note.changed.length && <div>upraveno: {note.changed.join(', ')}</div>}
{note.replaced.map((item, index) => (
<div key={index} className="plan-note-replaced">
nahrazeno: {item.name} <FontAwesomeIcon icon={faArrowRight} /> {formatShortDate(item.replacementDate)}
</div>
))}
</div>
</div>
);
@@ -62,11 +75,20 @@ function ActivityTooltip({ active, payload, label }: Readonly<Partial<TooltipCon
{row.planned > 0 && (
<div className="activity-tooltip-row activity-tooltip-plan">
<span className="activity-tooltip-swatch plan" />
<span>Plán šablon</span>
<span>Plán</span>
<span className="activity-tooltip-value">{formatCalories(row.planned)}</span>
</div>
)}
{row.notes.map((note, index) => <PlanNoteLine key={index} note={note} />)}
{row.replacements.map((item, index) => (
<div key={`r${index}`} className="plan-note">
<FontAwesomeIcon icon={faRotateLeft} className="plan-note-icon replaced" />
<div>
náhrada za {formatShortDate(item.sourceDate)}: {item.name}
<div className="plan-note-date">{item.templateName}</div>
</div>
</div>
))}
</div>
);
}
@@ -76,6 +98,7 @@ type DotProps = { cx?: number, cy?: number, payload?: ActivitySeriesRow, index?:
/**
* Bod plánu. Kde se ze šablony něco nesplnilo, je výraznější a nese "!" —
* sedí na úrovni plánu, tedy přesně nad mezerou, kterou nesplněné cviky nechaly.
* Když se všechno vynechané později nahradilo, nese místo "!" šipku k náhradě.
*/
function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
// Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama
@@ -83,6 +106,14 @@ function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
if (!payload.notes.length) {
return <circle key={index} cx={cx} cy={cy} r={3} fill="var(--ft-text-secondary)" />;
}
if (!hasUnresolvedNote(payload)) {
return (
<g key={index} className="plan-dot-replaced">
<circle cx={cx} cy={cy} r={8} fill="var(--ft-info)" stroke="var(--ft-bg-card)" strokeWidth={2} />
<text x={cx} y={cy} dy="0.35em" textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--ft-bg-card)">→</text>
</g>
);
}
return (
<g key={index} className="plan-dot-warning">
<circle cx={cx} cy={cy} r={8} fill="var(--ft-warning)" stroke="var(--ft-bg-card)" strokeWidth={2} />
@@ -114,12 +145,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
// Pořadí všech šablon dává barvy, které se nemění podle zobrazeného období
useEffect(() => {
getWorkoutTemplates().then(response => setTemplateIds((response.data ?? []).map(template => template.id)));
getWorkoutTemplates().then(response => setTemplateIds(listSeriesIds(response.data ?? [])));
}, []);
const { series, templateSeries } = useMemo(() => {
if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] };
const used = new Map(stats.byTemplate.map(usage => [usage.templateId, usage.name]));
const used = new Map(stats.byTemplate.map(usage => [getSeriesId(usage), getUsageName(usage)]));
const built = buildTemplateSeries(templateIds, used);
return {
series: buildActivitySeries(stats.days, range, mode, built.keyOf),
@@ -141,7 +172,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádný pohyb</div>
<p className="empty-description">
Aktivity přidáte v přehledu dne na záložce Výdej, ručně nebo ze šablony tréninku.
Aktivity přidáte v přehledu dne na záložce Výdej, ručně, z plánu nebo z tréninku.
</p>
</div>
);
@@ -162,14 +193,14 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<div className="summary-hint">za {stats.dayCount} dní s pohybem</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faBookmark} /> Ze šablon</div>
<div className="summary-label"><FontAwesomeIcon icon={faBookmark} /> Z plánů a tréninků</div>
<div className="summary-value">{formatCalories(stats.templateCalories)}</div>
<div className="summary-hint">
{fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'}
{fulfilment != null ? `${fulfilment} % plánu` : 'žádný plán ani trénink'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od šablon</div>
<div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od plánu</div>
<div className={`summary-value ${deviationClass(deviation)}`}>
{deviation ? formatBalance(deviation) : '—'}
</div>
@@ -178,7 +209,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Mimo šablonu</div>
<div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Ostatní pohyb</div>
<div className="summary-value">{formatCalories(stats.extraCalories)}</div>
<div className="summary-hint">z {extraCount} aktivit</div>
</div>
@@ -207,7 +238,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
))}
<Bar
dataKey="extra"
name="Mimo šablonu"
name="Ostatní pohyb"
stackId="vydej"
fill="var(--ft-series-other)"
stroke="var(--ft-bg-card)"
@@ -217,7 +248,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
{/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */}
<Line
dataKey="planned"
name="Plán šablon"
name="Plán"
stroke="var(--ft-text-secondary)"
strokeWidth={2}
strokeDasharray="5 4"
@@ -229,16 +260,18 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</ResponsiveContainer>
<p className="usage-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" /> u bodu plánu
znamená, že se ze šablony něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec.
znamená, že se z plánu něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec.
{' '}<FontAwesomeIcon icon={faArrowRight} className="plan-note-icon replaced" /> znamená, že se
vynechané nahradilo jiný den; bublina ukáže kdy. Dny volna a necvičené dny plánu se nezapisují.
</p>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Plnění šablon</h2>
<h2 className="chart-title">Plnění plánů a tréninků</h2>
<table className="source-table usage-table">
<thead>
<tr>
<th>Šablona</th>
<th>Trénink</th>
<th className="numeric">Použito</th>
<th className="numeric">Cviky</th>
<th className="numeric">Plán</th>
@@ -250,8 +283,8 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
{stats.byTemplate.map(usage => {
const usageDeviation = usage.actualCalories - usage.plannedCalories;
return (
<tr key={usage.templateId}>
<td>{usage.name}</td>
<tr key={`${usage.templateId}:${usage.planDayId ?? ''}`}>
<td>{getUsageName(usage)}</td>
<td className="numeric">{usage.dayCount}×</td>
<td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</td>
<td className="numeric">{formatCalories(usage.plannedCalories)}</td>
@@ -266,7 +299,7 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
<tr className="extras-toggle" onClick={() => setExtrasOpen(open => !open)}>
<td>
<FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth />
{' '}Mimo šablonu
{' '}Ostatní pohyb
</td>
<td className="numeric">{extraCount}×</td>
<td className="numeric" />
@@ -288,12 +321,12 @@ export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
</tbody>
</table>
{!stats.byTemplate.length && (
<p className="usage-note">V tomto období jste žádnou šablonu tréninku nepoužili.</p>
<p className="usage-note">V tomto období jste nepoužili žádný plán ani trénink.</p>
)}
<p className="usage-note">
Plán je to, co šablona slibovala v den použití. Smazaný nebo zkrácený cvik se
projeví jako odchylka; když smažete všechny cviky šablony, bere se to jako
vrácené použití.
Plán je to, co trénink sliboval v den použití. Smazaný nebo zkrácený cvik se
projeví jako odchylka, nahrazený jiný den se počítá jako splněný; když smažete
všechny cviky tréninku, bere se to jako vrácené použití.
</p>
</div>
</>
+118
View File
@@ -262,3 +262,121 @@
color: var(--ft-text-secondary);
}
}
// Návrh tréninku z plánu a cviky k náhradě nad seznamem aktivit
.planned-workout-card {
margin-bottom: 14px;
padding: 14px 18px;
border-left: 4px solid var(--ft-primary);
.planned-workout-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.planned-workout-icon {
color: var(--ft-primary);
}
.planned-workout-title {
flex: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
color: var(--ft-text);
}
.planned-workout-plan {
font-size: 0.82rem;
color: var(--ft-text-muted);
}
.planned-workout-calories {
color: var(--ft-warning);
font-weight: 700;
white-space: nowrap;
}
.planned-workout-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.planned-workout-hint {
flex: 1;
font-size: 0.8rem;
color: var(--ft-text-muted);
}
}
.planned-workout {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
border-radius: var(--ft-radius);
background: var(--ft-bg-card);
font-size: 0.88rem;
color: var(--ft-text-secondary);
}
.planned-workout-done svg {
color: var(--ft-success);
}
.planned-workout-rest svg {
color: var(--ft-text-muted);
}
.pending-replacements {
margin-bottom: 14px;
padding: 14px 18px;
.pending-replacements-title {
margin: 0 0 6px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ft-text-secondary);
}
.pending-replacement-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--ft-border-light);
&:last-child {
border-bottom: none;
}
}
.pending-replacement-main {
flex: 1;
min-width: 0;
}
.pending-replacement-name {
font-weight: 600;
color: var(--ft-text);
}
.pending-replacement-meta {
font-size: 0.8rem;
color: var(--ft-text-muted);
}
.template-pick-calories {
color: var(--ft-warning);
font-weight: 600;
white-space: nowrap;
}
}
+27 -6
View File
@@ -8,7 +8,7 @@ import {
faWeightScale,
} from "@fortawesome/free-solid-svg-icons";
import {
ActivityEntry, DayOverview, MealEntry,
ActivityEntry, DayOverview, MealEntry, PlannedWorkoutStatus,
deleteActivity, deleteMeal, getDayOverview, saveSleep, saveWeight, setActivityMultiplier, setMealMultiplier,
} from "../../../types";
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
@@ -27,6 +27,8 @@ import ApplyMealModal from "../components/modals/ApplyMealModal";
import SaveWorkoutTemplateModal from "../components/modals/SaveWorkoutTemplateModal";
import MealRow from "../components/MealRow";
import ActivityRow from "../components/ActivityRow";
import PlannedWorkoutCard from "../components/PlannedWorkoutCard";
import PendingReplacementsPanel from "../components/PendingReplacementsPanel";
import { DAY_DATE_PARAM } from "../routes";
import "./DayPage.scss";
@@ -37,6 +39,8 @@ function emptyOverview(date: string): DayOverview {
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
activities: { date, entries: [], totalCalories: 0 },
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false },
plannedWorkouts: [],
pendingReplacements: [],
};
}
@@ -140,6 +144,9 @@ export default function DayPage() {
setSaveWorkoutOpen(true);
};
/** Plán, který na dnešek ještě čeká na potvrzení — pak se nehlásí prázdný den. */
const hasProposal = day.plannedWorkouts.some(planned => planned.status === PlannedWorkoutStatus.NAVRH);
const removeActivity = async (entry: ActivityEntry) => {
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
if (response.data) setDay(response.data);
@@ -162,11 +169,11 @@ export default function DayPage() {
<div className="header-actions">
{!!day.activities.entries.length && (
<Button variant="outline-secondary" onClick={() => openSaveWorkout()}>
<FontAwesomeIcon icon={faBookmark} /> Uložit jako šablonu
<FontAwesomeIcon icon={faBookmark} /> Uložit jako trénink
</Button>
)}
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
<FontAwesomeIcon icon={faDumbbell} /> Ze šablony
<FontAwesomeIcon icon={faDumbbell} /> Z tréninků
</Button>
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
@@ -358,12 +365,26 @@ export default function DayPage() {
{!loading && tab === DayTab.VYDEJ && (
<>
{!day.activities.entries.length && (
{day.plannedWorkouts.map(planned => (
<PlannedWorkoutCard
key={planned.templateId}
planned={planned}
date={date}
onConfirmed={saved => setDay(saved)}
/>
))}
<PendingReplacementsPanel
replacements={day.pendingReplacements}
date={date}
onReplaced={saved => setDay(saved)}
/>
{!day.activities.entries.length && !hasProposal && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
<div className="empty-title">Za tento den nemáte žádný pohyb</div>
<p className="empty-description">
Přidejte aktivitu, nebo použijte šablonu tréninku a založí se celá naráz.
Přidejte aktivitu, nebo si na stránce Tréninky založte plán — přehled dne
pak sám navrhne, co je na řadě.
</p>
</div>
)}
@@ -436,7 +457,7 @@ export default function DayPage() {
onClose={() => setSaveWorkoutOpen(false)}
onSaved={() => {
setSaveWorkoutOpen(false);
showNotice('Šablona tréninku vytvořena.');
showNotice('Trénink vytvořen.');
}}
/>
+9
View File
@@ -307,6 +307,15 @@
.plan-note-icon {
color: var(--ft-warning);
margin-top: 2px;
// Vynechané, které se později nahradilo — informace, ne varování
&.replaced {
color: var(--ft-info);
}
}
.plan-note-replaced {
color: var(--ft-text-secondary);
}
.usage-note {
+1 -1
View File
@@ -21,7 +21,7 @@ const TAB_ICONS: Record<StatsTab, typeof faCoins> = {
const TAB_SUBTITLES: Record<StatsTab, string> = {
PRIJEM: 'Kolik jste snědli, v čem a jak to vychází proti výdeji',
VYDEJ: 'Kolik jste spálili pohybem a jak se držíte šablon',
VYDEJ: 'Kolik jste spálili pohybem a jak se držíte plánu',
UTRATA: 'Kolik jste utratili za jídlo a za co',
};
+50 -333
View File
@@ -1,60 +1,31 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Form, Spinner } from "react-bootstrap";
import { Alert, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPlus, faPenToSquare, faDumbbell, faLayerGroup } from "@fortawesome/free-solid-svg-icons";
import {
ActivityUnit, WorkoutItemKind, WorkoutTemplate, WorkoutTemplateItem,
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
} from "../../../types";
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
import {
deriveActivityCalories, formatCalories, formatExtraWeight, formatKgInput, formatQuantityWithSets,
parseIntInput, parseWeightKgInput,
} from "../Utils";
import { faDumbbell } from "@fortawesome/free-solid-svg-icons";
import { WorkoutTemplate, WorkoutTemplateType, deleteWorkoutTemplate, getWorkoutTemplates } from "../../../types";
import WorkoutTemplateCard from "../components/WorkoutTemplateCard";
import WorkoutTemplateForm from "../components/WorkoutTemplateForm";
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
type DraftItem = {
kind: WorkoutItemKind,
name: string,
unit: ActivityUnit,
quantity: string,
sets: string,
caloriesPer100Units: string,
/** Dodatečná zátěž v kg, jak ji uživatel píše */
extraWeight: string,
templateRefId: string,
};
const emptyExercise = (): DraftItem => ({
kind: WorkoutItemKind.CVIK,
name: '',
unit: ActivityUnit.OPAKOVANI,
quantity: '',
sets: '',
caloriesPer100Units: '',
extraWeight: '',
templateRefId: '',
});
const emptyReference = (templateRefId: string): DraftItem => ({
...emptyExercise(),
kind: WorkoutItemKind.SABLONA,
templateRefId,
});
/** Plány se řadí před volné šablony — jsou to ty, podle kterých se cvičí každý den. */
const sortTemplates = (templates: WorkoutTemplate[]): WorkoutTemplate[] => [
...templates.filter(template => template.type !== WorkoutTemplateType.VOLNY),
...templates.filter(template => template.type === WorkoutTemplateType.VOLNY),
];
/**
* Panel se správou šablon tréninků.
*
* Šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou šablonu,
* jejíž cviky se při použití rozbalí — zanoření je ale povolené jen na jednu
* úroveň, takže šablona, která už něco skládá, se sama vnořit nedá.
* Volná šablona je pojmenovaný seznam cviků. Může mezi ně zařadit i jinou
* volnou šablonu, jejíž cviky se při použití rozbalí — zanoření je ale povolené
* jen na jednu úroveň. Plán (fixní nebo plovoucí) má tréninkové dny a přehled
* dne z něj sám navrhuje, co je na řadě; volné šablony do jeho dnů jde zařadit.
*/
const WorkoutTemplatesPanel = () => {
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [editedId, setEditedId] = useState<string | undefined>();
const [name, setName] = useState('');
const [items, setItems] = useState<DraftItem[]>([emptyExercise()]);
const [edited, setEdited] = useState<WorkoutTemplate | undefined>();
/** Mění se po uložení, aby se formulář nové šablony vyprázdnil. */
const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
@@ -67,72 +38,9 @@ const WorkoutTemplatesPanel = () => {
useEffect(() => { load(); }, [load]);
const resetForm = () => {
setEditedId(undefined);
setName('');
setItems([emptyExercise()]);
setError(undefined);
};
const updateItem = (index: number, changes: Partial<DraftItem>) =>
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
const applyPreset = (index: number, value: string) => {
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
updateItem(index, preset
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
: { name: value });
};
/**
* Šablony, které lze vnořit do právě upravované.
*
* Vypadne ta upravovaná (odkaz sám na sebe) a všechny, které samy něco
* skládají — ty už by druhou úroveň zanoření přinesly.
*/
const nestable = templates.filter(template => template.id !== editedId && !template.hasNested);
/** True, pokud upravovanou šablonu už někdo skládá — pak do ní nelze vnořit další. */
const isUsedByOther = !!editedId && (templates.find(t => t.id === editedId)?.usedByCount ?? 0) > 0;
const toItems = (): WorkoutTemplateItem[] => items
.filter(item => item.kind === WorkoutItemKind.SABLONA
? item.templateRefId.length > 0
: item.name.trim().length > 0 && parseIntInput(item.quantity) != null)
.map(item => item.kind === WorkoutItemKind.SABLONA
? { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId }
: {
kind: WorkoutItemKind.CVIK,
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(parseIntInput(item.sets) != null && parseIntInput(item.sets)! > 1
? { sets: parseIntInput(item.sets) }
: {}),
...(parseIntInput(item.caloriesPer100Units) != null
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
: {}),
...(parseWeightKgInput(item.extraWeight) != null
? { extraWeightGrams: parseWeightKgInput(item.extraWeight) }
: {}),
});
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
const save = async () => {
if (!canSave) return;
setBusy(true);
setError(undefined);
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
setBusy(false);
if (response.data) {
setTemplates(response.data);
resetForm();
} else {
// Server odmítá typicky kvůli pravidlu o jedné úrovni zanoření
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
}
const closeForm = () => {
setEdited(undefined);
setFormKey(key => key + 1);
};
const remove = async (id: string) => {
@@ -142,50 +50,30 @@ const WorkoutTemplatesPanel = () => {
setBusy(false);
if (response.data) {
setTemplates(response.data);
if (editedId === id) resetForm();
if (edited?.id === id) closeForm();
} else {
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo smazat');
setError((response.error as { error?: string })?.error ?? 'Nepodařilo se smazat');
}
};
/** Načte šablonu do formuláře k úpravě. */
const edit = (template: WorkoutTemplate) => {
setEditedId(template.id);
setName(template.name);
setError(undefined);
setItems(template.items.map(item => item.kind === WorkoutItemKind.SABLONA
? emptyReference(item.templateRefId ?? '')
: {
kind: WorkoutItemKind.CVIK,
name: item.name ?? '',
unit: item.unit ?? ActivityUnit.OPAKOVANI,
quantity: item.quantity != null ? String(item.quantity) : '',
sets: item.sets != null ? String(item.sets) : '',
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
extraWeight: formatKgInput(item.extraWeightGrams),
templateRefId: '',
}));
setEdited(template);
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
};
/** Průběžný odhad kalorií rozpracované šablony, včetně vnořených. */
const draftCalories = items.reduce((sum, item) => {
if (item.kind === WorkoutItemKind.SABLONA) {
return sum + (templates.find(t => t.id === item.templateRefId)?.estimatedCalories ?? 0);
}
return sum + (deriveActivityCalories(
parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0);
}, 0);
return (
<>
<p className="text-body-secondary small">
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím.
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku.
Zanoření je kvůli jednoduchosti jen na jednu úroveň. U cviku jde zapsat
i dodatečnou zátěž (činky, vesta) — je to jen informace, kalorie nemění.
<strong>Plán</strong> popisuje, jak běžně cvičíte — fixní podle dnů v týdnu, plovoucí jako
sekvenci dnů s volnem na konci. Přehled dne z něj sám navrhne trénink a vy ho jen potvrdíte;
vynechané cviky jde ve zvolené lhůtě nahradit jindy. <strong>Volný trénink</strong> je seznam
cviků na kdykoli (rozcvička, protažení) a jde vložit i do dne plánu nebo do jiného volného tréninku.
U cviku jde zapsat i dodatečnou zátěž — je to jen informace, kalorie nemění. Úprava plánu
ani tréninku historii nemění, co jste kdy odcvičili, zůstává.
</p>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
@@ -195,202 +83,31 @@ const WorkoutTemplatesPanel = () => {
{!loading && !templates.length && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
<div className="empty-title">Zatím nemáte žádný plán ani trénink</div>
<p className="empty-description">Sestavte si ji ve formuláři níže.</p>
</div>
)}
{!loading && templates.map(template => (
<div className="card-panel template-card" key={template.id}>
<div className="template-head">
<div className="template-title">
{template.name}
{template.hasNested && (
<span className="tag tag-import">
<FontAwesomeIcon icon={faLayerGroup} /> skládaná
</span>
)}
{template.usedByCount > 0 && (
<span className="tag">používá ji {template.usedByCount}×</span>
)}
</div>
<span className="template-calories">{formatCalories(template.estimatedCalories)}</span>
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
<FontAwesomeIcon icon={faPenToSquare} />
</button>
<button
className="btn-icon danger"
onClick={() => remove(template.id)}
disabled={busy}
title={template.usedByCount > 0 ? 'Nelze smazat, skládá ji jiná šablona' : 'Smazat'}
aria-label="Smazat šablonu"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
<ul className="template-items">
{template.items.map((item, index) => (
// Položky nemají vlastní id, v rámci jedné šablony je index stabilní
<li key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="template-item-icon" />
<span className="template-item-name">{item.templateRefName ?? 'neznámá šablona'}</span>
<span className="template-item-calories">
{formatCalories(item.templateRefCalories)}
</span>
</>
) : (
<>
<span className="template-item-name">{item.name}</span>
<span className="template-item-quantity">
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
</span>
{!!item.extraWeightGrams && (
<span className="tag" title="Dodatečná zátěž">
{formatExtraWeight(item.extraWeightGrams)}
</span>
)}
<span className="template-item-calories">
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
</span>
</>
)}
</li>
))}
</ul>
</div>
{!loading && sortTemplates(templates).map(template => (
<WorkoutTemplateCard
key={template.id}
template={template}
busy={busy}
onEdit={() => edit(template)}
onDelete={() => remove(template.id)}
/>
))}
<div className="card-panel template-form">
<h2 className="chart-title">{editedId ? 'Úprava šablony' : 'Nová šablona'}</h2>
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
value={name}
placeholder="Např. Workout day 1"
onChange={event => setName(event.target.value)}
/>
</Form.Group>
{items.map((item, index) => (
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
<div className="workout-item-row" key={index}>
{item.kind === WorkoutItemKind.SABLONA ? (
<>
<FontAwesomeIcon icon={faLayerGroup} className="workout-item-nested-icon" />
<Form.Select
value={item.templateRefId}
onChange={event => updateItem(index, { templateRefId: event.target.value })}
>
<option value="">— vyberte šablonu —</option>
{nestable.map(template => (
<option key={template.id} value={template.id}>
{template.name} ({template.estimatedCalories} kcal)
</option>
))}
</Form.Select>
</>
) : (
<>
<Form.Control
list="activity-presets-templates"
value={item.name}
placeholder="Cvik"
onChange={event => applyPreset(index, event.target.value)}
/>
<Form.Control
className="workout-item-sets"
inputMode="numeric"
value={item.sets}
placeholder="série"
title="Počet sérií"
onChange={event => updateItem(index, { sets: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-quantity"
inputMode="numeric"
value={item.quantity}
placeholder="60"
title="Množství v jedné sérii"
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
/>
<Form.Select
className="workout-item-unit"
value={item.unit}
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
>
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
))}
</Form.Select>
<Form.Control
className="workout-item-rate"
inputMode="numeric"
value={item.caloriesPer100Units}
placeholder="kcal/100"
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
/>
<Form.Control
className="workout-item-weight"
inputMode="decimal"
value={item.extraWeight}
placeholder="zátěž kg"
title="Dodatečná zátěž v kg — činky, vesta. Kalorie nemění."
onChange={event => updateItem(index, { extraWeight: event.target.value })}
/>
</>
)}
<button
className="btn-icon danger"
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
disabled={items.length === 1}
title="Odebrat položku"
aria-label="Odebrat položku"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
<datalist id="activity-presets-templates">
{ACTIVITY_PRESETS.map(preset => (
<option key={preset.name} value={preset.name} />
))}
</datalist>
<div className="workout-form-footer">
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyExercise()])}
>
<FontAwesomeIcon icon={faPlus} /> Cvik
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => setItems(current => [...current, emptyReference('')])}
disabled={!nestable.length || isUsedByOther}
title={isUsedByOther
? 'Tuto šablonu už skládá jiná, další zanoření by bylo druhou úrovní'
: (!nestable.length ? 'Není k dispozici žádná šablona, kterou by šlo vnořit' : undefined)}
>
<FontAwesomeIcon icon={faLayerGroup} /> Vnořit šablonu
</Button>
{draftCalories > 0 && (
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
)}
{editedId && (
<Button variant="outline-secondary" size="sm" onClick={() => resetForm()}>Zrušit úpravu</Button>
)}
<Button size="sm" onClick={() => save()} disabled={!canSave}>
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
</Button>
</div>
</div>
<WorkoutTemplateForm
key={edited?.id ?? `nova-${formKey}`}
templates={templates}
edited={edited}
onSaved={saved => {
setTemplates(saved);
closeForm();
}}
onCancel={() => closeForm()}
/>
</>
);
};
+55 -10
View File
@@ -1,4 +1,4 @@
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
import { ActivityDayStats, PlanDeviationKind, WorkoutTemplate } from "../../../types";
import { StatsRange } from "../enums";
import { formatDateString } from "../Utils";
import { StatsBounds, formatPeriodLabel, listPeriods, periodOf } from "./statsShared";
@@ -9,6 +9,9 @@ export const SERIES_COLOR_COUNT = 8;
/** Klíč řady pro šablony, které se do palety nevešly. */
export const OTHER_TEMPLATES_KEY = 'ostatni';
/** Vynechaný cvik, který se později nahradil, a kdy. */
export type ReplacedNote = { name: string, replacementDate: string };
/** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */
export type PlanNote = {
/** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */
@@ -16,6 +19,14 @@ export type PlanNote = {
templateName: string;
missing: string[];
changed: string[];
replaced: ReplacedNote[];
};
/** Náhrada cviku vynechaného dřív, odcvičená v období sloupce. */
export type ReplacementNote = {
name: string;
templateName: string;
sourceDate: string;
};
/** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
@@ -28,12 +39,34 @@ export type ActivitySeriesRow = {
*/
planned: number;
notes: PlanNote[];
[templateKey: string]: number | string | null | PlanNote[];
replacements: ReplacementNote[];
[templateKey: string]: number | string | null | PlanNote[] | ReplacementNote[];
};
/** Šablona jako řada grafu. */
export type TemplateSeries = { key: string, name: string, color: string };
/** Název použití šablony — u plánu s názvem dne. */
export const getUsageName = (usage: { name: string, dayName?: string }): string =>
usage.dayName ? `${usage.name} · ${usage.dayName}` : usage.name;
/** Vrátí true, pokud se v období sloupce ze šablony něco nesplnilo a nenahradilo. */
export const hasUnresolvedNote = (row: ActivitySeriesRow): boolean =>
row.notes.some(note => note.missing.length > 0 || note.changed.length > 0);
/**
* Identifikátor řady grafu. Plán má řadu za každý svůj den — celý plán jednou
* barvou by neukázal, který den se plní a který ne.
*/
export const getSeriesId = (usage: { templateId: string, planDayId?: string }): string =>
usage.planDayId ? `${usage.templateId}:${usage.planDayId}` : usage.templateId;
/** Identifikátory všech řad v pořadí, které určuje barvy — volné tréninky a dny plánů. */
export const listSeriesIds = (templates: WorkoutTemplate[]): string[] => templates.flatMap(template =>
template.days?.length
? template.days.map(day => getSeriesId({ templateId: template.id, planDayId: day.id }))
: [template.id]);
/** Klíč řady šablony v řádku grafu. */
export const templateKey = (templateId: string): string => `t:${templateId}`;
@@ -44,8 +77,8 @@ export const templateKey = (templateId: string): string => `t:${templateId}`;
* co se v zobrazeném období zrovna použilo — jinak by šablona měnila barvu podle
* zvoleného měsíce. Smazané šablony a ty nad počet barev jdou do "Ostatní".
*
* @param allTemplateIds identifikátory všech šablon uživatele seřazené dle názvu
* @param used použité šablony v období (id → název)
* @param allTemplateIds identifikátory všech řad (viz {@link listSeriesIds})
* @param used použité řady v období (id → název)
*/
export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): {
series: TemplateSeries[], keyOf: (templateId: string) => string,
@@ -60,7 +93,7 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
.sort(([a], [b]) => colorIndex.get(a)! - colorIndex.get(b)!)
.map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` }));
if ([...used.keys()].some(id => keyOf(id) === OTHER_TEMPLATES_KEY)) {
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní šablony', color: 'var(--ft-series-other-template)' });
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní tréninky', color: 'var(--ft-series-other-template)' });
}
return { series, keyOf };
}
@@ -72,13 +105,13 @@ export function buildTemplateSeries(allTemplateIds: string[], used: Map<string,
* @param days dny s pohybem ze serveru
* @param range zobrazené období
* @param mode rozsah statistik
* @param keyOf klíč řady pro šablonu (viz {@link buildTemplateSeries})
* @param keyOf klíč řady podle jejího identifikátoru (viz {@link buildTemplateSeries})
*/
export function buildActivitySeries(
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
): ActivitySeriesRow[] {
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days[0]?.date).map(period => [period, {
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [], replacements: [],
}]));
for (const day of days) {
@@ -86,20 +119,32 @@ export function buildActivitySeries(
if (!row) continue;
row.extra += day.extraCalories;
for (const usage of day.templates) {
const key = keyOf(usage.templateId);
const key = keyOf(getSeriesId(usage));
row[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories;
row.planned += usage.plannedCalories;
const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO);
const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO);
if (missing.length || changed.length) {
const replaced = usage.deviations.filter(item => item.kind === PlanDeviationKind.NAHRAZENO);
if (missing.length || changed.length || replaced.length) {
row.notes.push({
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
templateName: usage.name,
templateName: getUsageName(usage),
missing: missing.map(item => item.planned.name),
changed: changed.map(item => item.planned.name),
replaced: replaced.map(item => ({ name: item.planned.name, replacementDate: item.replacementDate! })),
});
}
}
// Náhrada se kreslí do dílu plánu, ze kterého cvik pochází, plán dne nezvyšuje
for (const replacement of day.replacements) {
const key = keyOf(getSeriesId(replacement));
row[key] = ((row[key] as number | undefined) ?? 0) + replacement.item.calories;
row.replacements.push({
name: replacement.item.name,
templateName: getUsageName(replacement),
sourceDate: replacement.sourceDate,
});
}
}
return [...rows.values()];
}
+142
View File
@@ -309,3 +309,145 @@
border: none;
}
}
// ============================================
// PLÁNY TRÉNINKŮ
// ============================================
.tag-plan {
background: var(--ft-primary-light);
color: var(--ft-primary);
}
.template-card {
.template-plan-meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 0.8rem;
color: var(--ft-text-muted);
}
.template-plan-day {
margin-top: 12px;
}
// Hlavička dne je podbarvený pruh, cviky pod ní odsazené s linkou — na první
// pohled je vidět, kde den začíná a co k němu patří
.template-plan-day-head {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px;
border-left: 3px solid var(--ft-primary);
border-radius: var(--ft-radius-sm);
background: var(--ft-primary-light);
font-size: 0.9rem;
}
.template-plan-day-label {
color: var(--ft-primary);
font-weight: 700;
min-width: 20px;
}
.template-plan-day-name {
flex: 1;
font-weight: 700;
color: var(--ft-text);
}
.template-plan-day .template-items {
margin: 2px 0 0 12px;
padding-left: 12px;
border-left: 2px solid var(--ft-border);
li:first-child {
border-top: none;
}
}
// Den volna není záznam, jen připomínka konce cyklu
.template-plan-rest {
padding: 6px 10px;
border: 1px dashed var(--ft-border);
border-radius: var(--ft-radius-sm);
font-size: 0.85rem;
color: var(--ft-text-muted);
font-style: italic;
}
}
.template-form {
.template-form-head {
display: flex;
flex-wrap: wrap;
gap: 12px;
> * {
flex: 1 1 180px;
}
.template-form-name {
flex: 3 1 260px;
}
}
.input-with-unit {
display: flex;
align-items: center;
gap: 8px;
span {
color: var(--ft-text-secondary);
font-size: 0.88rem;
}
}
}
.workout-items-actions {
display: flex;
gap: 8px;
}
.plan-day-editor {
margin-bottom: 14px;
padding: 12px;
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
.plan-day-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.plan-day-weekday {
width: 140px;
flex: 0 0 auto;
}
.plan-day-order {
font-weight: 700;
color: var(--ft-text-secondary);
min-width: 22px;
}
.plan-day-calories {
color: var(--ft-warning);
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
}
}
.plan-rest-day {
margin-bottom: 14px;
padding: 10px 12px;
border: 1px dashed var(--ft-border);
border-radius: var(--ft-radius);
font-size: 0.87rem;
color: var(--ft-text-muted);
}
+111
View File
@@ -0,0 +1,111 @@
import { ActivityUnit, WorkoutItemKind, WorkoutPlanDay, WorkoutTemplate, WorkoutTemplateItem } from "../../../types";
import { deriveActivityCalories, formatKgInput, parseIntInput, parseWeightKgInput } from "../Utils";
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
export type DraftItem = {
kind: WorkoutItemKind,
name: string,
unit: ActivityUnit,
quantity: string,
sets: string,
caloriesPer100Units: string,
/** Dodatečná zátěž v kg, jak ji uživatel píše */
extraWeight: string,
templateRefId: string,
};
/**
* Rozpracovaný den plánu. `key` drží identitu v seznamu i u dne, který ještě
* nemá id ze serveru; `id` se posílá zpět, aby den zůstal tím samým dnem.
*/
export type DraftDay = {
key: string,
id?: string,
name: string,
weekday: number,
items: DraftItem[],
};
export const emptyExercise = (): DraftItem => ({
kind: WorkoutItemKind.CVIK,
name: '',
unit: ActivityUnit.OPAKOVANI,
quantity: '',
sets: '',
caloriesPer100Units: '',
extraWeight: '',
templateRefId: '',
});
export const emptyReference = (templateRefId: string): DraftItem => ({
...emptyExercise(),
kind: WorkoutItemKind.SABLONA,
templateRefId,
});
let dayKeySequence = 0;
/** Nový den plánu s jedním prázdným cvikem. */
export const emptyDay = (name: string, weekday: number): DraftDay => ({
key: `novy-${++dayKeySequence}`,
name,
weekday,
items: [emptyExercise()],
});
/** Převede uložené položky šablony do rozpracované podoby. */
export const toDraftItems = (items: WorkoutTemplateItem[]): DraftItem[] => items.map(item =>
item.kind === WorkoutItemKind.SABLONA
? emptyReference(item.templateRefId ?? '')
: {
kind: WorkoutItemKind.CVIK,
name: item.name ?? '',
unit: item.unit ?? ActivityUnit.OPAKOVANI,
quantity: item.quantity != null ? String(item.quantity) : '',
sets: item.sets != null ? String(item.sets) : '',
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
extraWeight: formatKgInput(item.extraWeightGrams),
templateRefId: '',
});
/** Převede uložené dny plánu do rozpracované podoby. */
export const toDraftDays = (days: WorkoutPlanDay[]): DraftDay[] => days.map(day => ({
key: day.id,
id: day.id,
name: day.name,
weekday: day.weekday ?? 1,
items: toDraftItems(day.items),
}));
/** Převede rozpracované položky na položky pro server; nedopsané se vynechají. */
export const toTemplateItems = (items: DraftItem[]): WorkoutTemplateItem[] => items
.filter(item => item.kind === WorkoutItemKind.SABLONA
? item.templateRefId.length > 0
: item.name.trim().length > 0 && parseIntInput(item.quantity) != null)
.map(item => {
if (item.kind === WorkoutItemKind.SABLONA) {
return { kind: WorkoutItemKind.SABLONA, templateRefId: item.templateRefId };
}
const sets = parseIntInput(item.sets);
const rate = parseIntInput(item.caloriesPer100Units);
const extraWeight = parseWeightKgInput(item.extraWeight);
return {
kind: WorkoutItemKind.CVIK,
name: item.name.trim(),
unit: item.unit,
quantity: parseIntInput(item.quantity)!,
...(sets != null && sets > 1 ? { sets } : {}),
...(rate != null ? { caloriesPer100Units: rate } : {}),
...(extraWeight != null ? { extraWeightGrams: extraWeight } : {}),
};
});
/** Průběžný odhad kalorií rozpracovaných položek, včetně vnořených šablon. */
export const getDraftCalories = (items: DraftItem[], templates: WorkoutTemplate[]): number =>
items.reduce((sum, item) => {
if (item.kind === WorkoutItemKind.SABLONA) {
return sum + (templates.find(template => template.id === item.templateRefId)?.estimatedCalories ?? 0);
}
return sum + (deriveActivityCalories(
parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units), parseIntInput(item.sets)) ?? 0);
}, 0);