diff --git a/CLAUDE.md b/CLAUDE.md index b53143f..3c5f681 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist - **Entry:** `index.tsx` → `AppRoutes.tsx`; `Login.tsx` je přihlašovací obrazovka - **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej), `TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej), - `StatsPage` (statistiky + import) + `StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`) - **Components:** `components/` (Header, CalorieLookup) a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal) - **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků, @@ -211,6 +211,18 @@ spánku a žádná ze standardních rovnic délku spánku jako proměnnou nemá nebo odečítat za něj kalorie by bylo vymýšlení čísla. Test to hlídá: bilance se po zadání spánku nesmí změnit. +**Statistiky výdeje** (`getActivityStats` ve `statsService.ts`, `GET /api/stats/activity`) +dělí pohyb na aktivity ze šablon a mimo ně a u šablon porovnává skutečnost s plánem. +Plán si `applyWorkoutTemplate` ukládá při použití (`workoutPlans.ts`, klíč +`workoutPlans::`) — **nesrovnává se se současnou podobou šablony**, +ta se mohla mezitím změnit, a smazaná aktivita by beze stopy zmizela. Plán nese +jednotlivé cviky s `entryId` aktivity, která z nich vznikla; podle něj +`analyzeDay` u každého dne pozná cvik **odebraný** (aktivita chybí) a **upravený** +(jiné množství, série nebo násobek) — podle názvu by to nešlo, šablona může mít +stejný cvik dvakrát. Smazání všech cviků šablony ve dni `dropOrphanedPlans` +bere jako vrácené použití a plán zahodí. Aktivity ze šablony bez plánu (cvik +přesunutý z jiného dne) se počítají jako splněné přesně dle plánu. + `GET /api/day?date=` vrací `DayOverview` se vším naráz (jídlo, pohyb, bilance), takže `DayPage` si vystačí s jedním voláním. @@ -239,6 +251,10 @@ načtení doplní na `HOTOVE`, v úložišti se nic nepřepisuje. `createMealTemplateFromMeal` recept stejného názvu **nepřepíše** (přišel by o suroviny a postup) a odmítne to chybou. +Jídlo založené ze šablony nese `templateId`. Nastavuje ho jen server +(`addMeal` s třetím parametrem) — `MealInput` ho nemá, takže ho klient +nepodstrčí a úprava jídla ho zachová. + Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate` ho vyžaduje a nešlo by jím posílat novou šablonu. diff --git a/client/src/App.scss b/client/src/App.scss index 4777664..0142ed5 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -33,6 +33,19 @@ --ft-danger: #dc2626; --ft-info: #2563eb; + // Kategoriální řady grafů (šablony tréninků) — pevné pořadí, ověřené na + // rozlišitelnost i pro barvoslepé. Mimo šablonu a přebytečné šablony jsou neutrální. + --ft-series-1: #2a78d6; + --ft-series-2: #eb6834; + --ft-series-3: #1baf7a; + --ft-series-4: #eda100; + --ft-series-5: #e87ba4; + --ft-series-6: #008300; + --ft-series-7: #4a3aa7; + --ft-series-8: #e34948; + --ft-series-other-template: #7c7b76; + --ft-series-other: #b8b7b1; + // Shadows --ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); @@ -73,6 +86,17 @@ --ft-danger: #f87171; --ft-info: #60a5fa; + --ft-series-1: #3987e5; + --ft-series-2: #d95926; + --ft-series-3: #199e70; + --ft-series-4: #c98500; + --ft-series-5: #d55181; + --ft-series-6: #008300; + --ft-series-7: #9085e9; + --ft-series-8: #e66767; + --ft-series-other-template: #8f8e88; + --ft-series-other: #55544f; + --ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5); --ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4); --ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4); diff --git a/client/src/enums.ts b/client/src/enums.ts index 470563a..5bb2c74 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -7,6 +7,7 @@ export const MEAL_TYPE_NAMES: Record = { OBED: 'Oběd', ODPOLEDNI_SVACINA: 'Odpolední svačina', VECERE: 'Večeře', + PULNOCNI_SVACINA: 'Půlnoční svačina', JINE: 'Jiné', }; @@ -17,6 +18,7 @@ export const MEAL_TYPE_ICONS: Record = { OBED: '🍲', ODPOLEDNI_SVACINA: '🥨', VECERE: '🍽', + PULNOCNI_SVACINA: '🌙', JINE: '🍫', }; @@ -27,6 +29,7 @@ export const MEAL_TYPES_IN_ORDER: MealType[] = [ MealType.OBED, MealType.ODPOLEDNI_SVACINA, MealType.VECERE, + MealType.PULNOCNI_SVACINA, MealType.JINE, ]; @@ -75,7 +78,7 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10 { name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 }, ]; -/** Záložky přehledu dne. */ +/** Záložky příjem/výdej — v přehledu dne i ve statistikách. */ export enum DayTab { PRIJEM = 'PRIJEM', VYDEJ = 'VYDEJ', diff --git a/client/src/pages/StatsPage.scss b/client/src/pages/StatsPage.scss index 29c5907..9396744 100644 --- a/client/src/pages/StatsPage.scss +++ b/client/src/pages/StatsPage.scss @@ -35,9 +35,14 @@ } } - .range-input { - width: auto; - min-width: 140px; + .period-picker { + display: inline-flex; + align-items: center; + gap: 6px; + + .form-select { + width: auto; + } } } @@ -206,3 +211,106 @@ flex-wrap: wrap; margin-bottom: 8px; } + +// Odchylka od šablon — vynechané je červeně, přidané navíc zeleně +.deviation-minus, +.summary-tile .summary-value.deviation-minus { + color: var(--ft-danger); +} + +.deviation-plus, +.summary-tile .summary-value.deviation-plus { + color: var(--ft-success); +} + +.usage-table { + .extras-toggle { + cursor: pointer; + + td { + font-weight: 600; + } + + &:hover td { + background: var(--ft-bg-hover); + } + } + + .extras-item td { + color: var(--ft-text-secondary); + + &:first-child { + padding-left: 26px; + } + } +} + +// Bublina sloupce výdeje — hodnoty v barvě textu, identitu nese jen čtvereček +.activity-tooltip { + padding: 8px 10px; + font-size: 0.82rem; + max-width: 280px; + box-shadow: var(--ft-shadow); + + .activity-tooltip-title { + font-weight: 700; + margin-bottom: 4px; + } + + .activity-tooltip-empty { + color: var(--ft-text-muted); + } + + .activity-tooltip-row { + display: flex; + align-items: center; + gap: 6px; + } + + .activity-tooltip-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex: 0 0 auto; + + &.plan { + height: 0; + border-top: 2px dashed var(--ft-text-secondary); + border-radius: 0; + } + } + + .activity-tooltip-value { + margin-left: auto; + padding-left: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .activity-tooltip-plan { + color: var(--ft-text-secondary); + } + + .plan-note { + display: flex; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--ft-border-light); + } + + .plan-note-date { + color: var(--ft-text-secondary); + } +} + +.plan-note-icon { + color: var(--ft-warning); + margin-top: 2px; +} + +.usage-note { + margin: 10px 0 0; + font-size: 0.8rem; + color: var(--ft-text-muted); +} diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index 43aa04a..b01e172 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -1,39 +1,36 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn } from "@fortawesome/free-solid-svg-icons"; +import { + faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning, +} from "@fortawesome/free-solid-svg-icons"; import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend, } from "recharts"; import { FoodStats, MealType, getFoodStats } from "../../../types"; -import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums"; +import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums"; import { - formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort, - getHumanMonth, getMonthEnd, getMonthStart, + formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart, } from "../Utils"; import ImportModal from "../components/modals/ImportModal"; +import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker"; +import ActivityStatsPanel from "./ActivityStatsPanel"; +import { TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared"; import "./StatsPage.scss"; -/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */ -const EARLIEST_YEAR = 2015; - /** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */ -const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3']; - -/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */ -const TOOLTIP_STYLE = { - background: 'var(--ft-bg-card)', - border: '1px solid var(--ft-border)', - borderRadius: 'var(--ft-radius-sm)', - color: 'var(--ft-text)', -}; +const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3']; /** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */ function formatChartPrice(value: unknown): string { return `${Number(value).toLocaleString('cs-CZ')} Kč`; } +/** Ikony záložek statistik — stejné dělení jako přehled dne. */ +const TAB_ICONS = { [DayTab.PRIJEM]: faUtensils, [DayTab.VYDEJ]: faPersonRunning }; + export default function StatsPage() { + const [tab, setTab] = useState(DayTab.PRIJEM); const [mode, setMode] = useState(StatsRange.MESIC); const [month, setMonth] = useState(() => formatMonth(new Date())); const [year, setYear] = useState(() => new Date().getFullYear()); @@ -59,31 +56,15 @@ export default function StatsPage() { setLoading(false); }, [range]); - useEffect(() => { load(); }, [load]); + // Výdej si data načítá sám ve svém panelu + useEffect(() => { if (tab === DayTab.PRIJEM) load(); }, [load, tab]); /** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */ - const series = useMemo(() => { - if (!stats) return []; - if (mode === StatsRange.MESIC) { - return stats.byDay.map(total => ({ - label: formatDateString(total.period).slice(0, 5), - price: total.price / 100, - calories: total.calories, - })); - } - if (mode === StatsRange.ROK) { - return stats.byMonth.map(total => ({ - label: getHumanMonth(total.period).split(' ')[0], - price: total.price / 100, - calories: total.calories, - })); - } - return stats.byYear.map(total => ({ - label: total.period, - price: total.price / 100, - calories: total.calories, - })); - }, [stats, mode]); + const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({ + label: formatPeriodLabel(total.period, mode), + price: total.price / 100, + calories: total.calories, + })) : [], [stats, mode]); const pieData = useMemo(() => (stats?.byMealType ?? []) .filter(total => total.price > 0) @@ -99,14 +80,31 @@ export default function StatsPage() {

Statistiky

-

Kolik jste utratili za jídlo a za co

+

+ {tab === DayTab.PRIJEM + ? 'Kolik jste utratili za jídlo a za co' + : 'Kolik jste spálili pohybem a jak se držíte šablon'} +

- + {tab === DayTab.PRIJEM && ( + + )}
+
+ {Object.values(DayTab).map(value => ( + + ))} +
{Object.values(StatsRange).map(value => (
- {mode === StatsRange.MESIC && ( - event.target.value && setMonth(event.target.value)} - /> - )} - {mode === StatsRange.ROK && ( - setYear(Number(event.target.value))} - /> - )} +
-
-
-
Celkem utraceno
-
{formatPrice(stats?.totalPrice ?? 0)}
-
-
-
Průměr na den
-
{formatPrice(averagePerDay)}
-
z {stats?.dayCount ?? 0} vykázaných dní
-
-
-
Jídel
-
{stats?.entryCount ?? 0}
-
-
-
Kalorie
-
{formatCalories(stats?.totalCalories)}
-
-
+ {tab === DayTab.VYDEJ && } - {loading && ( -
- -
- )} - - {!loading && !stats?.entryCount && ( -
-
-
Za zvolené období nemáte žádná data
-

- Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru. -

-
- )} - - {!loading && !!stats?.entryCount && ( + {tab === DayTab.PRIJEM && ( <> -
-

Útrata v čase

- - - - - - [formatChartPrice(value), 'Útrata']} - contentStyle={TOOLTIP_STYLE} - /> - - - -
- -
-
-

Podle chodu

- - - - {pieData.map((entry, index) => ( - - ))} - - - formatChartPrice(value)} - contentStyle={TOOLTIP_STYLE} - /> - - +
+
+
Celkem utraceno
+
{formatPrice(stats?.totalPrice ?? 0)}
- -
-

Kde nejvíc utrácíte

- - - - - - - - - - {stats.bySource.slice(0, 8).map(total => ( - - - - - - ))} - -
ZdrojJídelÚtrata
{total.key}{total.count}{formatPriceShort(total.price)}
+
+
Průměr na den
+
{formatPrice(averagePerDay)}
+
z {stats?.dayCount ?? 0} vykázaných dní
+
+
+
Jídel
+
{stats?.entryCount ?? 0}
+
+
+
Kalorie
+
{formatCalories(stats?.totalCalories)}
+ + {loading && ( +
+ +
+ )} + + {!loading && !stats?.entryCount && ( +
+
+
Za zvolené období nemáte žádná data
+

+ Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru. +

+
+ )} + + {!loading && !!stats?.entryCount && ( + <> +
+

Útrata v čase

+ + + + + + [formatChartPrice(value), 'Útrata']} + contentStyle={TOOLTIP_STYLE} + /> + + + +
+ +
+
+

Podle chodu

+ + + + {pieData.map((entry, index) => ( + + ))} + + + formatChartPrice(value)} + contentStyle={TOOLTIP_STYLE} + /> + + +
+ +
+

Kde nejvíc utrácíte

+ + + + + + + + + + {stats.bySource.slice(0, 8).map(total => ( + + + + + + ))} + +
ZdrojJídelÚtrata
{total.key}{total.count}{formatPriceShort(total.price)}
+
+
+ + )} )} diff --git a/server/src/activities.ts b/server/src/activities.ts index 76a1553..03a8914 100644 --- a/server/src/activities.ts +++ b/server/src/activities.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen"; import getStorage from "./storage"; import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils"; +import { dropOrphanedPlans } from "./workoutPlans"; const storage = getStorage(); @@ -203,7 +204,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown) } let original: ActivityEntry | undefined; - await storage.updateData(getDayKey(login, originalDate), current => { + const remaining = await storage.updateData(getDayKey(login, originalDate), current => { original = (current ?? []).find(entry => entry.id === id); return (current ?? []).filter(entry => entry.id !== id); }); @@ -227,6 +228,9 @@ export async function updateActivity(login: string, id: unknown, input: unknown) getDayKey(login, normalized.date), current => [...(current ?? []), updated] ); + if (originalDate !== normalized.date) { + await dropOrphanedPlans(login, originalDate, remaining); + } return normalized.date; } @@ -245,13 +249,14 @@ export async function deleteActivity(login: string, id: unknown, date: unknown): throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD"); } let found = false; - await storage.updateData(getDayKey(login, date), current => { + const remaining = await storage.updateData(getDayKey(login, date), current => { found = (current ?? []).some(entry => entry.id === id); return (current ?? []).filter(entry => entry.id !== id); }); if (!found) { throw new NotFoundError("Aktivita nebyla nalezena"); } + await dropOrphanedPlans(login, date, remaining); return date; } diff --git a/server/src/mealTemplates.ts b/server/src/mealTemplates.ts index ede354a..0d8ae19 100644 --- a/server/src/mealTemplates.ts +++ b/server/src/mealTemplates.ts @@ -307,7 +307,7 @@ export const applyMealTemplate = async ( // Postup receptu do záznamu dne nepatří, zůstává u šablony note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note, multiplier: normalizeMultiplier(multiplier), - }); + }, template.id); return date; }; diff --git a/server/src/meals.ts b/server/src/meals.ts index 1e797c9..efdbb9a 100644 --- a/server/src/meals.ts +++ b/server/src/meals.ts @@ -21,6 +21,7 @@ const MEAL_TYPE_ORDER: MealType[] = [ MealType.OBED, MealType.ODPOLEDNI_SVACINA, MealType.VECERE, + MealType.PULNOCNI_SVACINA, MealType.JINE, ]; @@ -180,13 +181,15 @@ export function normalizeInput(input: unknown): NormalizedMealInput { * * @param login přihlašovací jméno uživatele * @param input data jídla + * @param templateId šablona, ze které jídlo vzniká — klient ji poslat nemůže, nastavuje ji jen server */ -export async function addMeal(login: string, input: unknown): Promise { +export async function addMeal(login: string, input: unknown, templateId?: string): Promise { const normalized = normalizeInput(input); const entry: MealEntry = { id: randomUUID(), createdAt: new Date().toISOString(), ...normalized, + ...(templateId ? { templateId } : {}), }; const entries = await storage.updateData( getDayKey(login, normalized.date), diff --git a/server/src/routes/statsRoutes.ts b/server/src/routes/statsRoutes.ts index 252a053..89736ad 100644 --- a/server/src/routes/statsRoutes.ts +++ b/server/src/routes/statsRoutes.ts @@ -1,5 +1,5 @@ import express, { Request, Response, NextFunction } from "express"; -import { getFoodStats } from "../statsService"; +import { getActivityStats, getFoodStats } from "../statsService"; import { getLogin } from "../auth"; import { parseToken, requireIsoDate } from "../utils"; @@ -15,4 +15,14 @@ router.get("/summary", async (req: Request, res: Response, next: NextFunction) = } catch (e) { next(e); } }); +/** Vrátí statistiky výdeje pohybem přihlášeného uživatele za zvolené období. */ +router.get("/activity", async (req: Request, res: Response, next: NextFunction) => { + try { + const login = getLogin(parseToken(req)); + const from = requireIsoDate(req.query.from, 'from'); + const to = requireIsoDate(req.query.to, 'to'); + res.status(200).json(await getActivityStats(login, from, to)); + } catch (e) { next(e); } +}); + export default router; diff --git a/server/src/statsService.ts b/server/src/statsService.ts index 7bb395d..b637b6d 100644 --- a/server/src/statsService.ts +++ b/server/src/statsService.ts @@ -1,6 +1,13 @@ -import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen"; +import { + ActivityDayStats, ActivityEntry, ActivityGroupTotal, ActivityPeriodTotal, ActivityStats, ActivitySummaryItem, + ActivityUnit, FoodStats, GroupTotal, MealEntry, PeriodTotal, PlanDeviationKind, TemplateDayUsage, + TemplateUsageTotal, +} from "../../types/gen/types.gen"; import { getRange } from "./meals"; -import { applyMultiplier } from "./utils"; +import { getActivityDay } from "./activities"; +import { getWorkoutTemplates } from "./workouts"; +import { WorkoutPlan, WorkoutPlanItem, getWorkoutPlans } from "./workoutPlans"; +import { BadRequestError, applyMultiplier, listDatesInRange } from "./utils"; /** Zdroj přiřazený záznamům, které ho nemají vyplněný. */ const UNKNOWN_SOURCE = 'Neuvedeno'; @@ -81,3 +88,216 @@ export async function getFoodStats(login: string, from: string, to: string): Pro bySource: toGroupTotals(bySource), }; } + +/** Název šablony, která už neexistuje a nemá ani uložený plán s názvem. */ +const DELETED_TEMPLATE = 'Smazaná šablona'; + +/** Přičte výdej dne do období pod daným klíčem. */ +function accumulateActivity( + buckets: Map, period: string, template: number, planned: number, extra: number, +): void { + const bucket = buckets.get(period) ?? { period, templateCalories: 0, plannedCalories: 0, extraCalories: 0 }; + bucket.templateCalories += template; + bucket.plannedCalories += planned; + bucket.extraCalories += extra; + buckets.set(period, bucket); +} + +/** Seřadí součty období vzestupně dle označení období. */ +const sortPeriods = (buckets: Map): ActivityPeriodTotal[] => + Array.from(buckets.values()).sort((a, b) => a.period.localeCompare(b.period)); + +/** Stručný popis aktivity nebo cviku plánu; jedna série a násobek 1 se vynechávají. */ +const summarize = ( + item: { name: string, unit: ActivityUnit, quantity: number, sets?: number, multiplier?: number }, + calories: number, +): ActivitySummaryItem => ({ + name: item.name, + unit: item.unit, + quantity: item.quantity, + ...(item.sets != null && item.sets > 1 ? { sets: item.sets } : {}), + ...(item.multiplier != null && item.multiplier !== 1 ? { multiplier: item.multiplier } : {}), + calories, +}); + +/** Vrátí true, pokud se aktivita provedla jinak, než plán cviku sliboval. */ +const differsFromPlan = (entry: ActivityEntry, item: WorkoutPlanItem, calories: number): boolean => + entry.quantity !== item.quantity + || (entry.sets ?? 1) !== (item.sets ?? 1) + || (entry.multiplier ?? 1) !== 1 + || calories !== item.calories; + +/** Průběžné počty cviků jedné šablony v jednom dni — do kontraktu nejdou, slouží součtům. */ +type UsageCounts = { planned: number, removed: number }; + +/** + * Rozebere jeden den: ke každé použité šabloně dohledá odchylky od plánu + * a aktivity mimo šablony vrátí zvlášť. + * + * Aktivity ze šablony bez uloženého plánu (cvik přesunutý z jiného dne) se + * berou jako splněné přesně dle plánu, protože plán z nich zjistit nejde. + */ +function analyzeDay( + entries: ActivityEntry[], plans: WorkoutPlan[], templateNames: Map, date: string, +): { day: ActivityDayStats, counts: Map } { + const byId = new Map(entries.map(entry => [entry.id, entry])); + const planned = new Set(plans.flatMap(plan => plan.items.map(item => item.entryId))); + const usages = new Map(); + const counts = new Map(); + + const usageFor = (templateId: string, name: string, hasPlan: boolean): TemplateDayUsage => { + const usage = usages.get(templateId) ?? { + templateId, name, plannedCalories: 0, actualCalories: 0, hasPlan, deviations: [], + }; + usages.set(templateId, usage); + if (!counts.has(templateId)) counts.set(templateId, { planned: 0, removed: 0 }); + return usage; + }; + + for (const plan of plans) { + // Stejná šablona použitá v jednom dni dvakrát má dva plány, sčítají se + const usage = usageFor(plan.templateId, plan.templateName, true); + const count = counts.get(plan.templateId)!; + for (const item of plan.items) { + usage.plannedCalories += item.calories; + count.planned++; + const entry = item.entryId ? byId.get(item.entryId) : undefined; + if (!entry) { + usage.deviations.push({ kind: PlanDeviationKind.ODEBRANO, planned: summarize(item, item.calories) }); + count.removed++; + continue; + } + const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; + usage.actualCalories += calories; + if (differsFromPlan(entry, item, calories)) { + usage.deviations.push({ + kind: PlanDeviationKind.UPRAVENO, + planned: summarize(item, item.calories), + actual: summarize(entry, calories), + }); + } + } + } + + const extras: ActivitySummaryItem[] = []; + for (const entry of entries) { + if (planned.has(entry.id)) continue; + const calories = applyMultiplier(entry.calories, entry.multiplier) ?? 0; + if (!entry.templateId) { + extras.push(summarize(entry, calories)); + continue; + } + const usage = usageFor(entry.templateId, templateNames.get(entry.templateId) ?? DELETED_TEMPLATE, false); + usage.plannedCalories += calories; + usage.actualCalories += calories; + counts.get(entry.templateId)!.planned++; + } + + const templates = Array.from(usages.values()); + const extraCalories = extras.reduce((sum, item) => sum + item.calories, 0); + return { + day: { + date, + totalCalories: templates.reduce((sum, usage) => sum + usage.actualCalories, 0) + extraCalories, + templates, + extraCalories, + extras, + }, + counts, + }; +} + +/** + * Sestaví statistiky výdeje pohybem za zvolené období. + * + * Aktivity ze šablon se porovnávají s plánem, který si šablona uložila při + * použití (`workoutPlans.ts`) — ne s její současnou podobou, ta se mohla + * mezitím změnit. + * + * @param login přihlašovací jméno uživatele + * @param from první den období (včetně), YYYY-MM-DD + * @param to poslední den období (včetně), YYYY-MM-DD + */ +export async function getActivityStats(login: string, from: string, to: string): Promise { + if (from > to) { + throw new BadRequestError("Začátek rozsahu je až za jeho koncem"); + } + const dates = listDatesInRange(from, to); + const [activityDays, plansByDay, templates] = await Promise.all([ + Promise.all(dates.map(date => getActivityDay(login, date))), + Promise.all(dates.map(date => getWorkoutPlans(login, date))), + getWorkoutTemplates(login), + ]); + const templateNames = new Map(templates.map(template => [template.id, template.name])); + + const byDay = new Map(); + const byMonth = new Map(); + const byYear = new Map(); + const byTemplate = new Map(); + const extras = new Map(); + const days: ActivityDayStats[] = []; + + let templateCalories = 0; + let plannedCalories = 0; + let extraCalories = 0; + let entryCount = 0; + + activityDays.forEach((activityDay, index) => { + if (!activityDay.entries.length) return; + entryCount += activityDay.entries.length; + const { day, counts } = analyzeDay(activityDay.entries, plansByDay[index], templateNames, activityDay.date); + days.push(day); + + let dayTemplate = 0; + let dayPlanned = 0; + for (const usage of day.templates) { + const count = counts.get(usage.templateId)!; + const total = byTemplate.get(usage.templateId) ?? { + templateId: usage.templateId, name: usage.name, dayCount: 0, + plannedCalories: 0, actualCalories: 0, plannedItemCount: 0, doneItemCount: 0, + }; + // Dny jdou vzestupně, takže zůstane název z posledního použití + total.name = usage.name; + total.dayCount++; + total.plannedCalories += usage.plannedCalories; + total.actualCalories += usage.actualCalories; + total.plannedItemCount += count.planned; + total.doneItemCount += count.planned - count.removed; + byTemplate.set(usage.templateId, total); + dayTemplate += usage.actualCalories; + dayPlanned += usage.plannedCalories; + } + for (const item of day.extras) { + const group = extras.get(item.name) ?? { key: item.name, calories: 0, count: 0 }; + group.calories += item.calories; + group.count++; + extras.set(item.name, group); + } + + accumulateActivity(byDay, day.date, dayTemplate, dayPlanned, day.extraCalories); + accumulateActivity(byMonth, day.date.substring(0, 7), dayTemplate, dayPlanned, day.extraCalories); + accumulateActivity(byYear, day.date.substring(0, 4), dayTemplate, dayPlanned, day.extraCalories); + templateCalories += dayTemplate; + plannedCalories += dayPlanned; + extraCalories += day.extraCalories; + }); + + return { + from, + to, + totalCalories: templateCalories + extraCalories, + templateCalories, + plannedCalories, + extraCalories, + entryCount, + dayCount: days.length, + byDay: sortPeriods(byDay), + byMonth: sortPeriods(byMonth), + byYear: sortPeriods(byYear), + byTemplate: Array.from(byTemplate.values()) + .sort((a, b) => b.plannedCalories - a.plannedCalories || a.name.localeCompare(b.name, 'cs')), + extras: Array.from(extras.values()) + .sort((a, b) => b.calories - a.calories || a.key.localeCompare(b.key, 'cs')), + days, + }; +} diff --git a/server/src/workouts.ts b/server/src/workouts.ts index 20fbb39..5fe3563 100644 --- a/server/src/workouts.ts +++ b/server/src/workouts.ts @@ -5,6 +5,7 @@ import { import getStorage from "./storage"; import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities"; import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils"; +import { recordWorkoutPlan } from "./workoutPlans"; const storage = getStorage(); @@ -280,6 +281,19 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk })); await addActivities(login, date, entries); + await recordWorkoutPlan(login, date, { + templateId: template.id, + templateName: template.name, + appliedAt: now, + items: entries.map(entry => ({ + entryId: entry.id, + name: entry.name, + unit: entry.unit, + quantity: entry.quantity, + ...(entry.sets != null ? { sets: entry.sets } : {}), + calories: entry.calories ?? 0, + })), + }); return date; }; diff --git a/types/api.yml b/types/api.yml index 3414a35..99b7354 100644 --- a/types/api.yml +++ b/types/api.yml @@ -28,6 +28,8 @@ paths: # Statistiky (/api/stats) /stats/summary: $ref: "./paths/stats/summary.yml" + /stats/activity: + $ref: "./paths/stats/activity.yml" # Přehled dne (/api/day) /day: diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index ee4f5e5..560df20 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -9,6 +9,7 @@ MealType: - OBED - ODPOLEDNI_SVACINA - VECERE + - PULNOCNI_SVACINA - JINE MealEntry: @@ -74,6 +75,9 @@ MealEntry: Klíč pro rozpoznání duplicit při opakovaném importu stejného období. Odvozuje se z data, typu, názvu jídla a částky importovaného řádku. type: string + templateId: + description: Identifikátor šablony jídla, ze které záznam vznikl nebo ke které patří + type: string createdAt: description: Čas vzniku záznamu type: string @@ -977,3 +981,277 @@ RecipeIngredient: price: description: Cena použitého množství v haléřích type: integer + +ActivityPeriodTotal: + description: Výdej pohybem za jedno období (den, měsíc nebo rok) + type: object + additionalProperties: false + required: + - period + - templateCalories + - plannedCalories + - extraCalories + properties: + period: + description: Označení období — YYYY-MM-DD (den), YYYY-MM (měsíc) nebo YYYY (rok) + type: string + templateCalories: + description: Skutečně spálené kcal aktivitami založenými ze šablon + type: integer + plannedCalories: + description: Kolik kcal šablony slibovaly v okamžiku, kdy se použily + type: integer + extraCalories: + description: Spálené kcal aktivitami mimo šablony + type: integer + +TemplateUsageTotal: + description: Plnění jedné šablony tréninku za období + type: object + additionalProperties: false + required: + - templateId + - name + - dayCount + - plannedCalories + - actualCalories + - plannedItemCount + - doneItemCount + properties: + templateId: + description: Identifikátor šablony + type: string + name: + description: Název šablony v době použití + type: string + dayCount: + description: Počet dní, ve kterých se šablona použila + type: integer + plannedCalories: + description: Součet kcal, které šablona slibovala + type: integer + actualCalories: + description: Součet kcal, které aktivity ze šablony skutečně spálily + type: integer + plannedItemCount: + description: Kolik cviků šablona založila + type: integer + doneItemCount: + description: Kolik z nich ve dni zůstalo (smazané se berou jako neprovedené) + type: integer + +ActivityGroupTotal: + description: Součet výdeje jedné skupiny aktivit + type: object + additionalProperties: false + required: + - key + - calories + - count + properties: + key: + description: Klíč skupiny — název aktivity + type: string + calories: + description: Spálené kcal + type: integer + count: + description: Počet záznamů + type: integer + +ActivityStats: + description: | + Statistiky výdeje pohybem za zvolené období. Aktivity se dělí na ty ze šablon + a ty mimo šablony; u šablon se porovnává skutečnost s plánem. + type: object + additionalProperties: false + required: + - from + - to + - totalCalories + - templateCalories + - plannedCalories + - extraCalories + - entryCount + - dayCount + - byDay + - byMonth + - byYear + - byTemplate + - extras + - days + properties: + from: + description: Začátek období (YYYY-MM-DD, včetně) + type: string + format: date + to: + description: Konec období (YYYY-MM-DD, včetně) + type: string + format: date + totalCalories: + description: Celkem spálené kcal pohybem + type: integer + templateCalories: + description: Z toho aktivity ze šablon + type: integer + plannedCalories: + description: | + Kolik kcal šablony slibovaly. Rozdíl proti templateCalories je odchylka + od šablon — záporný znamená, že se něco vynechalo nebo zkrátilo. + type: integer + extraCalories: + description: Z toho aktivity mimo šablony + type: integer + entryCount: + description: Počet aktivit v období + type: integer + dayCount: + description: Počet dní, ve kterých je alespoň jedna aktivita + type: integer + byDay: + description: Součty po dnech, vzestupně + type: array + items: + $ref: "#/ActivityPeriodTotal" + byMonth: + description: Součty po měsících, vzestupně + type: array + items: + $ref: "#/ActivityPeriodTotal" + byYear: + description: Součty po letech, vzestupně + type: array + items: + $ref: "#/ActivityPeriodTotal" + byTemplate: + description: Plnění jednotlivých šablon, sestupně dle plánu + type: array + items: + $ref: "#/TemplateUsageTotal" + extras: + description: Aktivity mimo šablony seskupené dle názvu, sestupně dle kcal + type: array + items: + $ref: "#/ActivityGroupTotal" + days: + description: Detail každého dne s pohybem, vzestupně dle data + type: array + items: + $ref: "#/ActivityDayStats" + +ActivityDayStats: + description: Výdej jednoho dne — použité šablony s odchylkami a aktivity mimo ně + type: object + additionalProperties: false + required: + - date + - totalCalories + - templates + - extraCalories + - extras + properties: + date: + type: string + format: date + totalCalories: + description: Celkem spálené kcal pohybem v tento den + type: integer + templates: + description: Šablony použité v tento den + type: array + items: + $ref: "#/TemplateDayUsage" + extraCalories: + description: Součet kcal aktivit mimo šablony + type: integer + extras: + description: Jednotlivé aktivity mimo šablony + type: array + items: + $ref: "#/ActivitySummaryItem" + +TemplateDayUsage: + description: Použití jedné šablony v konkrétní den + type: object + additionalProperties: false + required: + - templateId + - name + - plannedCalories + - actualCalories + - hasPlan + - deviations + properties: + templateId: + type: string + name: + description: Název šablony v době použití + type: string + plannedCalories: + type: integer + actualCalories: + type: integer + hasPlan: + description: | + False u aktivit ze šablony, ke kterým se plán neuložil (cvik přesunutý + z jiného dne). Odchylku u nich zjistit nejde. + type: boolean + deviations: + description: Cviky šablony, které se neprovedly nebo provedly jinak + type: array + items: + $ref: "#/PlanDeviation" + +PlanDeviationKind: + description: | + ODEBRANO — cvik ze šablony ve dni chybí. + UPRAVENO — cvik je, ale s jiným množstvím, počtem sérií nebo násobkem. + type: string + enum: + - ODEBRANO + - UPRAVENO + +PlanDeviation: + description: Odchylka jednoho cviku od plánu šablony + type: object + additionalProperties: false + required: + - kind + - planned + properties: + kind: + $ref: "#/PlanDeviationKind" + planned: + $ref: "#/ActivitySummaryItem" + actual: + description: Skutečné provedení; u odebraného cviku chybí + allOf: + - $ref: "#/ActivitySummaryItem" + +ActivitySummaryItem: + description: Stručný popis jedné aktivity pro statistiky + type: object + additionalProperties: false + required: + - name + - unit + - quantity + - calories + properties: + name: + type: string + unit: + $ref: "#/ActivityUnit" + quantity: + description: Množství na jednu sérii + type: integer + sets: + description: Počet sérií, chybí-li, jedna + type: integer + multiplier: + description: Násobek, chybí-li, 1 + type: number + calories: + description: Spálené kcal včetně násobku + type: integer