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
+833
-163
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
|
- **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),
|
- **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),
|
`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/`
|
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
||||||
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
|
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
|
||||||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
- **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á:
|
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.
|
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),
|
`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.
|
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
|
`createMealTemplateFromMeal` recept stejného názvu **nepřepíše** (přišel by
|
||||||
o suroviny a postup) a odmítne to chybou.
|
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`
|
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate`
|
||||||
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,19 @@
|
|||||||
--ft-danger: #dc2626;
|
--ft-danger: #dc2626;
|
||||||
--ft-info: #2563eb;
|
--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
|
// Shadows
|
||||||
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--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);
|
--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-danger: #f87171;
|
||||||
--ft-info: #60a5fa;
|
--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-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: 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);
|
--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',
|
OBED: 'Oběd',
|
||||||
ODPOLEDNI_SVACINA: 'Odpolední svačina',
|
ODPOLEDNI_SVACINA: 'Odpolední svačina',
|
||||||
VECERE: 'Večeře',
|
VECERE: 'Večeře',
|
||||||
|
PULNOCNI_SVACINA: 'Půlnoční svačina',
|
||||||
JINE: 'Jiné',
|
JINE: 'Jiné',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const MEAL_TYPE_ICONS: Record<MealType, string> = {
|
|||||||
OBED: '🍲',
|
OBED: '🍲',
|
||||||
ODPOLEDNI_SVACINA: '🥨',
|
ODPOLEDNI_SVACINA: '🥨',
|
||||||
VECERE: '🍽',
|
VECERE: '🍽',
|
||||||
|
PULNOCNI_SVACINA: '🌙',
|
||||||
JINE: '🍫',
|
JINE: '🍫',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ export const MEAL_TYPES_IN_ORDER: MealType[] = [
|
|||||||
MealType.OBED,
|
MealType.OBED,
|
||||||
MealType.ODPOLEDNI_SVACINA,
|
MealType.ODPOLEDNI_SVACINA,
|
||||||
MealType.VECERE,
|
MealType.VECERE,
|
||||||
|
MealType.PULNOCNI_SVACINA,
|
||||||
MealType.JINE,
|
MealType.JINE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
|
|||||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
{ 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 {
|
export enum DayTab {
|
||||||
PRIJEM = 'PRIJEM',
|
PRIJEM = 'PRIJEM',
|
||||||
VYDEJ = 'VYDEJ',
|
VYDEJ = 'VYDEJ',
|
||||||
|
|||||||
@@ -35,9 +35,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-input {
|
.period-picker {
|
||||||
width: auto;
|
display: inline-flex;
|
||||||
min-width: 140px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,3 +211,106 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 8px;
|
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
@@ -1,39 +1,36 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Spinner } from "react-bootstrap";
|
import { Button, Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 {
|
import {
|
||||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
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 {
|
import {
|
||||||
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
|
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
|
||||||
getHumanMonth, getMonthEnd, getMonthStart,
|
|
||||||
} from "../Utils";
|
} from "../Utils";
|
||||||
import ImportModal from "../components/modals/ImportModal";
|
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";
|
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. */
|
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
|
||||||
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3'];
|
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#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)',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
|
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
|
||||||
function formatChartPrice(value: unknown): string {
|
function formatChartPrice(value: unknown): string {
|
||||||
return `${Number(value).toLocaleString('cs-CZ')} Kč`;
|
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() {
|
export default function StatsPage() {
|
||||||
|
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
|
||||||
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
|
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
|
||||||
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
||||||
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
||||||
@@ -59,31 +56,15 @@ export default function StatsPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [range]);
|
}, [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. */
|
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
|
||||||
if (!stats) return [];
|
label: formatPeriodLabel(total.period, mode),
|
||||||
if (mode === StatsRange.MESIC) {
|
price: total.price / 100,
|
||||||
return stats.byDay.map(total => ({
|
calories: total.calories,
|
||||||
label: formatDateString(total.period).slice(0, 5),
|
})) : [], [stats, 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]);
|
|
||||||
|
|
||||||
const pieData = useMemo(() => (stats?.byMealType ?? [])
|
const pieData = useMemo(() => (stats?.byMealType ?? [])
|
||||||
.filter(total => total.price > 0)
|
.filter(total => total.price > 0)
|
||||||
@@ -99,14 +80,31 @@ export default function StatsPage() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Statistiky</h1>
|
<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>
|
</div>
|
||||||
<Button onClick={() => setImportOpen(true)}>
|
{tab === DayTab.PRIJEM && (
|
||||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
<Button onClick={() => setImportOpen(true)}>
|
||||||
</Button>
|
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="range-picker card-panel">
|
<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">
|
<div className="range-modes">
|
||||||
{Object.values(StatsRange).map(value => (
|
{Object.values(StatsRange).map(value => (
|
||||||
<button
|
<button
|
||||||
@@ -118,121 +116,110 @@ export default function StatsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{mode === StatsRange.MESIC && (
|
<PeriodPicker mode={mode} month={month} year={year} onMonthChange={setMonth} onYearChange={setYear} />
|
||||||
<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))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="summary-grid">
|
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
|
||||||
<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>
|
|
||||||
|
|
||||||
{loading && (
|
{tab === DayTab.PRIJEM && (
|
||||||
<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">
|
<div className="summary-grid">
|
||||||
<h2 className="chart-title">Útrata v čase</h2>
|
<div className="summary-tile accent">
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
|
||||||
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
|
||||||
<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>
|
||||||
|
<div className="summary-tile">
|
||||||
<div className="card-panel chart-panel">
|
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
|
||||||
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
|
<div className="summary-value">{formatPrice(averagePerDay)}</div>
|
||||||
<table className="source-table">
|
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<div className="summary-tile">
|
||||||
<th>Zdroj</th>
|
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
|
||||||
<th className="numeric">Jídel</th>
|
<div className="summary-value">{stats?.entryCount ?? 0}</div>
|
||||||
<th className="numeric">Útrata</th>
|
</div>
|
||||||
</tr>
|
<div className="summary-tile">
|
||||||
</thead>
|
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
|
||||||
<tbody>
|
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
|
||||||
{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>
|
||||||
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||||
|
import { dropOrphanedPlans } from "./workoutPlans";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -203,7 +204,7 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
|||||||
}
|
}
|
||||||
|
|
||||||
let original: ActivityEntry | undefined;
|
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);
|
original = (current ?? []).find(entry => entry.id === id);
|
||||||
return (current ?? []).filter(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),
|
getDayKey(login, normalized.date),
|
||||||
current => [...(current ?? []), updated]
|
current => [...(current ?? []), updated]
|
||||||
);
|
);
|
||||||
|
if (originalDate !== normalized.date) {
|
||||||
|
await dropOrphanedPlans(login, originalDate, remaining);
|
||||||
|
}
|
||||||
return normalized.date;
|
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");
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||||
}
|
}
|
||||||
let found = false;
|
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);
|
found = (current ?? []).some(entry => entry.id === id);
|
||||||
return (current ?? []).filter(entry => entry.id !== id);
|
return (current ?? []).filter(entry => entry.id !== id);
|
||||||
});
|
});
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new NotFoundError("Aktivita nebyla nalezena");
|
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||||
}
|
}
|
||||||
|
await dropOrphanedPlans(login, date, remaining);
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export const applyMealTemplate = async (
|
|||||||
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||||
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
||||||
multiplier: normalizeMultiplier(multiplier),
|
multiplier: normalizeMultiplier(multiplier),
|
||||||
});
|
}, template.id);
|
||||||
return date;
|
return date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -21,6 +21,7 @@ const MEAL_TYPE_ORDER: MealType[] = [
|
|||||||
MealType.OBED,
|
MealType.OBED,
|
||||||
MealType.ODPOLEDNI_SVACINA,
|
MealType.ODPOLEDNI_SVACINA,
|
||||||
MealType.VECERE,
|
MealType.VECERE,
|
||||||
|
MealType.PULNOCNI_SVACINA,
|
||||||
MealType.JINE,
|
MealType.JINE,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -180,13 +181,15 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
|||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param input data jídla
|
* @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 normalized = normalizeInput(input);
|
||||||
const entry: MealEntry = {
|
const entry: MealEntry = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
...normalized,
|
...normalized,
|
||||||
|
...(templateId ? { templateId } : {}),
|
||||||
};
|
};
|
||||||
const entries = await storage.updateData<MealEntry[]>(
|
const entries = await storage.updateData<MealEntry[]>(
|
||||||
getDayKey(login, normalized.date),
|
getDayKey(login, normalized.date),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { getFoodStats } from "../statsService";
|
import { getActivityStats, getFoodStats } from "../statsService";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken, requireIsoDate } from "../utils";
|
import { parseToken, requireIsoDate } from "../utils";
|
||||||
|
|
||||||
@@ -15,4 +15,14 @@ router.get("/summary", async (req: Request, res: Response, next: NextFunction) =
|
|||||||
} catch (e) { next(e); }
|
} 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;
|
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 { 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ý. */
|
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
|
||||||
const UNKNOWN_SOURCE = 'Neuvedeno';
|
const UNKNOWN_SOURCE = 'Neuvedeno';
|
||||||
@@ -81,3 +88,216 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
|||||||
bySource: toGroupTotals(bySource),
|
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 getStorage from "./storage";
|
||||||
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
import { addActivities, getActivityDay, getTotalQuantity, isActivityUnit } from "./activities";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||||
|
import { recordWorkoutPlan } from "./workoutPlans";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -280,6 +281,19 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await addActivities(login, date, entries);
|
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;
|
return date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ paths:
|
|||||||
# Statistiky (/api/stats)
|
# Statistiky (/api/stats)
|
||||||
/stats/summary:
|
/stats/summary:
|
||||||
$ref: "./paths/stats/summary.yml"
|
$ref: "./paths/stats/summary.yml"
|
||||||
|
/stats/activity:
|
||||||
|
$ref: "./paths/stats/activity.yml"
|
||||||
|
|
||||||
# Přehled dne (/api/day)
|
# Přehled dne (/api/day)
|
||||||
/day:
|
/day:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ MealType:
|
|||||||
- OBED
|
- OBED
|
||||||
- ODPOLEDNI_SVACINA
|
- ODPOLEDNI_SVACINA
|
||||||
- VECERE
|
- VECERE
|
||||||
|
- PULNOCNI_SVACINA
|
||||||
- JINE
|
- JINE
|
||||||
|
|
||||||
MealEntry:
|
MealEntry:
|
||||||
@@ -74,6 +75,9 @@ MealEntry:
|
|||||||
Klíč pro rozpoznání duplicit při opakovaném importu stejného období.
|
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.
|
Odvozuje se z data, typu, názvu jídla a částky importovaného řádku.
|
||||||
type: string
|
type: string
|
||||||
|
templateId:
|
||||||
|
description: Identifikátor šablony jídla, ze které záznam vznikl nebo ke které patří
|
||||||
|
type: string
|
||||||
createdAt:
|
createdAt:
|
||||||
description: Čas vzniku záznamu
|
description: Čas vzniku záznamu
|
||||||
type: string
|
type: string
|
||||||
@@ -977,3 +981,277 @@ RecipeIngredient:
|
|||||||
price:
|
price:
|
||||||
description: Cena použitého množství v haléřích
|
description: Cena použitého množství v haléřích
|
||||||
type: integer
|
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