feat: přidána midnight snack
feat: přidány statistiky pohybu
This commit is contained in:
1 parent
8dac67bdd1
commit
f1ce3ff297
13 files changed
+737
-67
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -35,9 +35,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.range-input {
|
||||
.period-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.form-select {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
|
||||
label: formatPeriodLabel(total.period, mode),
|
||||
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]);
|
||||
})) : [], [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>
|
||||
{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,26 +116,13 @@ 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>
|
||||
|
||||
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
|
||||
|
||||
{tab === DayTab.PRIJEM && (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
|
||||
@@ -235,6 +220,8 @@ export default function StatsPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImportModal
|
||||
isOpen={importOpen}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user