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

+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>
</>
);
}