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,108 @@
|
|||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { StatsRange } from "../enums";
|
||||||
|
|
||||||
|
/** Nejstarší rok, do kterého sahá výběr i volba "Vše". Starší data nikdo evidovat nebude. */
|
||||||
|
export const EARLIEST_YEAR = 2015;
|
||||||
|
|
||||||
|
const MONTH_NAME_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long' });
|
||||||
|
|
||||||
|
/** Názvy měsíců v 1. pádě ("leden", "únor", ...), index 0 je leden. */
|
||||||
|
const MONTH_NAMES = Array.from({ length: 12 }, (_, index) => MONTH_NAME_FORMAT.format(new Date(2000, index, 1)));
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: StatsRange;
|
||||||
|
/** Zvolený měsíc ve formátu YYYY-MM. */
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
onMonthChange: (month: string) => void;
|
||||||
|
onYearChange: (year: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sestaví měsíc ve formátu YYYY-MM. */
|
||||||
|
const toMonth = (year: number, monthIndex: number): string => `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výběr měsíce nebo roku statistik — šipky na posun a rozbalovací seznamy.
|
||||||
|
*
|
||||||
|
* Záměrně bez ručního psaní: rozepsaná hodnota ("20" při přepisování roku)
|
||||||
|
* by se hned odeslala na server jako neplatné období. Ze seznamu vždy přijde
|
||||||
|
* platná hodnota, takže tahle situace nemůže nastat.
|
||||||
|
*/
|
||||||
|
export default function PeriodPicker({ mode, month, year, onMonthChange, onYearChange }: Readonly<Props>) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const years = Array.from({ length: currentYear - EARLIEST_YEAR + 1 }, (_, index) => currentYear - index);
|
||||||
|
|
||||||
|
if (mode === StatsRange.MESIC) {
|
||||||
|
const monthYear = Number(month.substring(0, 4));
|
||||||
|
const monthIndex = Number(month.substring(5, 7)) - 1;
|
||||||
|
// Posun přes hranici roku obstará Date, prosinec + 1 je leden dalšího roku
|
||||||
|
const shift = (delta: number) => {
|
||||||
|
const shifted = new Date(monthYear, monthIndex + delta, 1);
|
||||||
|
if (shifted.getFullYear() < EARLIEST_YEAR || shifted.getFullYear() > currentYear) return;
|
||||||
|
onMonthChange(toMonth(shifted.getFullYear(), shifted.getMonth()));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="period-picker">
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => shift(-1)} aria-label="Předchozí měsíc">
|
||||||
|
<FontAwesomeIcon icon={faChevronLeft} />
|
||||||
|
</Button>
|
||||||
|
<Form.Select
|
||||||
|
size="sm"
|
||||||
|
value={monthIndex}
|
||||||
|
onChange={event => onMonthChange(toMonth(monthYear, Number(event.target.value)))}
|
||||||
|
aria-label="Měsíc"
|
||||||
|
>
|
||||||
|
{MONTH_NAMES.map((name, index) => <option key={name} value={index}>{name}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Select
|
||||||
|
size="sm"
|
||||||
|
value={monthYear}
|
||||||
|
onChange={event => onMonthChange(toMonth(Number(event.target.value), monthIndex))}
|
||||||
|
aria-label="Rok"
|
||||||
|
>
|
||||||
|
{years.map(value => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => shift(1)} aria-label="Další měsíc">
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === StatsRange.ROK) {
|
||||||
|
return (
|
||||||
|
<div className="period-picker">
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={year <= EARLIEST_YEAR}
|
||||||
|
onClick={() => onYearChange(year - 1)}
|
||||||
|
aria-label="Předchozí rok"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronLeft} />
|
||||||
|
</Button>
|
||||||
|
<Form.Select
|
||||||
|
size="sm"
|
||||||
|
value={year}
|
||||||
|
onChange={event => onYearChange(Number(event.target.value))}
|
||||||
|
aria-label="Rok"
|
||||||
|
>
|
||||||
|
{years.map(value => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={year >= currentYear}
|
||||||
|
onClick={() => onYearChange(year + 1)}
|
||||||
|
aria-label="Další rok"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { ActivityDayStats, PlanDeviationKind } from "../../../types";
|
||||||
|
import { StatsRange } from "../enums";
|
||||||
|
import { formatDateString, shiftDate } from "../Utils";
|
||||||
|
import { StatsBounds, formatPeriodLabel } from "./statsShared";
|
||||||
|
|
||||||
|
/** Počet barev kategoriální palety (`--ft-series-1` až `-8`). Další šablony se slijí do "Ostatní šablony". */
|
||||||
|
export const SERIES_COLOR_COUNT = 8;
|
||||||
|
|
||||||
|
/** Klíč řady pro šablony, které se do palety nevešly. */
|
||||||
|
export const OTHER_TEMPLATES_KEY = 'ostatni';
|
||||||
|
|
||||||
|
/** Co se v jednom použití šablony nesplnilo — pro bublinu a značku u sloupce. */
|
||||||
|
export type PlanNote = {
|
||||||
|
/** Den použití; u měsíčního grafu ho popisek sloupce už nese, tak chybí. */
|
||||||
|
date?: string;
|
||||||
|
templateName: string;
|
||||||
|
missing: string[];
|
||||||
|
changed: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jeden sloupec grafu výdeje — kcal po šablonách pod klíči `t:<id>`. */
|
||||||
|
export type ActivitySeriesRow = {
|
||||||
|
label: string;
|
||||||
|
extra: number;
|
||||||
|
/**
|
||||||
|
* Plán šablon. Den bez šablony má plán nulový — čára jím prochází po ose,
|
||||||
|
* aby bylo vidět, že se ten den nic neplánovalo, místo aby se přerušila.
|
||||||
|
*/
|
||||||
|
planned: number;
|
||||||
|
notes: PlanNote[];
|
||||||
|
[templateKey: string]: number | string | null | PlanNote[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Šablona jako řada grafu. */
|
||||||
|
export type TemplateSeries = { key: string, name: string, color: string };
|
||||||
|
|
||||||
|
/** Klíč řady šablony v řádku grafu. */
|
||||||
|
export const templateKey = (templateId: string): string => `t:${templateId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sestaví řady šablon s pevnou barvou.
|
||||||
|
*
|
||||||
|
* Barva se odvozuje z pořadí šablony v celém seznamu šablon uživatele, ne z toho,
|
||||||
|
* co se v zobrazeném období zrovna použilo — jinak by šablona měnila barvu podle
|
||||||
|
* zvoleného měsíce. Smazané šablony a ty nad počet barev jdou do "Ostatní".
|
||||||
|
*
|
||||||
|
* @param allTemplateIds identifikátory všech šablon uživatele seřazené dle názvu
|
||||||
|
* @param used použité šablony v období (id → název)
|
||||||
|
*/
|
||||||
|
export function buildTemplateSeries(allTemplateIds: string[], used: Map<string, string>): {
|
||||||
|
series: TemplateSeries[], keyOf: (templateId: string) => string,
|
||||||
|
} {
|
||||||
|
const colorIndex = new Map(allTemplateIds.map((id, index) => [id, index]));
|
||||||
|
const keyOf = (templateId: string) => {
|
||||||
|
const index = colorIndex.get(templateId);
|
||||||
|
return index != null && index < SERIES_COLOR_COUNT ? templateKey(templateId) : OTHER_TEMPLATES_KEY;
|
||||||
|
};
|
||||||
|
const series: TemplateSeries[] = [...used.entries()]
|
||||||
|
.filter(([id]) => keyOf(id) !== OTHER_TEMPLATES_KEY)
|
||||||
|
.sort(([a], [b]) => colorIndex.get(a)! - colorIndex.get(b)!)
|
||||||
|
.map(([id, name]) => ({ key: templateKey(id), name, color: `var(--ft-series-${colorIndex.get(id)! + 1})` }));
|
||||||
|
if ([...used.keys()].some(id => keyOf(id) === OTHER_TEMPLATES_KEY)) {
|
||||||
|
series.push({ key: OTHER_TEMPLATES_KEY, name: 'Ostatní šablony', color: 'var(--ft-series-other-template)' });
|
||||||
|
}
|
||||||
|
return { series, keyOf };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí označení všech období rozsahu, i těch bez pohybu — prázdný den v grafu
|
||||||
|
* je informace sama o sobě.
|
||||||
|
*
|
||||||
|
* U "Vše" se začíná prvním rokem s pohybem, jinak by graf otevíralo deset let nul.
|
||||||
|
*/
|
||||||
|
function listPeriods(range: StatsBounds, mode: StatsRange, days: ActivityDayStats[]): string[] {
|
||||||
|
if (mode === StatsRange.MESIC) {
|
||||||
|
const periods: string[] = [];
|
||||||
|
for (let date = range.from; date <= range.to; date = shiftDate(date, 1)) periods.push(date);
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
if (mode === StatsRange.ROK) {
|
||||||
|
const year = range.from.substring(0, 4);
|
||||||
|
return Array.from({ length: 12 }, (_, index) => `${year}-${String(index + 1).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
if (!days.length) return [];
|
||||||
|
const first = Number(days[0].date.substring(0, 4));
|
||||||
|
const last = Number(range.to.substring(0, 4));
|
||||||
|
return Array.from({ length: last - first + 1 }, (_, index) => String(first + index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zkrátí datum dne na označení období grafu. */
|
||||||
|
const periodOf = (date: string, mode: StatsRange): string => {
|
||||||
|
if (mode === StatsRange.MESIC) return date;
|
||||||
|
if (mode === StatsRange.ROK) return date.substring(0, 7);
|
||||||
|
return date.substring(0, 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sestaví sloupce grafu výdeje: každá šablona vlastní díl sloupce, nad ní
|
||||||
|
* aktivity mimo šablony, čára plánu a poznámky o nesplněných cvicích.
|
||||||
|
*
|
||||||
|
* @param days dny s pohybem ze serveru
|
||||||
|
* @param range zobrazené období
|
||||||
|
* @param mode rozsah statistik
|
||||||
|
* @param keyOf klíč řady pro šablonu (viz {@link buildTemplateSeries})
|
||||||
|
*/
|
||||||
|
export function buildActivitySeries(
|
||||||
|
days: ActivityDayStats[], range: StatsBounds, mode: StatsRange, keyOf: (templateId: string) => string,
|
||||||
|
): ActivitySeriesRow[] {
|
||||||
|
const rows = new Map<string, ActivitySeriesRow>(listPeriods(range, mode, days).map(period => [period, {
|
||||||
|
label: formatPeriodLabel(period, mode), extra: 0, planned: 0, notes: [],
|
||||||
|
}]));
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
const row = rows.get(periodOf(day.date, mode));
|
||||||
|
if (!row) continue;
|
||||||
|
row.extra += day.extraCalories;
|
||||||
|
for (const usage of day.templates) {
|
||||||
|
const key = keyOf(usage.templateId);
|
||||||
|
row[key] = ((row[key] as number | undefined) ?? 0) + usage.actualCalories;
|
||||||
|
row.planned += usage.plannedCalories;
|
||||||
|
const missing = usage.deviations.filter(item => item.kind === PlanDeviationKind.ODEBRANO);
|
||||||
|
const changed = usage.deviations.filter(item => item.kind === PlanDeviationKind.UPRAVENO);
|
||||||
|
if (missing.length || changed.length) {
|
||||||
|
row.notes.push({
|
||||||
|
...(mode === StatsRange.MESIC ? {} : { date: formatDateString(day.date) }),
|
||||||
|
templateName: usage.name,
|
||||||
|
missing: missing.map(item => item.planned.name),
|
||||||
|
changed: changed.map(item => item.planned.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...rows.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { StatsRange } from "../enums";
|
||||||
|
import { formatDateString, getHumanMonth } from "../Utils";
|
||||||
|
|
||||||
|
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
|
||||||
|
export const TOOLTIP_STYLE = {
|
||||||
|
background: 'var(--ft-bg-card)',
|
||||||
|
border: '1px solid var(--ft-border)',
|
||||||
|
borderRadius: 'var(--ft-radius-sm)',
|
||||||
|
color: 'var(--ft-text)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hranice zvoleného období statistik, YYYY-MM-DD včetně. */
|
||||||
|
export type StatsBounds = { from: string, to: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popisek období na ose grafu — den u měsíce, měsíc u roku, rok u celého přehledu.
|
||||||
|
*
|
||||||
|
* @param period označení období ze serveru (YYYY-MM-DD, YYYY-MM nebo YYYY)
|
||||||
|
* @param mode zvolený rozsah statistik
|
||||||
|
*/
|
||||||
|
export function formatPeriodLabel(period: string, mode: StatsRange): string {
|
||||||
|
if (mode === StatsRange.MESIC) return formatDateString(period).slice(0, 5);
|
||||||
|
if (mode === StatsRange.ROK) return getHumanMonth(period).split(' ')[0];
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vybere řadu součtů podle rozsahu — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||||
|
export function pickPeriods<T>(stats: { byDay: T[], byMonth: T[], byYear: T[] }, mode: StatsRange): T[] {
|
||||||
|
if (mode === StatsRange.MESIC) return stats.byDay;
|
||||||
|
if (mode === StatsRange.ROK) return stats.byMonth;
|
||||||
|
return stats.byYear;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
|
import { addActivity, deleteActivity, getActivityDay, setActivityMultiplier, updateActivity } from "../activities";
|
||||||
|
import { applyWorkoutTemplate, deleteWorkoutTemplate, saveWorkoutTemplate } from "../workouts";
|
||||||
|
import { getActivityStats } from "../statsService";
|
||||||
|
import { getWorkoutPlans } from "../workoutPlans";
|
||||||
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import { applyMealTemplate, saveMealTemplate } from "../mealTemplates";
|
||||||
|
import { addMeal, getDay } from "../meals";
|
||||||
|
import { ActivityUnit, MealType, PlanDeviationKind, WorkoutItemKind } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const LOGIN = 'tester';
|
||||||
|
const DATE = '2025-03-03';
|
||||||
|
const NEXT_DATE = '2025-03-04';
|
||||||
|
|
||||||
|
/** Cvik jako položka šablony. */
|
||||||
|
const cvik = (name: string, quantity: number, rate: number) => ({
|
||||||
|
kind: WorkoutItemKind.CVIK,
|
||||||
|
name,
|
||||||
|
unit: ActivityUnit.OPAKOVANI,
|
||||||
|
quantity,
|
||||||
|
caloriesPer100Units: rate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Založí šablonu Kliky 100 × 0,5 kcal + Dřepy 100 × 0,3 kcal, tedy 80 kcal. */
|
||||||
|
async function createWorkout(): Promise<string> {
|
||||||
|
const [template] = await saveWorkoutTemplate(LOGIN, undefined, 'Workout', [
|
||||||
|
cvik('Kliky', 100, 50),
|
||||||
|
cvik('Dřepy', 100, 30),
|
||||||
|
]);
|
||||||
|
return template.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = () => getActivityStats(LOGIN, '2025-03-01', '2025-03-31');
|
||||||
|
|
||||||
|
beforeEach(() => resetMemoryStorage());
|
||||||
|
|
||||||
|
describe('statistiky výdeje', () => {
|
||||||
|
|
||||||
|
it('odcvičená šablona se shoduje s plánem', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.templateCalories).toBe(80);
|
||||||
|
expect(result.plannedCalories).toBe(80);
|
||||||
|
expect(result.extraCalories).toBe(0);
|
||||||
|
expect(result.byTemplate).toEqual([expect.objectContaining({
|
||||||
|
name: 'Workout', dayCount: 1, plannedCalories: 80, actualCalories: 80,
|
||||||
|
plannedItemCount: 2, doneItemCount: 2,
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smazaný cvik ze šablony se projeví jako odchylka', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
const drepy = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Dřepy')!;
|
||||||
|
await deleteActivity(LOGIN, drepy.id, DATE);
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.templateCalories).toBe(50);
|
||||||
|
expect(result.plannedCalories).toBe(80);
|
||||||
|
expect(result.byTemplate[0]).toEqual(expect.objectContaining({ plannedItemCount: 2, doneItemCount: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zkrácený cvik ze šablony se projeví jako odchylka', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
|
||||||
|
await setActivityMultiplier(LOGIN, kliky.id, DATE, 0.5);
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.templateCalories).toBe(55);
|
||||||
|
expect(result.plannedCalories).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smazání všech cviků šablony vrátí její použití, nepočítá se jako odchylka', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
for (const entry of (await getActivityDay(LOGIN, DATE)).entries) {
|
||||||
|
await deleteActivity(LOGIN, entry.id, DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await getWorkoutPlans(LOGIN, DATE)).toEqual([]);
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.plannedCalories).toBe(0);
|
||||||
|
expect(result.byTemplate).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plán se drží podoby šablony v době použití, ne její pozdější úpravy', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
await saveWorkoutTemplate(LOGIN, id, 'Workout v2', [cvik('Kliky', 200, 50)]);
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.plannedCalories).toBe(80);
|
||||||
|
expect(result.byTemplate[0].name).toBe('Workout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktivity mimo šablonu se sčítají zvlášť podle názvu', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
await addActivity(LOGIN, { date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
|
||||||
|
await addActivity(LOGIN, { date: NEXT_DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.totalCalories).toBe(480);
|
||||||
|
expect(result.extraCalories).toBe(400);
|
||||||
|
expect(result.extras).toEqual([{ key: 'Chůze', calories: 400, count: 2 }]);
|
||||||
|
expect(result.dayCount).toBe(2);
|
||||||
|
expect(result.byDay).toEqual([
|
||||||
|
{ period: DATE, templateCalories: 80, plannedCalories: 80, extraCalories: 200 },
|
||||||
|
{ period: NEXT_DATE, templateCalories: 0, plannedCalories: 0, extraCalories: 200 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cvik přesunutý do jiného dne chybí v původním a v novém se bere bez odchylky', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
|
||||||
|
await updateActivity(LOGIN, kliky.id, { ...kliky, date: NEXT_DATE });
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
// Původní den: plán 80, zůstaly dřepy za 30; nový den: kliky 50 bez plánu
|
||||||
|
expect(result.templateCalories).toBe(80);
|
||||||
|
expect(result.plannedCalories).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('šablona smazaná po použití se ve statistikách drží názvu z plánu', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
await deleteWorkoutTemplate(LOGIN, id);
|
||||||
|
|
||||||
|
const result = await stats();
|
||||||
|
expect(result.byTemplate[0].name).toBe('Workout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne obrácený rozsah', async () => {
|
||||||
|
await expect(getActivityStats(LOGIN, '2025-03-31', '2025-03-01')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('denní detail výdeje', () => {
|
||||||
|
|
||||||
|
it('ukáže, jaká šablona se použila a který cvik z ní chybí', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
const drepy = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Dřepy')!;
|
||||||
|
await deleteActivity(LOGIN, drepy.id, DATE);
|
||||||
|
|
||||||
|
const [day] = (await stats()).days;
|
||||||
|
expect(day.date).toBe(DATE);
|
||||||
|
expect(day.templates).toEqual([expect.objectContaining({
|
||||||
|
name: 'Workout', plannedCalories: 80, actualCalories: 50, hasPlan: true,
|
||||||
|
deviations: [{
|
||||||
|
kind: PlanDeviationKind.ODEBRANO,
|
||||||
|
planned: { name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, quantity: 100, calories: 30 },
|
||||||
|
}],
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upravený cvik ukáže plán i skutečnost', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
const kliky = (await getActivityDay(LOGIN, DATE)).entries.find(entry => entry.name === 'Kliky')!;
|
||||||
|
await updateActivity(LOGIN, kliky.id, { ...kliky, quantity: 40 });
|
||||||
|
|
||||||
|
const [usage] = (await stats()).days[0].templates;
|
||||||
|
expect(usage.deviations).toEqual([{
|
||||||
|
kind: PlanDeviationKind.UPRAVENO,
|
||||||
|
planned: expect.objectContaining({ name: 'Kliky', quantity: 100, calories: 50 }),
|
||||||
|
actual: expect.objectContaining({ name: 'Kliky', quantity: 40, calories: 20 }),
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odcvičená šablona nemá žádnou odchylku', async () => {
|
||||||
|
const id = await createWorkout();
|
||||||
|
await applyWorkoutTemplate(LOGIN, id, DATE);
|
||||||
|
expect((await stats()).days[0].templates[0].deviations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktivity mimo šablonu vrátí jednotlivě i se součtem', async () => {
|
||||||
|
await addActivity(LOGIN, { date: DATE, name: 'Chůze', unit: ActivityUnit.KROKY, quantity: 5000, caloriesPer100Units: 4 });
|
||||||
|
await addActivity(LOGIN, { date: DATE, name: 'Běh', unit: ActivityUnit.MINUTY, quantity: 20, caloriesPer100Units: 1000 });
|
||||||
|
|
||||||
|
const [day] = (await stats()).days;
|
||||||
|
expect(day.templates).toEqual([]);
|
||||||
|
expect(day.extraCalories).toBe(400);
|
||||||
|
expect(day.extras.map(item => item.name)).toEqual(['Chůze', 'Běh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vazba jídla na šablonu', () => {
|
||||||
|
|
||||||
|
it('jídlo ze šablony si pamatuje, ze které vzniklo', async () => {
|
||||||
|
const [template] = await saveMealTemplate(LOGIN, { name: 'Štrůdl', mealType: MealType.PULNOCNI_SVACINA });
|
||||||
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
|
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||||
|
expect(meal.templateId).toBe(template.id);
|
||||||
|
expect(meal.mealType).toBe(MealType.PULNOCNI_SVACINA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ručně zadané jídlo vazbu nemá a klient ji podstrčit nemůže', async () => {
|
||||||
|
await addMeal(LOGIN, { date: DATE, mealType: MealType.OBED, name: 'Guláš', templateId: 'podvrh' });
|
||||||
|
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||||
|
expect(meal.templateId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/** Jeden cvik plánu — jak ho šablona založila. */
|
||||||
|
export type WorkoutPlanItem = {
|
||||||
|
/**
|
||||||
|
* Aktivita, která z cviku vznikla. Podle ní se pozná, jestli cvik ve dni
|
||||||
|
* zůstal a jestli se upravil — podle názvu by to nešlo, šablona může mít
|
||||||
|
* stejný cvik dvakrát. Chybí jen u plánů dopočtených zpětně z dnešní podoby
|
||||||
|
* šablony, kde se cvik ve dni nenašel.
|
||||||
|
*/
|
||||||
|
entryId?: string;
|
||||||
|
name: string;
|
||||||
|
unit: ActivityUnit;
|
||||||
|
quantity: number;
|
||||||
|
sets?: number;
|
||||||
|
calories: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plán jednoho použití šablony v konkrétní den — co šablona v tu chvíli slibovala.
|
||||||
|
*
|
||||||
|
* Bez něj by nešlo poznat odchylku od šablony: smazaná aktivita beze stopy zmizí
|
||||||
|
* a šablona se mezitím mohla upravit, takže porovnání s její současnou podobou
|
||||||
|
* by lhalo.
|
||||||
|
*/
|
||||||
|
export type WorkoutPlan = {
|
||||||
|
templateId: string;
|
||||||
|
/** Název šablony v době použití — šablona se mohla mezitím přejmenovat nebo smazat. */
|
||||||
|
templateName: string;
|
||||||
|
appliedAt: string;
|
||||||
|
items: WorkoutPlanItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Klíč úložiště s plány šablon jednoho dne jednoho uživatele. */
|
||||||
|
const getKey = (login: string, date: string): string => `workoutPlans:${login}:${date}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí plány šablon použitých v daném dni.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date datum ve formátu YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export const getWorkoutPlans = async (login: string, date: string): Promise<WorkoutPlan[]> =>
|
||||||
|
await storage.getData<WorkoutPlan[]>(getKey(login, date)) ?? [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapamatuje si plán použité šablony.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date den, do kterého se šablona použila
|
||||||
|
* @param plan plán šablony
|
||||||
|
*/
|
||||||
|
export const recordWorkoutPlan = async (login: string, date: string, plan: WorkoutPlan): Promise<void> => {
|
||||||
|
await storage.updateData<WorkoutPlan[]>(getKey(login, date), current => [...(current ?? []), plan]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahodí plány šablon, ze kterých ve dni nezůstala žádná aktivita.
|
||||||
|
*
|
||||||
|
* Smazání všech cviků šablony se bere jako vrácení jejího použití, ne jako
|
||||||
|
* neodcvičený trénink — jinak by omylem použitá šablona navždy strašila ve
|
||||||
|
* statistikách jako odchylka. Vynechání jen části cviků se jako odchylka počítá.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param date datum dne
|
||||||
|
* @param remaining aktivity, které ve dni zůstaly
|
||||||
|
*/
|
||||||
|
export const dropOrphanedPlans = async (login: string, date: string, remaining: ActivityEntry[]): Promise<void> => {
|
||||||
|
const key = getKey(login, date);
|
||||||
|
if (!await storage.hasData(key)) return;
|
||||||
|
const ids = new Set(remaining.map(entry => entry.id));
|
||||||
|
await storage.updateData<WorkoutPlan[]>(key,
|
||||||
|
current => (current ?? []).filter(plan => plan.items.some(item => item.entryId && ids.has(item.entryId))));
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
get:
|
||||||
|
operationId: getActivityStats
|
||||||
|
summary: Vrátí statistiky výdeje (pohybu) za zvolené období včetně plnění šablon.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: from
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: První den období (včetně), YYYY-MM-DD
|
||||||
|
- in: query
|
||||||
|
name: to
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Poslední den období (včetně), YYYY-MM-DD
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Statistiky výdeje za období
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/ActivityStats"
|
||||||
|
"400":
|
||||||
|
description: Neplatný rozsah
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
Reference in new issue
Block a user