chore: commit chybějících souborů

This commit is contained in:
Ondřej Anděl committed 2026-10-02 19:38:02 +02:00
1 parent f1ce3ff297
commit 9e72f431a1
7 files changed
+890

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { Button, Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { StatsRange } from "../enums";
/** Nejstarší rok, do kterého sahá výběr i volba "Vše". Starší data nikdo evidovat nebude. */
export const EARLIEST_YEAR = 2015;
const MONTH_NAME_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long' });
/** Názvy měsíců v 1. pádě ("leden", "únor", ...), index 0 je leden. */
const MONTH_NAMES = Array.from({ length: 12 }, (_, index) => MONTH_NAME_FORMAT.format(new Date(2000, index, 1)));
type Props = {
mode: StatsRange;
/** Zvolený měsíc ve formátu YYYY-MM. */
month: string;
year: number;
onMonthChange: (month: string) => void;
onYearChange: (year: number) => void;
};
/** Sestaví měsíc ve formátu YYYY-MM. */
const toMonth = (year: number, monthIndex: number): string => `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
/**
* Výběr měsíce nebo roku statistik — šipky na posun a rozbalovací seznamy.
*
* Záměrně bez ručního psaní: rozepsaná hodnota ("20" při přepisování roku)
* by se hned odeslala na server jako neplatné období. Ze seznamu vždy přijde
* platná hodnota, takže tahle situace nemůže nastat.
*/
export default function PeriodPicker({ mode, month, year, onMonthChange, onYearChange }: Readonly<Props>) {
const currentYear = new Date().getFullYear();
const years = Array.from({ length: currentYear - EARLIEST_YEAR + 1 }, (_, index) => currentYear - index);
if (mode === StatsRange.MESIC) {
const monthYear = Number(month.substring(0, 4));
const monthIndex = Number(month.substring(5, 7)) - 1;
// Posun přes hranici roku obstará Date, prosinec + 1 je leden dalšího roku
const shift = (delta: number) => {
const shifted = new Date(monthYear, monthIndex + delta, 1);
if (shifted.getFullYear() < EARLIEST_YEAR || shifted.getFullYear() > currentYear) return;
onMonthChange(toMonth(shifted.getFullYear(), shifted.getMonth()));
};
return (
<div className="period-picker">
<Button variant="outline-secondary" size="sm" onClick={() => shift(-1)} aria-label="Předchozí měsíc">
<FontAwesomeIcon icon={faChevronLeft} />
</Button>
<Form.Select
size="sm"
value={monthIndex}
onChange={event => onMonthChange(toMonth(monthYear, Number(event.target.value)))}
aria-label="Měsíc"
>
{MONTH_NAMES.map((name, index) => <option key={name} value={index}>{name}</option>)}
</Form.Select>
<Form.Select
size="sm"
value={monthYear}
onChange={event => onMonthChange(toMonth(Number(event.target.value), monthIndex))}
aria-label="Rok"
>
{years.map(value => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button variant="outline-secondary" size="sm" onClick={() => shift(1)} aria-label="Další měsíc">
<FontAwesomeIcon icon={faChevronRight} />
</Button>
</div>
);
}
if (mode === StatsRange.ROK) {
return (
<div className="period-picker">
<Button
variant="outline-secondary"
size="sm"
disabled={year <= EARLIEST_YEAR}
onClick={() => onYearChange(year - 1)}
aria-label="Předchozí rok"
>
<FontAwesomeIcon icon={faChevronLeft} />
</Button>
<Form.Select
size="sm"
value={year}
onChange={event => onYearChange(Number(event.target.value))}
aria-label="Rok"
>
{years.map(value => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button
variant="outline-secondary"
size="sm"
disabled={year >= currentYear}
onClick={() => onYearChange(year + 1)}
aria-label="Další rok"
>
<FontAwesomeIcon icon={faChevronRight} />
</Button>
</div>
);
}
return null;
}
+301
View File
@@ -0,0 +1,301 @@
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,
} 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 { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
import {
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries,
} from "./activityChartSeries";
type Props = {
range: StatsBounds;
mode: StatsRange;
};
/** CSS třída odchylky — vynechané je chyba, přidané navíc je v pořádku. */
function deviationClass(deviation: number): string {
if (deviation < 0) return 'deviation-minus';
if (deviation > 0) return 'deviation-plus';
return '';
}
/** Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. */
function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) {
return (
<div className="plan-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" />
<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>}
</div>
</div>
);
}
/** Bublina sloupce — díly po šablonách, plán a hlavně co ze šablon nebylo splněno. */
function ActivityTooltip({ active, payload, label }: Readonly<Partial<TooltipContentProps<number, string>>>) {
if (!active || !payload?.length) return null;
const row = payload[0].payload as ActivitySeriesRow;
const parts = payload.filter(item => item.dataKey !== 'planned' && Number(item.value) > 0);
return (
<div className="activity-tooltip" style={TOOLTIP_STYLE}>
<div className="activity-tooltip-title">{label}</div>
{!parts.length && <div className="activity-tooltip-empty">bez pohybu</div>}
{parts.map(item => (
<div key={String(item.dataKey)} className="activity-tooltip-row">
<span className="activity-tooltip-swatch" style={{ background: item.color }} />
<span>{item.name}</span>
<span className="activity-tooltip-value">{formatCalories(Number(item.value))}</span>
</div>
))}
{row.planned > 0 && (
<div className="activity-tooltip-row activity-tooltip-plan">
<span className="activity-tooltip-swatch plan" />
<span>Plán šablon</span>
<span className="activity-tooltip-value">{formatCalories(row.planned)}</span>
</div>
)}
{row.notes.map((note, index) => <PlanNoteLine key={index} note={note} />)}
</div>
);
}
type DotProps = { cx?: number, cy?: number, payload?: ActivitySeriesRow, index?: number };
/**
* 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.
*/
function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
// Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama
if (cx == null || cy == null || !payload?.planned) return <g key={index} />;
if (!payload.notes.length) {
return <circle key={index} cx={cx} cy={cy} r={3} fill="var(--ft-text-secondary)" />;
}
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} />
<text x={cx} y={cy} dy="0.35em" textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--ft-bg-card)">!</text>
</g>
);
}
/**
* Statistiky výdeje pohybem: kolik se spálilo, kterými šablonami a co z nich
* zůstalo nesplněné, a kolik přibylo mimo ně.
*/
export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
const [stats, setStats] = useState<ActivityStats | undefined>();
const [templateIds, setTemplateIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [extrasOpen, setExtrasOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
getActivityStats({ query: range }).then(response => {
if (cancelled) return;
setStats(response.data);
setLoading(false);
});
return () => { cancelled = true; };
}, [range]);
// 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)));
}, []);
const { series, templateSeries } = useMemo(() => {
if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] };
const used = new Map(stats.byTemplate.map(usage => [usage.templateId, usage.name]));
const built = buildTemplateSeries(templateIds, used);
return {
series: buildActivitySeries(stats.days, range, mode, built.keyOf),
templateSeries: built.series,
};
}, [stats, templateIds, range, mode]);
if (loading) {
return (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
);
}
if (!stats?.entryCount) {
return (
<div className="card-panel empty-state">
<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.
</p>
</div>
);
}
const deviation = stats.templateCalories - stats.plannedCalories;
const fulfilment = stats.plannedCalories ? Math.round(stats.templateCalories / stats.plannedCalories * 100) : undefined;
const skippedItems = stats.byTemplate.reduce(
(sum, usage) => sum + Math.max(usage.plannedItemCount - usage.doneItemCount, 0), 0);
const extraCount = stats.extras.reduce((sum, group) => sum + group.count, 0);
return (
<>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faBolt} /> Pohybem spáleno</div>
<div className="summary-value">{formatCalories(stats.totalCalories)}</div>
<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-value">{formatCalories(stats.templateCalories)}</div>
<div className="summary-hint">
{fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od šablon</div>
<div className={`summary-value ${deviationClass(deviation)}`}>
{deviation ? formatBalance(deviation) : '—'}
</div>
<div className="summary-hint">
{skippedItems ? `vynecháno cviků: ${skippedItems}` : 'žádný cvik nevynechán'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Mimo šablonu</div>
<div className="summary-value">{formatCalories(stats.extraCalories)}</div>
<div className="summary-hint">z {extraCount} aktivit</div>
</div>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Výdej v čase</h2>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={series} margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip content={<ActivityTooltip />} cursor={{ fill: 'var(--ft-bg-hover)' }} />
<Legend />
{/* Každá šablona vlastní díl sloupce; tenký okraj v barvě karty díly odděluje */}
{templateSeries.map(item => (
<Bar
key={item.key}
dataKey={item.key}
name={item.name}
stackId="vydej"
fill={item.color}
stroke="var(--ft-bg-card)"
strokeWidth={1}
/>
))}
<Bar
dataKey="extra"
name="Mimo šablonu"
stackId="vydej"
fill="var(--ft-series-other)"
stroke="var(--ft-bg-card)"
strokeWidth={1}
radius={[4, 4, 0, 0]}
/>
{/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */}
<Line
dataKey="planned"
name="Plán šablon"
stroke="var(--ft-text-secondary)"
strokeWidth={2}
strokeDasharray="5 4"
dot={props => <PlanDot {...props} />}
activeDot={false}
type="linear"
/>
</ComposedChart>
</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.
</p>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Plnění šablon</h2>
<table className="source-table usage-table">
<thead>
<tr>
<th>Šablona</th>
<th className="numeric">Použito</th>
<th className="numeric">Cviky</th>
<th className="numeric">Plán</th>
<th className="numeric">Skutečnost</th>
<th className="numeric">Odchylka</th>
</tr>
</thead>
<tbody>
{stats.byTemplate.map(usage => {
const usageDeviation = usage.actualCalories - usage.plannedCalories;
return (
<tr key={usage.templateId}>
<td>{usage.name}</td>
<td className="numeric">{usage.dayCount}×</td>
<td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</td>
<td className="numeric">{formatCalories(usage.plannedCalories)}</td>
<td className="numeric">{formatCalories(usage.actualCalories)}</td>
<td className={`numeric ${deviationClass(usageDeviation)}`}>
{usageDeviation ? formatBalance(usageDeviation) : '—'}
</td>
</tr>
);
})}
{!!stats.extras.length && (
<tr className="extras-toggle" onClick={() => setExtrasOpen(open => !open)}>
<td>
<FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth />
{' '}Mimo šablonu
</td>
<td className="numeric">{extraCount}×</td>
<td className="numeric" />
<td className="numeric" />
<td className="numeric">{formatCalories(stats.extraCalories)}</td>
<td className="numeric" />
</tr>
)}
{extrasOpen && stats.extras.map(group => (
<tr key={group.key} className="extras-item">
<td>{group.key}</td>
<td className="numeric">{group.count}×</td>
<td className="numeric" />
<td className="numeric" />
<td className="numeric">{formatCalories(group.calories)}</td>
<td className="numeric" />
</tr>
))}
</tbody>
</table>
{!stats.byTemplate.length && (
<p className="usage-note">V tomto období jste žádnou šablonu tréninku nepoužili.</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í.
</p>
</div>
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
import { StatsRange } from "../enums";
import { formatDateString, shiftDate } from "../Utils";
import { StatsBounds, formatPeriodLabel } from "./statsShared";
/** Počet barev kategoriální palety (`--ft-series-1` až `-8`). Další šablony se slijí do "Ostatní šablony". */
export const SERIES_COLOR_COUNT = 8;
/** Klíč řady pro šablony, které se do palety nevešly. */
export const OTHER_TEMPLATES_KEY = 'ostatni';
/** 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í. */
date?: string;
templateName: string;
missing: string[];
changed: string[];
};
/** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
export type ActivitySeriesRow = {
label: string;
extra: number;
/**
* Plán šablon. Den bez šablony má plán nulový — čára jím prochází po ose,
* aby bylo vidět, že se ten den nic neplánovalo, místo aby se přerušila.
*/
planned: number;
notes: PlanNote[];
[templateKey: string]: number | string | null | PlanNote[];
};
/** Šablona jako řada grafu. */
export type TemplateSeries = { key: string, name: string, color: string };
/** Klíč řady šablony v řádku grafu. */
export const templateKey = (templateId: string): string => `t:${templateId}`;
/**
* Sestaví řady šablon s pevnou barvou.
*
* Barva se odvozuje z pořadí šablony v celém seznamu šablon uživatele, ne z toho,
* 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)
*/
export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): {
series: TemplateSeries[], keyOf: (templateId: string) => string,
} {
const colorIndex = new Map(allTemplateIds.map((id, index) => [id, index]));
const keyOf = (templateId: string) => {
const index = colorIndex.get(templateId);
return index != null && index < SERIES_COLOR_COUNT ? templateKey(templateId) : OTHER_TEMPLATES_KEY;
};
const series: TemplateSeries[] = [...used.entries()]
.filter(([id]) => keyOf(id) !== OTHER_TEMPLATES_KEY)
.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)' });
}
return { series, keyOf };
}
/**
* Vrátí označení všech období rozsahu, i těch bez pohybu — prázdný den v grafu
* je informace sama o sobě.
*
* U "Vše" se začíná prvním rokem s pohybem, jinak by graf otevíralo deset let nul.
*/
function listPeriods(range: StatsBounds, mode: StatsRange, days: ActivityDayStats[]): string[] {
if (mode === StatsRange.MESIC) {
const periods: string[] = [];
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
return periods;
}
if (mode === StatsRange.ROK) {
const year = range.from.substring(0, 4);
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
}
if (!days.length) return [];
const first = Number(days[0].date.substring(0, 4));
const last = Number(range.to.substring(0, 4));
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
}
/** Zkrátí datum dne na označení období grafu. */
const periodOf = (date: string, mode: StatsRange): string => {
if (mode === StatsRange.MESIC) return date;
if (mode === StatsRange.ROK) return date.substring(0, 7);
return date.substring(0, 4);
};
/**
* Sestaví sloupce grafu výdeje: každá šablona vlastní díl sloupce, nad ní
* aktivity mimo šablony, čára plánu a poznámky o nesplněných cvicích.
*
* @param days dny s pohybem ze serveru
* @param range zobrazené období
* @param mode rozsah statistik
* @param keyOf klíč řady pro šablonu (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).map(period => [period, {
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
}]));
for (const day of days) {
const row = rows.get(periodOf(day.date, mode));
if (!row) continue;
row.extra += day.extraCalories;
for (const usage of day.templates) {
const key = keyOf(usage.templateId);
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) {
row.notes.push({
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
templateName: usage.name,
missing: missing.map(item => item.planned.name),
changed: changed.map(item => item.planned.name),
});
}
}
}
return [...rows.values()];
}
+32
View File
@@ -0,0 +1,32 @@
import { StatsRange } from "../enums";
import { formatDateString, getHumanMonth } from "../Utils";
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
export const TOOLTIP_STYLE = {
background: 'var(--ft-bg-card)',
border: '1px solid var(--ft-border)',
borderRadius: 'var(--ft-radius-sm)',
color: 'var(--ft-text)',
};
/** Hranice zvoleného období statistik, YYYY-MM-DD včetně. */
export type StatsBounds = { from: string, to: string };
/**
* Popisek období na ose grafu — den u měsíce, měsíc u roku, rok u celého přehledu.
*
* @param period označení období ze serveru (YYYY-MM-DD, YYYY-MM nebo YYYY)
* @param mode zvolený rozsah statistik
*/
export function formatPeriodLabel(period: string, mode: StatsRange): string {
if (mode === StatsRange.MESIC) return formatDateString(period).slice(0, 5);
if (mode === StatsRange.ROK) return getHumanMonth(period).split(' ')[0];
return period;
}
/** Vybere řadu součtů podle rozsahu — dny u měsíce, měsíce u roku, roky u celého přehledu. */
export function pickPeriods<T>(stats: { byDay: T[], byMonth: T[], byYear: T[] }, mode: StatsRange): T[] {
if (mode === StatsRange.MESIC) return stats.byDay;
if (mode === StatsRange.ROK) return stats.byMonth;
return stats.byYear;
}
+209
View File
@@ -0,0 +1,209 @@
process.env.CALORIE_PROVIDER = 'none';
import { addActivity, deleteActivity, getActivityDay, setActivityMultiplier, updateActivity } from "../activities";
import { applyWorkoutTemplate, deleteWorkoutTemplate, saveWorkoutTemplate } from "../workouts";
import { getActivityStats } from "../statsService";
import { getWorkoutPlans } from "../workoutPlans";
import { resetMemoryStorage } from "../storage/memory";
import { applyMealTemplate, saveMealTemplate } from "../mealTemplates";
import { addMeal, getDay } from "../meals";
import { ActivityUnit, MealType, PlanDeviationKind, WorkoutItemKind } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
const NEXT_DATE = '2025-03-04';
/** Cvik jako položka šablony. */
const cvik = (name: string, quantity: number, rate: number) => ({
kind: WorkoutItemKind.CVIK,
name,
unit: ActivityUnit.OPAKOVANI,
quantity,
caloriesPer100Units: rate,
});
/** Založí šablonu Kliky 100 × 0,5 kcal + Dřepy 100 × 0,3 kcal, tedy 80 kcal. */
async function createWorkout(): Promise<string> {
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
cvik('Kliky', 100, 50),
cvik('Dřepy', 100, 30),
]);
return template.id;
}
const stats = () => getActivityStats(LOGIN, '2025-03-01', '2025-03-31');
beforeEach(() => resetMemoryStorage());
describe('statistiky výdeje', () => {
it('odcvičená šablona se shoduje s plánem', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const result = await stats();
expect(result.templateCalories).toBe(80);
expect(result.plannedCalories).toBe(80);
expect(result.extraCalories).toBe(0);
expect(result.byTemplate).toEqual([expect.objectContaining({
name: 'Workout', dayCount: 1, plannedCalories: 80, actualCalories: 80,
plannedItemCount: 2, doneItemCount: 2,
})]);
});
it('smazaný cvik ze šablony se projeví jako odchylka', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const drepy = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Dřepy')!;
await deleteActivity(LOGIN, drepy.id, DATE);
const result = await stats();
expect(result.templateCalories).toBe(50);
expect(result.plannedCalories).toBe(80);
expect(result.byTemplate[0]).toEqual(expect.objectContaining({ plannedItemCount: 2, doneItemCount: 1 }));
});
it('zkrácený cvik ze šablony se projeví jako odchylka', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
await setActivityMultiplier(LOGIN, kliky.id, DATE, 0.5);
const result = await stats();
expect(result.templateCalories).toBe(55);
expect(result.plannedCalories).toBe(80);
});
it('smazání všech cviků šablony vrátí její použití, nepočítá se jako odchylka', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
for (const entry of (await getActivityDay(LOGIN, DATE)).entries) {
await deleteActivity(LOGIN, entry.id, DATE);
}
expect(await getWorkoutPlans(LOGIN, DATE)).toEqual([]);
const result = await stats();
expect(result.plannedCalories).toBe(0);
expect(result.byTemplate).toEqual([]);
});
it('plán se drží podoby šablony v době použití, ne její pozdější úpravy', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
await saveWorkoutTemplate(LOGIN, id, 'Workout v2', [cvik('Kliky', 200, 50)]);
const result = await stats();
expect(result.plannedCalories).toBe(80);
expect(result.byTemplate[0].name).toBe('Workout');
});
it('aktivity mimo šablonu se sčítají zvlášť podle názvu', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
await addActivity(LOGIN, { date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
await addActivity(LOGIN, { date: NEXT_DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
const result = await stats();
expect(result.totalCalories).toBe(480);
expect(result.extraCalories).toBe(400);
expect(result.extras).toEqual([{ key: 'Chůze', calories: 400, count: 2 }]);
expect(result.dayCount).toBe(2);
expect(result.byDay).toEqual([
{ period: DATE, templateCalories: 80, plannedCalories: 80, extraCalories: 200 },
{ period: NEXT_DATE, templateCalories: 0, plannedCalories: 0, extraCalories: 200 },
]);
});
it('cvik přesunutý do jiného dne chybí v původním a v novém se bere bez odchylky', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
await updateActivity(LOGIN, kliky.id, { ...kliky, date: NEXT_DATE });
const result = await stats();
// Původní den: plán 80, zůstaly dřepy za 30; nový den: kliky 50 bez plánu
expect(result.templateCalories).toBe(80);
expect(result.plannedCalories).toBe(130);
});
it('šablona smazaná po použití se ve statistikách drží názvu z plánu', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
await deleteWorkoutTemplate(LOGIN, id);
const result = await stats();
expect(result.byTemplate[0].name).toBe('Workout');
});
it('odmítne obrácený rozsah', async () => {
await expect(getActivityStats(LOGIN, '2025-03-31', '2025-03-01')).rejects.toThrow();
});
});
describe('denní detail výdeje', () => {
it('ukáže, jaká šablona se použila a který cvik z ní chybí', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const drepy = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Dřepy')!;
await deleteActivity(LOGIN, drepy.id, DATE);
const [day] = (await stats()).days;
expect(day.date).toBe(DATE);
expect(day.templates).toEqual([expect.objectContaining({
name: 'Workout', plannedCalories: 80, actualCalories: 50, hasPlan: true,
deviations: [{
kind: PlanDeviationKind.ODEBRANO,
planned: { name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, quantity: 100, calories: 30 },
}],
})]);
});
it('upravený cvik ukáže plán i skutečnost', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
await updateActivity(LOGIN, kliky.id, { ...kliky, quantity: 40 });
const [usage] = (await stats()).days[0].templates;
expect(usage.deviations).toEqual([{
kind: PlanDeviationKind.UPRAVENO,
planned: expect.objectContaining({ name: 'Kliky', quantity: 100, calories: 50 }),
actual: expect.objectContaining({ name: 'Kliky', quantity: 40, calories: 20 }),
}]);
});
it('odcvičená šablona nemá žádnou odchylku', async () => {
const id = await createWorkout();
await applyWorkoutTemplate(LOGIN, id, DATE);
expect((await stats()).days[0].templates[0].deviations).toEqual([]);
});
it('aktivity mimo šablonu vrátí jednotlivě i se součtem', async () => {
await addActivity(LOGIN, { date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
await addActivity(LOGIN, { date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, caloriesPer100Units: 1000 });
const [day] = (await stats()).days;
expect(day.templates).toEqual([]);
expect(day.extraCalories).toBe(400);
expect(day.extras.map(item => item.name)).toEqual(['Chůze', 'Běh']);
});
});
describe('vazba jídla na šablonu', () => {
it('jídlo ze šablony si pamatuje, ze které vzniklo', async () => {
const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA });
await applyMealTemplate(LOGIN, template.id, DATE);
const [meal] = (await getDay(LOGIN, DATE)).entries;
expect(meal.templateId).toBe(template.id);
expect(meal.mealType).toBe(MealType.PULNOCNI_SVACINA);
});
it('ručně zadané jídlo vazbu nemá a klient ji podstrčit nemůže', async () => {
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Guláš', templateId: 'podvrh' });
const [meal] = (await getDay(LOGIN, DATE)).entries;
expect(meal.templateId).toBeUndefined();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
import getStorage from "./storage";
const storage = getStorage();
/** Jeden cvik plánu — jak ho šablona založila. */
export type WorkoutPlanItem = {
/**
* Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni
* zůstal a jestli se upravil — podle názvu by to nešlo, šablona může mít
* stejný cvik dvakrát. Chybí jen u plánů dopočtených zpětně z dnešní podoby
* šablony, kde se cvik ve dni nenašel.
*/
entryId?: string;
name: string;
unit: ActivityUnit;
quantity: number;
sets?: number;
calories: number;
};
/**
* Plán jednoho použití šablony v konkrétní den — co šablona v tu chvíli slibovala.
*
* Bez něj by nešlo poznat odchylku od šablony: smazaná aktivita beze stopy zmizí
* a šablona se mezitím mohla upravit, takže porovnání s její současnou podobou
* by lhalo.
*/
export type WorkoutPlan = {
templateId: string;
/** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */
templateName: string;
appliedAt: string;
items: WorkoutPlanItem[];
};
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`;
/**
* Vrátí plány šablon použitých v daném dni.
*
* @param login přihlašovací jméno uživatele
* @param date datum ve formátu YYYY-MM-DD
*/
export const getWorkoutPlans = async (login: string, date: string): Promise<WorkoutPlan[]> =>
await storage.getData<WorkoutPlan[]>(getKey(login, date)) ?? [];
/**
* Zapamatuje si plán použité šablony.
*
* @param login přihlašovací jméno uživatele
* @param date den, do kterého se šablona použila
* @param plan plán šablony
*/
export const recordWorkoutPlan = async (login: string, date: string, plan: WorkoutPlan): Promise<void> => {
await storage.updateData<WorkoutPlan[]>(getKey(login, date), current => [...(current ?? []), plan]);
};
/**
* Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita.
*
* Smazání všech cviků šablony se bere jako vrácení jejího použití, ne jako
* neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve
* statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá.
*
* @param login přihlašovací jméno uživatele
* @param date datum dne
* @param remaining aktivity, které ve dni zůstaly
*/
export const dropOrphanedPlans = async (login: string, date: string, remaining: ActivityEntry[]): Promise<void> => {
const key = getKey(login, date);
if (!await storage.hasData(key)) return;
const ids = new Set(remaining.map(entry => entry.id));
await storage.updateData<WorkoutPlan[]>(key,
current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId))));
};
+29
View File
@@ -0,0 +1,29 @@
get:
operationId: getActivityStats
summary: Vrátí statistiky výdeje (pohybu) za zvolené období včetně plnění šablon.
parameters:
- in: query
name: from
required: true
schema:
type: string
format: date
description: První den období (včetně), YYYY-MM-DD
- in: query
name: to
required: true
schema:
type: string
format: date
description: Poslední den období (včetně), YYYY-MM-DD
responses:
"200":
description: Statistiky výdeje za období
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ActivityStats"
"400":
description: Neplatný rozsah
"401":
description: Neautentizovaný uživatel