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 (
{note.date && {note.date} } {note.templateName} {!!note.missing.length &&
nesplněno: {note.missing.join(', ')}
} {!!note.changed.length &&
upraveno: {note.changed.join(', ')}
}
); } /** Bublina sloupce — díly po šablonách, plán a hlavně co ze šablon nebylo splněno. */ function ActivityTooltip({ active, payload, label }: Readonly>>) { 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 (
{label}
{!parts.length &&
bez pohybu
} {parts.map(item => (
{item.name} {formatCalories(Number(item.value))}
))} {row.planned > 0 && (
Plán šablon {formatCalories(row.planned)}
)} {row.notes.map((note, index) => )}
); } 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) { // Na nulovém plánu by body jen zaplevelily osu, čára tam stačí sama if (cx == null || cy == null || !payload?.planned) return ; if (!payload.notes.length) { return ; } return ( ! ); } /** * 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) { const [stats, setStats] = useState(); const [templateIds, setTemplateIds] = useState([]); 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 (
); } if (!stats?.entryCount) { return (
Za zvolené období nemáte žádný pohyb

Aktivity přidáte v přehledu dne na záložce Výdej, ručně nebo ze šablony tréninku.

); } 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 ( <>
Pohybem spáleno
{formatCalories(stats.totalCalories)}
za {stats.dayCount} dní s pohybem
Ze šablon
{formatCalories(stats.templateCalories)}
{fulfilment != null ? `${fulfilment} % plánu šablon` : 'žádná šablona nepoužita'}
Odchylka od šablon
{deviation ? formatBalance(deviation) : '—'}
{skippedItems ? `vynecháno cviků: ${skippedItems}` : 'žádný cvik nevynechán'}
Mimo šablonu
{formatCalories(stats.extraCalories)}
z {extraCount} aktivit

Výdej v čase

} cursor={{ fill: 'var(--ft-bg-hover)' }} /> {/* Každá šablona vlastní díl sloupce; tenký okraj v barvě karty díly odděluje */} {templateSeries.map(item => ( ))} {/* Čára plánu sedí proti horní hraně dílů šablon — mezera mezi nimi je nesplněná část */} } activeDot={false} type="linear" />

u bodu plánu znamená, že se ze šablony něco nesplnilo — co přesně, ukáže bublina po najetí na sloupec.

Plnění šablon

{stats.byTemplate.map(usage => { const usageDeviation = usage.actualCalories - usage.plannedCalories; return ( ); })} {!!stats.extras.length && ( setExtrasOpen(open => !open)}> )} {extrasOpen && stats.extras.map(group => ( ))}
Šablona Použito Cviky Plán Skutečnost Odchylka
{usage.name} {usage.dayCount}× {usage.doneItemCount}/{usage.plannedItemCount} {formatCalories(usage.plannedCalories)} {formatCalories(usage.actualCalories)} {usageDeviation ? formatBalance(usageDeviation) : '—'}
{' '}Mimo šablonu {extraCount}× {formatCalories(stats.extraCalories)}
{group.key} {group.count}× {formatCalories(group.calories)}
{!stats.byTemplate.length && (

V tomto období jste žádnou šablonu tréninku nepoužili.

)}

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í.

); }