chore: commit chybějících souborů

This commit is contained in:
Ondřej Anděl committed 2026-10-02 19:38:02 +02:00
1 parent f1ce3ff297
commit 9e72f431a1
7 files changed
+890

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { Button, Form } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { StatsRange } from "../enums";
/** Nejstarší rok, do kterého sahá výběr i volba "Vše". Starší data nikdo evidovat nebude. */
export const EARLIEST_YEAR = 2015;
const MONTH_NAME_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long' });
/** Názvy měsíců v 1. pádě ("leden", "únor", ...), index 0 je leden. */
const MONTH_NAMES = Array.from({ length: 12 }, (_, index) => MONTH_NAME_FORMAT.format(new Date(2000, index, 1)));
type Props = {
mode: StatsRange;
/** Zvolený měsíc ve formátu YYYY-MM. */
month: string;
year: number;
onMonthChange: (month: string) => void;
onYearChange: (year: number) => void;
};
/** Sestaví měsíc ve formátu YYYY-MM. */
const toMonth = (year: number, monthIndex: number): string => `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
/**
* Výběr měsíce nebo roku statistik — šipky na posun a rozbalovací seznamy.
*
* Záměrně bez ručního psaní: rozepsaná hodnota ("20" při přepisování roku)
* by se hned odeslala na server jako neplatné období. Ze seznamu vždy přijde
* platná hodnota, takže tahle situace nemůže nastat.
*/
export default function PeriodPicker({ mode, month, year, onMonthChange, onYearChange }: Readonly<Props>) {
const currentYear = new Date().getFullYear();
const years = Array.from({ length: currentYear - EARLIEST_YEAR + 1 }, (_, index) => currentYear - index);
if (mode === StatsRange.MESIC) {
const monthYear = Number(month.substring(0, 4));
const monthIndex = Number(month.substring(5, 7)) - 1;
// Posun přes hranici roku obstará Date, prosinec + 1 je leden dalšího roku
const shift = (delta: number) => {
const shifted = new Date(monthYear, monthIndex + delta, 1);
if (shifted.getFullYear() < EARLIEST_YEAR || shifted.getFullYear() > currentYear) return;
onMonthChange(toMonth(shifted.getFullYear(), shifted.getMonth()));
};
return (
<div className="period-picker">
<Button variant="outline-secondary" size="sm" onClick={() => shift(-1)} aria-label="Předchozí měsíc">
<FontAwesomeIcon icon={faChevronLeft} />
</Button>
<Form.Select
size="sm"
value={monthIndex}
onChange={event => onMonthChange(toMonth(monthYear, Number(event.target.value)))}
aria-label="Měsíc"
>
{MONTH_NAMES.map((name, index) => <option key={name} value={index}>{name}</option>)}
</Form.Select>
<Form.Select
size="sm"
value={monthYear}
onChange={event => onMonthChange(toMonth(Number(event.target.value), monthIndex))}
aria-label="Rok"
>
{years.map(value => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button variant="outline-secondary" size="sm" onClick={() => shift(1)} aria-label="Další měsíc">
<FontAwesomeIcon icon={faChevronRight} />
</Button>
</div>
);
}
if (mode === StatsRange.ROK) {
return (
<div className="period-picker">
<Button
variant="outline-secondary"
size="sm"
disabled={year <= EARLIEST_YEAR}
onClick={() => onYearChange(year - 1)}
aria-label="Předchozí rok"
>
<FontAwesomeIcon icon={faChevronLeft} />
</Button>
<Form.Select
size="sm"
value={year}
onChange={event => onYearChange(Number(event.target.value))}
aria-label="Rok"
>
{years.map(value => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button
variant="outline-secondary"
size="sm"
disabled={year >= currentYear}
onClick={() => onYearChange(year + 1)}
aria-label="Další rok"
>
<FontAwesomeIcon icon={faChevronRight} />
</Button>
</div>
);
}
return null;
}
+301
View File
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from "react";
import { Spinner } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBolt, faBookmark, faChartColumn, faChevronDown, faChevronRight, faCodeCompare, faPersonWalking,
faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
import {
Bar, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, TooltipContentProps, XAxis, YAxis,
} from "recharts";
import { ActivityStats, getActivityStats, getWorkoutTemplates } from "../../../types";
import { StatsRange } from "../enums";
import { formatBalance, formatCalories } from "../Utils";
import { StatsBounds, TOOLTIP_STYLE } from "./statsShared";
import {
ActivitySeriesRow, PlanNote, TemplateSeries, buildActivitySeries, buildTemplateSeries,
} from "./activityChartSeries";
type Props = {
range: StatsBounds;
mode: StatsRange;
};
/** CSS třída odchylky — vynechané je chyba, přidané navíc je v pořádku. */
function deviationClass(deviation: number): string {
if (deviation < 0) return 'deviation-minus';
if (deviation > 0) return 'deviation-plus';
return '';
}
/** Jedna poznámka o nesplněném použití šablony. Kalorie záměrně chybí — jde o to, co se nedodělalo. */
function PlanNoteLine({ note }: Readonly<{ note: PlanNote }>) {
return (
<div className="plan-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" />
<div>
{note.date && <span className="plan-note-date">{note.date} </span>}
<strong>{note.templateName}</strong>
{!!note.missing.length && <div>nesplněno: {note.missing.join(', ')}</div>}
{!!note.changed.length && <div>upraveno: {note.changed.join(', ')}</div>}
</div>
</div>
);
}
/** Bublina sloupce — díly po šablonách, plán a hlavně co ze šablon nebylo splněno. */
function ActivityTooltip({ active, payload, label }: Readonly<Partial<TooltipContentProps<number, string>>>) {
if (!active || !payload?.length) return null;
const row = payload[0].payload as ActivitySeriesRow;
const parts = payload.filter(item => item.dataKey !== 'planned' && Number(item.value) > 0);
return (
<div className="activity-tooltip" style={TOOLTIP_STYLE}>
<div className="activity-tooltip-title">{label}</div>
{!parts.length && <div className="activity-tooltip-empty">bez pohybu</div>}
{parts.map(item => (
<div key={String(item.dataKey)} className="activity-tooltip-row">
<span className="activity-tooltip-swatch" style={{ background: item.color }} />
<span>{item.name}</span>
<span className="activity-tooltip-value">{formatCalories(Number(item.value))}</span>
</div>
))}
{row.planned > 0 && (
<div className="activity-tooltip-row activity-tooltip-plan">
<span className="activity-tooltip-swatch plan" />
<span>Plán šablon</span>
<span className="activity-tooltip-value">{formatCalories(row.planned)}</span>
</div>
)}
{row.notes.map((note, index) => <PlanNoteLine key={index} note={note} />)}
</div>
);
}
type DotProps = { cx?: number, cy?: number, payload?: ActivitySeriesRow, index?: number };
/**
* Bod plánu. Kde se ze šablony něco nesplnilo, je výraznější a nese "!" —
* sedí na úrovni plánu, tedy přesně nad mezerou, kterou nesplněné cviky nechaly.
*/
function PlanDot({ cx, cy, payload, index }: Readonly<DotProps>) {
// Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama
if (cx == null || cy == null || !payload?.planned) return <g key={index} />;
if (!payload.notes.length) {
return <circle key={index} cx={cx} cy={cy} r={3} fill="var(--ft-text-secondary)" />;
}
return (
<g key={index} className="plan-dot-warning">
<circle cx={cx} cy={cy} r={8} fill="var(--ft-warning)" stroke="var(--ft-bg-card)" strokeWidth={2} />
<text x={cx} y={cy} dy="0.35em" textAnchor="middle" fontSize={11} fontWeight={700} fill="var(--ft-bg-card)">!</text>
</g>
);
}
/**
* Statistiky výdeje pohybem: kolik se spálilo, kterými šablonami a co z nich
* zůstalo nesplněné, a kolik přibylo mimo ně.
*/
export default function ActivityStatsPanel({ range, mode }: Readonly<Props>) {
const [stats, setStats] = useState<ActivityStats | undefined>();
const [templateIds, setTemplateIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [extrasOpen, setExtrasOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
getActivityStats({ query: range }).then(response => {
if (cancelled) return;
setStats(response.data);
setLoading(false);
});
return () => { cancelled = true; };
}, [range]);
// Pořadí všech šablon dává barvy, které se nemění podle zobrazeného období
useEffect(() => {
getWorkoutTemplates().then(response => setTemplateIds((response.data ?? []).map(template => template.id)));
}, []);
const { series, templateSeries } = useMemo(() => {
if (!stats) return { series: [], templateSeries: [] as TemplateSeries[] };
const used = new Map(stats.byTemplate.map(usage => [usage.templateId, usage.name]));
const built = buildTemplateSeries(templateIds, used);
return {
series: buildActivitySeries(stats.days, range, mode, built.keyOf),
templateSeries: built.series,
};
}, [stats, templateIds, range, mode]);
if (loading) {
return (
<div className="empty-state">
<Spinner animation="border" variant="success" />
</div>
);
}
if (!stats?.entryCount) {
return (
<div className="card-panel empty-state">
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
<div className="empty-title">Za zvolené období nemáte žádný pohyb</div>
<p className="empty-description">
Aktivity přidáte v přehledu dne na záložce Výdej, ručně nebo ze šablony tréninku.
</p>
</div>
);
}
const deviation = stats.templateCalories - stats.plannedCalories;
const fulfilment = stats.plannedCalories ? Math.round(stats.templateCalories / stats.plannedCalories * 100) : undefined;
const skippedItems = stats.byTemplate.reduce(
(sum, usage) => sum + Math.max(usage.plannedItemCount - usage.doneItemCount, 0), 0);
const extraCount = stats.extras.reduce((sum, group) => sum + group.count, 0);
return (
<>
<div className="summary-grid">
<div className="summary-tile accent">
<div className="summary-label"><FontAwesomeIcon icon={faBolt} /> Pohybem spáleno</div>
<div className="summary-value">{formatCalories(stats.totalCalories)}</div>
<div className="summary-hint">za {stats.dayCount} dní s pohybem</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faBookmark} /> Ze šablon</div>
<div className="summary-value">{formatCalories(stats.templateCalories)}</div>
<div className="summary-hint">
{fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faCodeCompare} /> Odchylka od šablon</div>
<div className={`summary-value ${deviationClass(deviation)}`}>
{deviation ? formatBalance(deviation) : '—'}
</div>
<div className="summary-hint">
{skippedItems ? `vynecháno cviků: ${skippedItems}` : 'žádný cvik nevynechán'}
</div>
</div>
<div className="summary-tile">
<div className="summary-label"><FontAwesomeIcon icon={faPersonWalking} /> Mimo šablonu</div>
<div className="summary-value">{formatCalories(stats.extraCalories)}</div>
<div className="summary-hint">z {extraCount} aktivit</div>
</div>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Výdej v čase</h2>
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={series} margin={{ top: 12, 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 content={<ActivityTooltip />} cursor={{ fill: 'var(--ft-bg-hover)' }} />
<Legend />
{/* Každá šablona vlastní díl sloupce; tenký okraj v barvě karty díly odděluje */}
{templateSeries.map(item => (
<Bar
key={item.key}
dataKey={item.key}
name={item.name}
stackId="vydej"
fill={item.color}
stroke="var(--ft-bg-card)"
strokeWidth={1}
/>
))}
<Bar
dataKey="extra"
name="Mimo šablonu"
stackId="vydej"
fill="var(--ft-series-other)"
stroke="var(--ft-bg-card)"
strokeWidth={1}
radius={[4, 4, 0, 0]}
/>
{/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */}
<Line
dataKey="planned"
name="Plán šablon"
stroke="var(--ft-text-secondary)"
strokeWidth={2}
strokeDasharray="5 4"
dot={props => <PlanDot {...props} />}
activeDot={false}
type="linear"
/>
</ComposedChart>
</ResponsiveContainer>
<p className="usage-note">
<FontAwesomeIcon icon={faTriangleExclamation} className="plan-note-icon" /> u bodu plánu
znamená, že se ze šablony něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec.
</p>
</div>
<div className="card-panel chart-panel">
<h2 className="chart-title">Plnění šablon</h2>
<table className="source-table usage-table">
<thead>
<tr>
<th>Šablona</th>
<th className="numeric">Použito</th>
<th className="numeric">Cviky</th>
<th className="numeric">Plán</th>
<th className="numeric">Skutečnost</th>
<th className="numeric">Odchylka</th>
</tr>
</thead>
<tbody>
{stats.byTemplate.map(usage => {
const usageDeviation = usage.actualCalories - usage.plannedCalories;
return (
<tr key={usage.templateId}>
<td>{usage.name}</td>
<td className="numeric">{usage.dayCount}×</td>
<td className="numeric">{usage.doneItemCount}/{usage.plannedItemCount}</td>
<td className="numeric">{formatCalories(usage.plannedCalories)}</td>
<td className="numeric">{formatCalories(usage.actualCalories)}</td>
<td className={`numeric ${deviationClass(usageDeviation)}`}>
{usageDeviation ? formatBalance(usageDeviation) : '—'}
</td>
</tr>
);
})}
{!!stats.extras.length && (
<tr className="extras-toggle" onClick={() => setExtrasOpen(open => !open)}>
<td>
<FontAwesomeIcon icon={extrasOpen ? faChevronDown : faChevronRight} fixedWidth />
{' '}Mimo šablonu
</td>
<td className="numeric">{extraCount}×</td>
<td className="numeric" />
<td className="numeric" />
<td className="numeric">{formatCalories(stats.extraCalories)}</td>
<td className="numeric" />
</tr>
)}
{extrasOpen && stats.extras.map(group => (
<tr key={group.key} className="extras-item">
<td>{group.key}</td>
<td className="numeric">{group.count}×</td>
<td className="numeric" />
<td className="numeric" />
<td className="numeric">{formatCalories(group.calories)}</td>
<td className="numeric" />
</tr>
))}
</tbody>
</table>
{!stats.byTemplate.length && (
<p className="usage-note">V tomto období jste žádnou šablonu tréninku nepoužili.</p>
)}
<p className="usage-note">
Plán je to, co šablona slibovala v den použití. Smazaný nebo zkrácený cvik se
projeví jako odchylka; když smažete všechny cviky šablony, bere se to jako
vrácené použití.
</p>
</div>
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
import { StatsRange } from "../enums";
import { formatDateString, shiftDate } from "../Utils";
import { StatsBounds, formatPeriodLabel } from "./statsShared";
/** Počet barev kategoriální palety (`--ft-series-1` až `-8`). Další šablony se slijí do "Ostatní šablony". */
export const SERIES_COLOR_COUNT = 8;
/** Klíč řady pro šablony, které se do palety nevešly. */
export const OTHER_TEMPLATES_KEY = 'ostatni';
/** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */
export type PlanNote = {
/** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */
date?: string;
templateName: string;
missing: string[];
changed: string[];
};
/** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
export type ActivitySeriesRow = {
label: string;
extra: number;
/**
* Plán šablon. Den bez šablony má plán nulový — čára jím prochází po ose,
* aby bylo vidět, že se ten den nic neplánovalo, místo aby se přerušila.
*/
planned: number;
notes: PlanNote[];
[templateKey: string]: number | string | null | PlanNote[];
};
/** Šablona jako řada grafu. */
export type TemplateSeries = { key: string, name: string, color: string };
/** Klíč řady šablony v řádku grafu. */
export const templateKey = (templateId: string): string => `t:${templateId}`;
/**
* Sestaví řady šablon s pevnou barvou.
*
* Barva se odvozuje z pořadí šablony v celém seznamu šablon uživatele, ne z toho,
* co se v zobrazeném období zrovna použilo — jinak by šablona měnila barvu podle
* zvoleného měsíce. Smazané šablony a ty nad počet barev jdou do "Ostatní".
*
* @param allTemplateIds identifikátory všech šablon uživatele seřazené dle názvu
* @param used použité šablony v období (id → název)
*/
export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): {
series: TemplateSeries[], keyOf: (templateId: string) => string,
} {
const colorIndex = new Map(allTemplateIds.map((id, index) => [id, index]));
const keyOf = (templateId: string) => {
const index = colorIndex.get(templateId);
return index != null && index < SERIES_COLOR_COUNT ? templateKey(templateId) : OTHER_TEMPLATES_KEY;
};
const series: TemplateSeries[] = [...used.entries()]
.filter(([id]) => keyOf(id) !== OTHER_TEMPLATES_KEY)
.sort(([a], [b]) => colorIndex.get(a)! - colorIndex.get(b)!)
.map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` }));
if ([...used.keys()].some(id => keyOf(id) === OTHER_TEMPLATES_KEY)) {
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní šablony', color: 'var(--ft-series-other-template)' });
}
return { series, keyOf };
}
/**
* Vrátí označení všech období rozsahu, i těch bez pohybu — prázdný den v grafu
* je informace sama o sobě.
*
* U "Vše" se začíná prvním rokem s pohybem, jinak by graf otevíralo deset let nul.
*/
function listPeriods(range: StatsBounds, mode: StatsRange, days: ActivityDayStats[]): string[] {
if (mode === StatsRange.MESIC) {
const periods: string[] = [];
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
return periods;
}
if (mode === StatsRange.ROK) {
const year = range.from.substring(0, 4);
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
}
if (!days.length) return [];
const first = Number(days[0].date.substring(0, 4));
const last = Number(range.to.substring(0, 4));
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
}
/** Zkrátí datum dne na označení období grafu. */
const periodOf = (date: string, mode: StatsRange): string => {
if (mode === StatsRange.MESIC) return date;
if (mode === StatsRange.ROK) return date.substring(0, 7);
return date.substring(0, 4);
};
/**
* Sestaví sloupce grafu výdeje: každá šablona vlastní díl sloupce, nad ní
* aktivity mimo šablony, čára plánu a poznámky o nesplněných cvicích.
*
* @param days dny s pohybem ze serveru
* @param range zobrazené období
* @param mode rozsah statistik
* @param keyOf klíč řady pro šablonu (viz {@link buildTemplateSeries})
*/
export function buildActivitySeries(
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
): ActivitySeriesRow[] {
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days).map(period => [period, {
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
}]));
for (const day of days) {
const row = rows.get(periodOf(day.date, mode));
if (!row) continue;
row.extra += day.extraCalories;
for (const usage of day.templates) {
const key = keyOf(usage.templateId);
row[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories;
row.planned += usage.plannedCalories;
const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO);
const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO);
if (missing.length || changed.length) {
row.notes.push({
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
templateName: usage.name,
missing: missing.map(item => item.planned.name),
changed: changed.map(item => item.planned.name),
});
}
}
}
return [...rows.values()];
}
+32
View File
@@ -0,0 +1,32 @@
import { StatsRange } from "../enums";
import { formatDateString, getHumanMonth } from "../Utils";
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
export const TOOLTIP_STYLE = {
background: 'var(--ft-bg-card)',
border: '1px solid var(--ft-border)',
borderRadius: 'var(--ft-radius-sm)',
color: 'var(--ft-text)',
};
/** Hranice zvoleného období statistik, YYYY-MM-DD včetně. */
export type StatsBounds = { from: string, to: string };
/**
* Popisek období na ose grafu — den u měsíce, měsíc u roku, rok u celého přehledu.
*
* @param period označení období ze serveru (YYYY-MM-DD, YYYY-MM nebo YYYY)
* @param mode zvolený rozsah statistik
*/
export function formatPeriodLabel(period: string, mode: StatsRange): string {
if (mode === StatsRange.MESIC) return formatDateString(period).slice(0, 5);
if (mode === StatsRange.ROK) return getHumanMonth(period).split(' ')[0];
return period;
}
/** Vybere řadu součtů podle rozsahu — dny u měsíce, měsíce u roku, roky u celého přehledu. */
export function pickPeriods<T>(stats: { byDay: T[], byMonth: T[], byYear: T[] }, mode: StatsRange): T[] {
if (mode === StatsRange.MESIC) return stats.byDay;
if (mode === StatsRange.ROK) return stats.byMonth;
return stats.byYear;
}