added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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 (
|
||||
<article className="fitness-chart-card">
|
||||
<div className="fitness-chart-card-header">
|
||||
<h4>
|
||||
{series.label} <span className="fitness-chart-unit">({series.unit})</span>
|
||||
</h4>
|
||||
<span className="fitness-chart-meta">
|
||||
{series.data_points} {granularity === "day" ? "дн." : "нед."}
|
||||
{showTrend && slopeLabel ? ` · тренд ${slopeLabel}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="fitness-chart-svg"
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
role="img"
|
||||
aria-label={`График ${series.label}`}
|
||||
>
|
||||
{layout.yTicks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={layout.pad.left}
|
||||
x2={layout.pad.left + layout.plotW}
|
||||
y1={layout.yScale(tick)}
|
||||
y2={layout.yScale(tick)}
|
||||
className="fitness-chart-grid"
|
||||
/>
|
||||
<text x={layout.pad.left - 6} y={layout.yScale(tick) + 4} className="fitness-chart-axis">
|
||||
{tick.toFixed(tick >= 100 ? 0 : 1)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{showTrend && layout.trendPath ? (
|
||||
<polyline points={layout.trendPath} className="fitness-chart-trend" />
|
||||
) : null}
|
||||
|
||||
{layout.dots.map((dot) => (
|
||||
<g key={`${dot.label}-${dot.value}`}>
|
||||
<circle cx={dot.x} cy={dot.y} r={4.5} className="fitness-chart-dot" />
|
||||
<title>
|
||||
{dot.label}: {dot.value.toFixed(1)} {series.unit}
|
||||
</title>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{layout.labelIndexes.map((idx) => {
|
||||
const point = series.points[idx];
|
||||
if (!point?.has_data) return null;
|
||||
return (
|
||||
<text
|
||||
key={idx}
|
||||
x={layout.xScale(idx)}
|
||||
y={layout.height - 8}
|
||||
textAnchor="middle"
|
||||
className="fitness-chart-axis"
|
||||
>
|
||||
{formatTick(point, granularity)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface FitnessChartsProps {
|
||||
data: FitnessChartsResponse | null;
|
||||
showTrend: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function FitnessCharts({ data, showTrend, loading }: FitnessChartsProps) {
|
||||
if (loading) {
|
||||
return <p className="fitness-chart-status">Загрузка графиков…</p>;
|
||||
}
|
||||
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 (
|
||||
<section className="fitness-section fitness-charts-section">
|
||||
<div className="fitness-charts-head">
|
||||
<div>
|
||||
<h3>Динамика за год</h3>
|
||||
<p className="fitness-charts-subtitle">
|
||||
{granularity === "day"
|
||||
? `Мало данных (${data.days_with_data} дн.) — показаны дневные точки`
|
||||
: `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{charts.length === 0 ? (
|
||||
<p className="fitness-empty">
|
||||
Пока нет данных для графиков. Логируй еду, воду, шаги или вес — точки появятся автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<div className="fitness-charts-grid">
|
||||
{charts.map((series) => (
|
||||
<MetricChart
|
||||
key={series.key}
|
||||
series={series}
|
||||
showTrend={showTrend}
|
||||
granularity={granularity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user