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