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;
|
||||
}
|
||||
Reference in new issue
Block a user