feat: přidána midnight snack

feat: přidány statistiky pohybu
This commit is contained in:
Ondřej Anděl committed 2026-10-02 19:33:08 +02:00
1 parent 8dac67bdd1
commit f1ce3ff297
13 files changed
+833 -163

No files matched your search

+17 -1
View File
@@ -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:<login>:<datum>`) — **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.
+24
View File
@@ -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);
+4 -1
View File
@@ -7,6 +7,7 @@ export const MEAL_TYPE_NAMES: Record<MealType, string> = {
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<MealType, string> = {
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',
+111 -3
View File
@@ -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);
}
+138 -151
View File
@@ -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>(DayTab.PRIJEM);
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
const [year, setYear] = useState<number>(() => 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() {
<div className="page-header">
<div>
<h1>Statistiky</h1>
<p className="page-subtitle">Kolik jste utratili za jídlo a za co</p>
<p className="page-subtitle">
{tab === DayTab.PRIJEM
? 'Kolik jste utratili za jídlo a za co'
: 'Kolik jste spálili pohybem a jak se držíte šablon'}
</p>
</div>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
{tab === DayTab.PRIJEM && (
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
)}
</div>
<div className="range-picker card-panel">
<div className="range-modes">
{Object.values(DayTab).map(value => (
<button
key={value}
className={`range-mode ${tab === value ? 'active' : ''}`}
onClick={() => setTab(value)}
>
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {DAY_TAB_NAMES[value]}
</button>
))}
</div>
<div className="range-modes">
{Object.values(StatsRange).map(value => (
<button
@@ -118,121 +116,110 @@ export default function StatsPage() {
</button>
))}
</div>
{mode === StatsRange.MESIC && (
<input
type="month"
className="form-control range-input"
value={month}
onChange={event => event.target.value && setMonth(event.target.value)}
/>
)}
{mode === StatsRange.ROK && (
<input
type="number"
className="form-control range-input"
value={year}
min={EARLIEST_YEAR}
max={new Date().getFullYear()}
onChange={event => setYear(Number(event.target.value))}
/>
)}
<PeriodPicker mode={mode} month={month} year={year} onMonthChange={setMonth} onYearChange={setYear} />
</div>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
{tab === DayTab.PRIJEM && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, 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
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, 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
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</>
)}
+7 -2
View File
@@ -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<ActivityEntry[]>(getDayKey(login, originalDate), current => {
const remaining = await storage.updateData<ActivityEntry[]>(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<ActivityEntry[]>(getDayKey(login, date), current => {
const remaining = await storage.updateData<ActivityEntry[]>(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;
}
+1 -1
View File
@@ -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;
};
+4 -1
View File
@@ -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<DayRecord> {
export async function addMeal(login: string, input: unknown, templateId?: string): Promise<DayRecord> {
const normalized = normalizeInput(input);
const entry: MealEntry = {
id: randomUUID(),
createdAt: new Date().toISOString(),
...normalized,
...(templateId ? { templateId } : {}),
};
const entries = await storage.updateData<MealEntry[]>(
getDayKey(login, normalized.date),
+11 -1
View File
@@ -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;
+222 -2
View File
@@ -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<string, ActivityPeriodTotal>, 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<string, ActivityPeriodTotal>): 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<string, string>, date: string,
): { day: ActivityDayStats, counts: Map<string, UsageCounts> } {
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<string, TemplateDayUsage>();
const counts = new Map<string, UsageCounts>();
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<ActivityStats> {
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<string, ActivityPeriodTotal>();
const byMonth = new Map<string, ActivityPeriodTotal>();
const byYear = new Map<string, ActivityPeriodTotal>();
const byTemplate = new Map<string, TemplateUsageTotal>();
const extras = new Map<string, ActivityGroupTotal>();
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,
};
}
+14
View File
@@ -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;
};
+2
View File
@@ -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:
+278
View File
@@ -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