import { useMemo } from "react";
import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client";
import "./FitnessCharts.css";
const CHART_KEYS = ["weight_kg", "calories", "protein_g", "water_l", "steps", "body_fat_pct"] as const;
interface MetricChartProps {
series: FitnessChartSeries;
showTrend: boolean;
granularity: "week" | "day";
}
function formatTick(point: FitnessChartPoint, granularity: "week" | "day") {
if (granularity === "day") {
const d = new Date(`${point.date}T12:00:00`);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
const d = new Date(`${point.week_start}T12:00:00`);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
function MetricChart({ series, showTrend, granularity }: MetricChartProps) {
const layout = useMemo(() => {
const width = 640;
const height = 168;
const pad = { top: 12, right: 12, bottom: 28, left: 44 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const active = series.points.filter((p) => p.has_data && p.value != null);
if (active.length === 0) {
return null;
}
const xMax = Math.max(1, series.points.length - 1);
const values = active.map((p) => p.value as number);
let yMin = Math.min(...values);
let yMax = Math.max(...values);
if (yMin === yMax) {
yMin -= 1;
yMax += 1;
} else {
const padY = (yMax - yMin) * 0.12;
yMin -= padY;
yMax += padY;
}
const xScale = (index: number) => pad.left + (index / xMax) * plotW;
const yScale = (value: number) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH;
const dots = active.map((p) => ({
x: xScale(p.index),
y: yScale(p.value as number),
label: formatTick(p, granularity),
value: p.value as number,
}));
let trendPath = "";
if (showTrend && series.trend?.line) {
const trendPoints = series.trend.line
.map((p) => `${xScale(p.index)},${yScale(p.value)}`)
.join(" ");
trendPath = trendPoints;
}
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
const labelIndexes = active.length <= 4
? active.map((p) => p.index)
: [active[0].index, active[active.length - 1].index];
return {
width,
height,
pad,
plotW,
plotH,
yMin,
yMax,
yScale,
xScale,
dots,
trendPath,
yTicks,
labelIndexes,
};
}, [granularity, series, showTrend]);
if (!layout) {
return null;
}
const slopeLabel =
granularity === "day" && series.trend && "slope_per_day" in series.trend
? `${(series.trend as { slope_per_day: number }).slope_per_day > 0 ? "+" : ""}${(series.trend as { slope_per_day: number }).slope_per_day}/день`
: series.trend && "slope_per_week" in series.trend
? `${(series.trend as { slope_per_week: number }).slope_per_week > 0 ? "+" : ""}${(series.trend as { slope_per_week: number }).slope_per_week}/нед`
: null;
return (
{series.label} ({series.unit})
{series.data_points} {granularity === "day" ? "дн." : "нед."}
{showTrend && slopeLabel ? ` · тренд ${slopeLabel}` : ""}
Загрузка графиков…
; } if (!data) { return null; } const granularity = data.granularity; const source = granularity === "day" && data.daily_series ? data.daily_series : data.series; const charts = CHART_KEYS.map((key) => source[key]).filter( (series) => series && series.data_points > 0, ); return ({granularity === "day" ? `Мало данных (${data.days_with_data} дн.) — показаны дневные точки` : `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}
Пока нет данных для графиков. Логируй еду, воду, шаги или вес — точки появятся автоматически.
) : (