chore: graf db
feat: workout plans (volný, fixní, plovoucí)
This commit is contained in:
1 parent
865c2c3b22
commit
7641e26c9f
43 files changed
+3459
-599
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user