chore: commit chybějících souborů
This commit is contained in:
1 parent
f1ce3ff297
commit
9e72f431a1
7 files changed
+890
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user