diff --git a/client/src/components/PeriodPicker.tsx b/client/src/components/PeriodPicker.tsx new file mode 100644 index 0000000..b1eac45 --- /dev/null +++ b/client/src/components/PeriodPicker.tsx @@ -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) { + 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 ( +
+ + onMonthChange(toMonth(monthYear, Number(event.target.value)))} + aria-label="Měsíc" + > + {MONTH_NAMES.map((name, index) => )} + + onMonthChange(toMonth(Number(event.target.value), monthIndex))} + aria-label="Rok" + > + {years.map(value => )} + + +
+ ); + } + + if (mode === StatsRange.ROK) { + return ( +
+ + onYearChange(Number(event.target.value))} + aria-label="Rok" + > + {years.map(value => )} + + +
+ ); + } + + return null; +} diff --git a/client/src/pages/ActivityStatsPanel.tsx b/client/src/pages/ActivityStatsPanel.tsx new file mode 100644 index 0000000..f16f9ab --- /dev/null +++ b/client/src/pages/ActivityStatsPanel.tsx @@ -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 ( +
+ +
+ {note.date && {note.date} } + {note.templateName} + {!!note.missing.length &&
nesplněno: {note.missing.join(', ')}
} + {!!note.changed.length &&
upraveno: {note.changed.join(', ')}
} +
+
+ ); +} + +/** Bublina sloupce — díly po šablonách, plán a hlavně co ze šablon nebylo splněno. */ +function ActivityTooltip({ active, payload, label }: Readonly>>) { + 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 ( +
+
{label}
+ {!parts.length &&
bez pohybu
} + {parts.map(item => ( +
+ + {item.name} + {formatCalories(Number(item.value))} +
+ ))} + {row.planned > 0 && ( +
+ + Plán šablon + {formatCalories(row.planned)} +
+ )} + {row.notes.map((note, index) => )} +
+ ); +} + +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) { + // Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama + if (cx == null || cy == null || !payload?.planned) return ; + if (!payload.notes.length) { + return ; + } + return ( + + + ! + + ); +} + +/** + * 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) { + const [stats, setStats] = useState(); + const [templateIds, setTemplateIds] = useState([]); + 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 ( +
+ +
+ ); + } + + if (!stats?.entryCount) { + return ( +
+
+
Za zvolené období nemáte žádný pohyb
+

+ Aktivity přidáte v přehledu dne na záložce Výdej, ručně nebo ze šablony tréninku. +

+
+ ); + } + + 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 ( + <> +
+
+
Pohybem spáleno
+
{formatCalories(stats.totalCalories)}
+
za {stats.dayCount} dní s pohybem
+
+
+
Ze šablon
+
{formatCalories(stats.templateCalories)}
+
+ {fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'} +
+
+
+
Odchylka od šablon
+
+ {deviation ? formatBalance(deviation) : '—'} +
+
+ {skippedItems ? `vynecháno cviků: ${skippedItems}` : 'žádný cvik nevynechán'} +
+
+
+
Mimo šablonu
+
{formatCalories(stats.extraCalories)}
+
z {extraCount} aktivit
+
+
+ +
+

Výdej v čase

+ + + + + + } cursor={{ fill: 'var(--ft-bg-hover)' }} /> + + {/* Každá šablona vlastní díl sloupce; tenký okraj v barvě karty díly odděluje */} + {templateSeries.map(item => ( + + ))} + + {/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */} + } + activeDot={false} + type="linear" + /> + + +

+ u bodu plánu + znamená, že se ze šablony něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec. +

+
+ +
+

Plnění šablon

+ + + + + + + + + + + + + {stats.byTemplate.map(usage => { + const usageDeviation = usage.actualCalories - usage.plannedCalories; + return ( + + + + + + + + + ); + })} + {!!stats.extras.length && ( + setExtrasOpen(open => !open)}> + + + + + )} + {extrasOpen && stats.extras.map(group => ( + + + + + + ))} + +
ŠablonaPoužitoCvikyPlánSkutečnostOdchylka
{usage.name}{usage.dayCount}×{usage.doneItemCount}/{usage.plannedItemCount}{formatCalories(usage.plannedCalories)}{formatCalories(usage.actualCalories)} + {usageDeviation ? formatBalance(usageDeviation) : '—'} +
+ + {' '}Mimo šablonu + {extraCount}× + + {formatCalories(stats.extraCalories)} +
{group.key}{group.count}× + + {formatCalories(group.calories)} +
+ {!stats.byTemplate.length && ( +

V tomto období jste žádnou šablonu tréninku nepoužili.

+ )} +

+ 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í. +

+
+ + ); +} diff --git a/client/src/pages/activityChartSeries.ts b/client/src/pages/activityChartSeries.ts new file mode 100644 index 0000000..c9e716b --- /dev/null +++ b/client/src/pages/activityChartSeries.ts @@ -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:`. */ +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): { + 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(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()]; +} diff --git a/client/src/pages/statsShared.ts b/client/src/pages/statsShared.ts new file mode 100644 index 0000000..dd91c15 --- /dev/null +++ b/client/src/pages/statsShared.ts @@ -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(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; +} diff --git a/server/src/tests/activityStats.test.ts b/server/src/tests/activityStats.test.ts new file mode 100644 index 0000000..62be49d --- /dev/null +++ b/server/src/tests/activityStats.test.ts @@ -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 { + 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(); + }); +}); diff --git a/server/src/workoutPlans.ts b/server/src/workoutPlans.ts new file mode 100644 index 0000000..e62811e --- /dev/null +++ b/server/src/workoutPlans.ts @@ -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 => + await storage.getData(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 => { + await storage.updateData(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 => { + const key = getKey(login, date); + if (!await storage.hasData(key)) return; + const ids = new Set(remaining.map(entry => entry.id)); + await storage.updateData(key, + current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId)))); +}; diff --git a/types/paths/stats/activity.yml b/types/paths/stats/activity.yml new file mode 100644 index 0000000..cda8793 --- /dev/null +++ b/types/paths/stats/activity.yml @@ -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