added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-14 06:26:16 +00:00
parent c8a9429bed
commit 0c8ab6018a
24 changed files with 1280 additions and 479 deletions
+217
View File
@@ -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>
);
}