Files
food-tracer/client/src/pages/ActivityStatsPanel.tsx
T

302 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}