feat: přidána midnight snack

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

No files matched your search

+24
View File
@@ -33,6 +33,19 @@
--ft-danger: #dc2626;
--ft-info: #2563eb;
// Kategoriální řady grafů (šablony tréninků) — pevné pořadí, ověřené na
// rozlišitelnost i pro barvoslepé. Mimo šablonu a přebytečné šablony jsou neutrální.
--ft-series-1: #2a78d6;
--ft-series-2: #eb6834;
--ft-series-3: #1baf7a;
--ft-series-4: #eda100;
--ft-series-5: #e87ba4;
--ft-series-6: #008300;
--ft-series-7: #4a3aa7;
--ft-series-8: #e34948;
--ft-series-other-template: #7c7b76;
--ft-series-other: #b8b7b1;
// Shadows
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
@@ -73,6 +86,17 @@
--ft-danger: #f87171;
--ft-info: #60a5fa;
--ft-series-1: #3987e5;
--ft-series-2: #d95926;
--ft-series-3: #199e70;
--ft-series-4: #c98500;
--ft-series-5: #d55181;
--ft-series-6: #008300;
--ft-series-7: #9085e9;
--ft-series-8: #e66767;
--ft-series-other-template: #8f8e88;
--ft-series-other: #55544f;
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
+4 -1
View File
@@ -7,6 +7,7 @@ export const MEAL_TYPE_NAMES: Record<MealType, string> = {
OBED: 'Oběd',
ODPOLEDNI_SVACINA: 'Odpolední svačina',
VECERE: 'Večeře',
PULNOCNI_SVACINA: 'Půlnoční svačina',
JINE: 'Jiné',
};
@@ -17,6 +18,7 @@ export const MEAL_TYPE_ICONS: Record<MealType, string> = {
OBED: '🍲',
ODPOLEDNI_SVACINA: '🥨',
VECERE: '🍽',
PULNOCNI_SVACINA: '🌙',
JINE: '🍫',
};
@@ -27,6 +29,7 @@ export const MEAL_TYPES_IN_ORDER: MealType[] = [
MealType.OBED,
MealType.ODPOLEDNI_SVACINA,
MealType.VECERE,
MealType.PULNOCNI_SVACINA,
MealType.JINE,
];
@@ -75,7 +78,7 @@ export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer10
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
];
/** Záložky přehledu dne. */
/** Záložky příjem/výdej — v přehledu dne i ve statistikách. */
export enum DayTab {
PRIJEM = 'PRIJEM',
VYDEJ = 'VYDEJ',
+111 -3
View File
@@ -35,9 +35,14 @@
}
}
.range-input {
width: auto;
min-width: 140px;
.period-picker {
display: inline-flex;
align-items: center;
gap: 6px;
.form-select {
width: auto;
}
}
}
@@ -206,3 +211,106 @@
flex-wrap: wrap;
margin-bottom: 8px;
}
// Odchylka od šablon — vynechané je červeně, přidané navíc zeleně
.deviation-minus,
.summary-tile .summary-value.deviation-minus {
color: var(--ft-danger);
}
.deviation-plus,
.summary-tile .summary-value.deviation-plus {
color: var(--ft-success);
}
.usage-table {
.extras-toggle {
cursor: pointer;
td {
font-weight: 600;
}
&:hover td {
background: var(--ft-bg-hover);
}
}
.extras-item td {
color: var(--ft-text-secondary);
&:first-child {
padding-left: 26px;
}
}
}
// Bublina sloupce výdeje — hodnoty v barvě textu, identitu nese jen čtvereček
.activity-tooltip {
padding: 8px 10px;
font-size: 0.82rem;
max-width: 280px;
box-shadow: var(--ft-shadow);
.activity-tooltip-title {
font-weight: 700;
margin-bottom: 4px;
}
.activity-tooltip-empty {
color: var(--ft-text-muted);
}
.activity-tooltip-row {
display: flex;
align-items: center;
gap: 6px;
}
.activity-tooltip-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
flex: 0 0 auto;
&.plan {
height: 0;
border-top: 2px dashed var(--ft-text-secondary);
border-radius: 0;
}
}
.activity-tooltip-value {
margin-left: auto;
padding-left: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.activity-tooltip-plan {
color: var(--ft-text-secondary);
}
.plan-note {
display: flex;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--ft-border-light);
}
.plan-note-date {
color: var(--ft-text-secondary);
}
}
.plan-note-icon {
color: var(--ft-warning);
margin-top: 2px;
}
.usage-note {
margin: 10px 0 0;
font-size: 0.8rem;
color: var(--ft-text-muted);
}
+138 -151
View File
@@ -1,39 +1,36 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn } from "@fortawesome/free-solid-svg-icons";
import {
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
} from "recharts";
import { FoodStats, MealType, getFoodStats } from "../../../types";
import { MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES } from "../enums";
import {
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
getHumanMonth, getMonthEnd, getMonthStart,
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
} from "../Utils";
import ImportModal from "../components/modals/ImportModal";
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
import ActivityStatsPanel from "./ActivityStatsPanel";
import { TOOLTIP_STYLE, formatPeriodLabel, pickPeriods } from "./statsShared";
import "./StatsPage.scss";
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
const EARLIEST_YEAR = 2015;
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3'];
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
const TOOLTIP_STYLE = {
background: 'var(--ft-bg-card)',
border: '1px solid var(--ft-border)',
borderRadius: 'var(--ft-radius-sm)',
color: 'var(--ft-text)',
};
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#6366f1', '#a3a3a3'];
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
function formatChartPrice(value: unknown): string {
return `${Number(value).toLocaleString('cs-CZ')} Kč`;
}
/** Ikony záložek statistik — stejné dělení jako přehled dne. */
const TAB_ICONS = { [DayTab.PRIJEM]: faUtensils, [DayTab.VYDEJ]: faPersonRunning };
export default function StatsPage() {
const [tab, setTab] = useState<DayTab>(DayTab.PRIJEM);
const [mode, setMode] = useState<StatsRange>(StatsRange.MESIC);
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
const [year, setYear] = useState<number>(() => new Date().getFullYear());
@@ -59,31 +56,15 @@ export default function StatsPage() {
setLoading(false);
}, [range]);
useEffect(() => { load(); }, [load]);
// Výdej si data načítá sám ve svém panelu
useEffect(() => { if (tab === DayTab.PRIJEM) load(); }, [load, tab]);
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
const series = useMemo(() => {
if (!stats) return [];
if (mode === StatsRange.MESIC) {
return stats.byDay.map(total => ({
label: formatDateString(total.period).slice(0, 5),
price: total.price / 100,
calories: total.calories,
}));
}
if (mode === StatsRange.ROK) {
return stats.byMonth.map(total => ({
label: getHumanMonth(total.period).split(' ')[0],
price: total.price / 100,
calories: total.calories,
}));
}
return stats.byYear.map(total => ({
label: total.period,
price: total.price / 100,
calories: total.calories,
}));
}, [stats, mode]);
const series = useMemo(() => stats ? pickPeriods(stats, mode).map(total => ({
label: formatPeriodLabel(total.period, mode),
price: total.price / 100,
calories: total.calories,
})) : [], [stats, mode]);
const pieData = useMemo(() => (stats?.byMealType ?? [])
.filter(total => total.price > 0)
@@ -99,14 +80,31 @@ export default function StatsPage() {
<div className="page-header">
<div>
<h1>Statistiky</h1>
<p className="page-subtitle">Kolik jste utratili za jídlo a za co</p>
<p className="page-subtitle">
{tab === DayTab.PRIJEM
? 'Kolik jste utratili za jídlo a za co'
: 'Kolik jste spálili pohybem a jak se držíte šablon'}
</p>
</div>
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
{tab === DayTab.PRIJEM && (
<Button onClick={() => setImportOpen(true)}>
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
</Button>
)}
</div>
<div className="range-picker card-panel">
<div className="range-modes">
{Object.values(DayTab).map(value => (
<button
key={value}
className={`range-mode ${tab === value ? 'active' : ''}`}
onClick={() => setTab(value)}
>
<FontAwesomeIcon icon={TAB_ICONS[value]} /> {DAY_TAB_NAMES[value]}
</button>
))}
</div>
<div className="range-modes">
{Object.values(StatsRange).map(value => (
<button
@@ -118,121 +116,110 @@ export default function StatsPage() {
</button>
))}
</div>
{mode === StatsRange.MESIC && (
<input
type="month"
className="form-control range-input"
value={month}
onChange={event => event.target.value && setMonth(event.target.value)}
/>
)}
{mode === StatsRange.ROK && (
<input
type="number"
className="form-control range-input"
value={year}
min={EARLIEST_YEAR}
max={new Date().getFullYear()}
onChange={event => setYear(Number(event.target.value))}
/>
)}
<PeriodPicker mode={mode} month={month} year={year} onMonthChange={setMonth} onYearChange={setYear} />
</div>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{tab === DayTab.VYDEJ && <ActivityStatsPanel range={range} mode={mode} />}
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
{tab === DayTab.PRIJEM && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
<div className="summary-value">{formatPrice(averagePerDay)}</div>
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
<div className="summary-value">{stats?.entryCount ?? 0}</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
</div>
</div>
{loading && (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
)}
{!loading && !stats?.entryCount && (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádná data</div>
<p className="empty-description">
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
</p>
</div>
)}
{!loading && !!stats?.entryCount && (
<>
<div className="card-panel chart-panel">
<h2 className="chart-title">Útrata v čase</h2>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
<Tooltip
formatter={value => [formatChartPrice(value), 'Útrata']}
contentStyle={TOOLTIP_STYLE}
/>
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="chart-row">
<div className="card-panel chart-panel">
<h2 className="chart-title">Podle chodu</h2>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
{pieData.map((entry, index) => (
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Legend />
<Tooltip
formatter={value => formatChartPrice(value)}
contentStyle={TOOLTIP_STYLE}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
<table className="source-table">
<thead>
<tr>
<th>Zdroj</th>
<th className="numeric">Jídel</th>
<th className="numeric">Útrata</th>
</tr>
</thead>
<tbody>
{stats.bySource.slice(0, 8).map(total => (
<tr key={total.key}>
<td>{total.key}</td>
<td className="numeric">{total.count}</td>
<td className="numeric">{formatPriceShort(total.price)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</>
)}