diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 86f8b56..0119f4b 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -66,6 +66,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({ "lookup_exercise", "calc_fitness_targets", "calc_body_composition", + "get_body_metrics", "get_weather", "get_morning_briefing", "list_shopping_lists", diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index f330dde..5c9abe3 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -42,7 +42,10 @@ _DOMAIN_CACHE: dict[str, tuple[float, str]] = {} _DOMAIN_TTL_SEC = 60.0 _DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = { - "fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"), + "fitness": ( + "фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир", + "талия", "обхват", "антроп", "ffmi", "whr", "navy", "состав тела", "замер", + ), "shopping": ("покуп", "магазин", "список", "shopping", "корзин"), "reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"), "projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"), diff --git a/backend/app/fitness/charts.py b/backend/app/fitness/charts.py index 9bde0c6..eed53b6 100644 --- a/backend/app/fitness/charts.py +++ b/backend/app/fitness/charts.py @@ -64,6 +64,21 @@ def _last(values: list[tuple[date, float]]) -> float | None: return values[-1][1] +def _day_has_activity( + day: date, + daily_flags: dict[date, set[str]], + workout_kcal_by_day: dict[date, float], +) -> bool: + flags = daily_flags.get(day, set()) + if "nutrition" in flags or "steps" in flags: + return True + return workout_kcal_by_day.get(day, 0) > 0 + + +def _day_has_nutrition(day: date, daily_flags: dict[date, set[str]]) -> bool: + return "nutrition" in daily_flags.get(day, set()) + + def _profile_for_charts(row: FitnessProfile | None) -> dict[str, float | int | str | None] | None: if row is None: return None @@ -312,11 +327,15 @@ def build_fitness_charts( week_daily_values.append(totals["water_ml"] / 1000.0) elif metric == "steps" and totals and "steps" in flags: week_daily_values.append(totals["steps"]) - elif metric == "tdee" and profile is not None and day_cursor <= end: + elif metric == "tdee" and profile is not None and _day_has_activity( + day_cursor, daily_flags, workout_kcal_by_day + ): week_daily_values.append( _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor) ) - elif metric == "tdee_expected" and profile is not None and day_cursor <= end: + elif metric == "tdee_expected" and profile is not None and _day_has_nutrition( + day_cursor, daily_flags + ): week_daily_values.append( _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor) ) @@ -463,7 +482,13 @@ def _build_daily_series( elif key == "steps" and totals and "steps" in flags: value = totals["steps"] has_data = True - elif key == "tdee" and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None: + elif ( + key == "tdee" + and profile is not None + and steps_by_day is not None + and workout_kcal_by_day is not None + and _day_has_activity(d, daily_flags, workout_kcal_by_day) + ): value = _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, d) has_data = True elif ( @@ -471,6 +496,7 @@ def _build_daily_series( and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None + and _day_has_nutrition(d, daily_flags) ): value = _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, d) has_data = True diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py index 24396bd..a47ba4b 100644 --- a/backend/app/fitness/context.py +++ b/backend/app/fitness/context.py @@ -105,31 +105,33 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str: f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)" ) - latest = (snapshot.get("body_metrics") or [None])[0] - if latest: + metrics = snapshot.get("body_metrics") or [] + if metrics: lines.append("") - lines.append("Антропометрия (последняя):") - parts = [f"{latest.get('weight_kg')} кг"] - if latest.get("body_fat_pct") is not None: - method = latest.get("body_fat_method") or "?" - parts.append(f"жир {latest.get('body_fat_pct')}% ({method})") - if latest.get("neck_cm"): - parts.append(f"шея {latest.get('neck_cm')}") - if latest.get("waist_cm"): - parts.append(f"талия {latest.get('waist_cm')}") - if latest.get("hip_cm"): - parts.append(f"бёдра {latest.get('hip_cm')}") - if latest.get("whr"): - parts.append(f"WHR {latest.get('whr')}") - if latest.get("ffmi"): - parts.append(f"FFMI {latest.get('ffmi')}") - lines.append(" · ".join(parts)) + lines.append(f"Антропометрия — последние {len(metrics)} замеров (новые → старые):") + for m in metrics: + recorded = (m.get("recorded_at") or "")[:10] + parts = [f"{recorded}: {m.get('weight_kg')} кг"] + if m.get("body_fat_pct") is not None: + method = m.get("body_fat_method") or "?" + parts.append(f"жир {m.get('body_fat_pct')}% ({method})") + if m.get("waist_cm"): + parts.append(f"талия {m.get('waist_cm')}") + if m.get("neck_cm"): + parts.append(f"шея {m.get('neck_cm')}") + if m.get("hip_cm"): + parts.append(f"бёдра {m.get('hip_cm')}") + if m.get("whr"): + parts.append(f"WHR {m.get('whr')}") + if m.get("ffmi"): + parts.append(f"FFMI {m.get('ffmi')}") + lines.append(" · ".join(parts)) lines.append("") lines.append( "Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps (replace=true — итог за день), log_workout (date/days_ago), " "update_fitness_log, delete_fitness_log (last=true — удалить последнюю), merge_fitness_logs, compare_fitness_days, " - "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, " + "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, get_body_metrics, " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. " "TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. " diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py index 918478c..40cf3a6 100644 --- a/backend/app/fitness/service.py +++ b/backend/app/fitness/service.py @@ -979,7 +979,7 @@ class FitnessService: "today": self.get_daily_summary(today), "history": self.get_history(days=7, end_day=today), "workout_stats": self.get_workout_stats(days=7, end_day=today), - "body_metrics": self.list_body_metrics(limit=10), + "body_metrics": self.list_body_metrics(limit=15), "reminders": self.list_reminders(), } diff --git a/backend/app/homelab/comfyui.py b/backend/app/homelab/comfyui.py index 6247eb0..611886f 100644 --- a/backend/app/homelab/comfyui.py +++ b/backend/app/homelab/comfyui.py @@ -13,15 +13,22 @@ ANIMA_DEFAULT_NEGATIVE = ( "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" ) -ROFL_PROMPTS = [ - f"{ANIMA_QUALITY_PREFIX}, confused cat in tiny business suit, server room, meme, chibi", - f"{ANIMA_QUALITY_PREFIX}, potato with sunglasses on skateboard, absurd cartoon, silly", - f"{ANIMA_QUALITY_PREFIX}, astronaut watering houseplant on the moon, wholesome, cute", - f"{ANIMA_QUALITY_PREFIX}, rubber duck as judge at programming contest, comic style", - f"{ANIMA_QUALITY_PREFIX}, llama DJ at house party, neon lights, party, silly", +ROFL_POSES = [ + "full_body, standing, peace_sign, looking_at_viewer, smile", + "sitting, on_chair, legs_up, playful, blush", + "lying, on_bed, stretching, sleepy, relaxed", + "standing, arms_up, victory_pose, happy, open_mouth", + "sitting, hugging_knees, shy, looking_away", + "full_body, walking, wind, hair_blowing, outdoors", + "portrait, close-up, wink, finger_gun", + "sitting, holding_cup, cozy, sweater, indoors", ] +def random_rofl_pose() -> str: + return random.choice(ROFL_POSES) + + def _use_anima(settings) -> bool: return bool(settings.comfyui_unet.strip()) and not settings.comfyui_checkpoint.strip() @@ -272,6 +279,3 @@ class ComfyUIClient: } return {"ok": False, "error": f"Таймаут генерации ({self.timeout}s)"} - - def random_rofl_prompt(self) -> str: - return random.choice(ROFL_PROMPTS) diff --git a/backend/app/homelab/watcher.py b/backend/app/homelab/watcher.py index 78b66a7..873b887 100644 --- a/backend/app/homelab/watcher.py +++ b/backend/app/homelab/watcher.py @@ -8,7 +8,9 @@ import httpx from app.config import get_settings from app.db.base import SessionLocal -from app.homelab.comfyui import ComfyUIClient +from app.character.service import CharacterService +from app.homelab.comfyui import ComfyUIClient, random_rofl_pose +from app.homelab.image_gen import generate_image from app.homelab.context import resolve_timezone from app.homelab.digest import build_morning_digest from app.homelab.monitoring import check_netdata_alerts @@ -128,10 +130,15 @@ async def _tick_rofl() -> None: if not await _comfyui_reachable(client.base_url): return - prompt = client.random_rofl_prompt() + card = CharacterService(db, 1).get_card().get("data", {}) + appearance = (card.get("appearance_tags") or "").strip() + if not appearance: + return + + pose = random_rofl_pose() try: result = await asyncio.wait_for( - client.generate_image(prompt), + generate_image(db, user_id=1, draw_self=True, scene_description=pose), timeout=settings.comfyui_timeout_sec + 15, ) except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc: @@ -143,7 +150,7 @@ async def _tick_rofl() -> None: url = result.get("url", "") post_chat_notice( - f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_" + f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{pose}_" ) set_state(db, f"comfy_rofl_count_{today}", str(count + 1)) set_state(db, "last_comfy_rofl_at", now.isoformat()) diff --git a/backend/app/homelab_scoped/watcher.py b/backend/app/homelab_scoped/watcher.py index 4d7fbf9..503453d 100644 --- a/backend/app/homelab_scoped/watcher.py +++ b/backend/app/homelab_scoped/watcher.py @@ -10,9 +10,11 @@ from sqlalchemy import select from app.config import get_settings from app.db.base import SessionLocal from app.db.models import User -from app.homelab.comfyui import ComfyUIClient +from app.character.service import CharacterService +from app.homelab.comfyui import ComfyUIClient, random_rofl_pose from app.homelab.context import resolve_timezone from app.homelab.digest import build_morning_digest +from app.homelab.image_gen import generate_image from app.homelab.monitoring import check_netdata_alerts from app.homelab_scoped.notices import post_chat_notice from app.homelab.state import get_state, set_state @@ -140,10 +142,15 @@ async def _tick_rofl() -> None: if not await _comfyui_reachable(client.base_url): continue - prompt = client.random_rofl_prompt() + card = CharacterService(db, user.id).get_card().get("data", {}) + appearance = (card.get("appearance_tags") or "").strip() + if not appearance: + continue + + pose = random_rofl_pose() try: result = await asyncio.wait_for( - client.generate_image(prompt), + generate_image(db, user_id=user.id, draw_self=True, scene_description=pose), timeout=settings.comfyui_timeout_sec + 15, ) except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc: @@ -155,7 +162,7 @@ async def _tick_rofl() -> None: url = result.get("url", "") post_chat_notice( - f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{prompt}_", + f"🎨 **Рофл дня**\n\n![rofl]({url})\n\n_{pose}_", user.id, ) set_state(db, count_key, str(count + 1)) diff --git a/backend/app/tools/fitness.py b/backend/app/tools/fitness.py index dcfec68..ee7dd01 100644 --- a/backend/app/tools/fitness.py +++ b/backend/app/tools/fitness.py @@ -10,6 +10,7 @@ from app.tools._dispatch import NOT_HANDLED, ToolContext TOOL_NAMES = frozenset({ "get_fitness_summary", "get_fitness_history", + "get_body_metrics", "compare_fitness_days", "set_fitness_profile", "calc_fitness_targets", @@ -25,6 +26,7 @@ TOOL_NAMES = frozenset({ "lookup_food", "lookup_exercise", "set_fitness_reminder", + "get_body_metrics", }) TOOL_DEFINITIONS: list[dict[str, Any]] = [ @@ -49,6 +51,26 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "get_body_metrics", + "description": ( + "История антропометрии: вес, % жира, обхваты, WHR, FFMI по датам. " + "«Как менялся вес», «динамика жира», «прошлые замеры», «что было месяц назад»." + ), + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Сколько последних записей, по умолчанию 20, макс. 50", + }, + }, + "required": [], + }, + }, + }, { "type": "function", "function": { @@ -407,6 +429,10 @@ async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any elif arguments.get("days_ago") is not None: day = datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"])) return fitness.get_daily_summary(day) + if name == "get_body_metrics": + limit = max(1, min(int(arguments.get("limit") or 20), 50)) + metrics = fitness.list_body_metrics(limit=limit) + return {"metrics": metrics, "count": len(metrics)} if name == "get_fitness_history": end_day = None if arguments.get("end_date"): diff --git a/frontend/src/components/FitnessCharts.css b/frontend/src/components/FitnessCharts.css index 3405376..cebf3f4 100644 --- a/frontend/src/components/FitnessCharts.css +++ b/frontend/src/components/FitnessCharts.css @@ -135,6 +135,29 @@ opacity: 0.9; } +.fitness-chart-line { + fill: none; + stroke: #5b9bd5; + stroke-width: 1.5; + opacity: 0.55; +} + +.fitness-chart-empty { + margin: 0.5rem 0 0; + color: #8b95a8; + font-size: 0.85rem; +} + +.fitness-chart-empty-reset { + background: none; + border: none; + color: #5b9bd5; + cursor: pointer; + padding: 0; + font: inherit; + text-decoration: underline; +} + @media (max-width: 768px) { .fitness-charts-grid { grid-template-columns: 1fr; diff --git a/frontend/src/components/FitnessCharts.tsx b/frontend/src/components/FitnessCharts.tsx index 1aff4e5..be026a0 100644 --- a/frontend/src/components/FitnessCharts.tsx +++ b/frontend/src/components/FitnessCharts.tsx @@ -14,6 +14,7 @@ const CHART_KEYS = [ "body_fat_pct", ] as const; +const CONNECTED_KEYS = new Set(["weight_kg", "body_fat_pct"]); const MIN_VIEW_SPAN = 2; const ZOOM_FACTOR = 0.85; @@ -28,7 +29,9 @@ interface MetricChartProps { granularity: "week" | "day"; view: ChartView; pointCount: number; + connectPoints: boolean; onViewChange: (view: ChartView) => void; + onResetView: () => void; } function formatTick(point: FitnessChartPoint, granularity: "week" | "day") { @@ -69,12 +72,22 @@ function MetricChart({ granularity, view, pointCount, + connectPoints, onViewChange, + onResetView, }: MetricChartProps) { const svgRef = useRef(null); const dragRef = useRef<{ x: number; view: ChartView } | null>(null); const [dragging, setDragging] = useState(false); + const visibleActive = useMemo( + () => + series.points.filter( + (p) => p.has_data && p.value != null && p.index >= view.start && p.index <= view.end, + ), + [series.points, view.end, view.start], + ); + const layout = useMemo(() => { const width = 640; const height = 168; @@ -83,9 +96,6 @@ function MetricChart({ const plotH = height - pad.top - pad.bottom; const viewSpan = Math.max(view.end - view.start, 0.001); - const visibleActive = series.points.filter( - (p) => p.has_data && p.value != null && p.index >= view.start && p.index <= view.end, - ); if (visibleActive.length === 0) { return null; } @@ -122,6 +132,15 @@ function MetricChart({ trendPath = trendPoints; } + const linePath = + connectPoints && dots.length >= 2 + ? dots + .slice() + .sort((a, b) => a.index - b.index) + .map((dot) => `${dot.x},${dot.y}`) + .join(" ") + : ""; + const yTicks = [yMin, (yMin + yMax) / 2, yMax]; const visibleIndexes = series.points @@ -143,12 +162,13 @@ function MetricChart({ yScale, xScale, dots, + linePath, trendPath, yTicks, labelIndexes, viewSpan, }; - }, [granularity, series, showTrend, view]); + }, [connectPoints, granularity, series, showTrend, view, visibleActive]); const applyView = useCallback( (nextView: ChartView) => { @@ -210,12 +230,6 @@ function MetricChart({ e.currentTarget.releasePointerCapture(e.pointerId); }, []); - if (!layout) { - return null; - } - - const clipId = `clip-${series.key}`; - 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}/день` @@ -223,6 +237,29 @@ function MetricChart({ ? `${(series.trend as { slope_per_week: number }).slope_per_week > 0 ? "+" : ""}${(series.trend as { slope_per_week: number }).slope_per_week}/нед` : null; + if (!layout) { + return ( +
+
+

+ {series.label} ({series.unit}) +

+ + {series.data_points} {granularity === "day" ? "дн." : "нед."} + +
+

+ Нет точек в этом диапазоне.{" "} + +

+
+ ); + } + + const clipId = `clip-${series.key}`; + return (
@@ -277,6 +314,10 @@ function MetricChart({ ) : null} + {layout.linePath ? ( + + ) : null} + {layout.dots.map((dot) => ( @@ -313,19 +354,8 @@ interface FitnessChartsProps { loading?: boolean; } -function formatViewRange( - points: FitnessChartPoint[], - view: ChartView, - granularity: "week" | "day", -): string { - const startPoint = points.find((p) => p.index >= Math.floor(view.start)); - const endPoint = [...points].reverse().find((p) => p.index <= Math.ceil(view.end)); - if (!startPoint || !endPoint) return ""; - return `${formatTick(startPoint, granularity)} — ${formatTick(endPoint, granularity)}`; -} - export default function FitnessCharts({ data, showTrend, loading }: FitnessChartsProps) { - const [view, setView] = useState({ start: 0, end: 0 }); + const [views, setViews] = useState>({}); const granularity = data?.granularity ?? "week"; const source = @@ -338,36 +368,28 @@ export default function FitnessCharts({ data, showTrend, loading }: FitnessChart ); }, [source]); - const pointCount = charts[0]?.points.length ?? 0; - const referencePoints = charts[0]?.points ?? []; - useEffect(() => { - setView(fullView(pointCount)); - }, [pointCount, data?.end_date, data?.granularity]); + setViews((prev) => { + const next: Record = {}; + for (const series of charts) { + const pointCount = series.points.length; + next[series.key] = prev[series.key] ?? fullView(pointCount); + } + return next; + }); + }, [charts, data?.end_date, data?.granularity]); - const zoomBy = useCallback( - (factor: number) => { - if (pointCount <= 1) return; - const span = view.end - view.start; - const center = (view.start + view.end) / 2; - const newSpan = Math.max(MIN_VIEW_SPAN, Math.min(pointCount, span * factor)); - setView( - clampView({ start: center - newSpan / 2, end: center + newSpan / 2 }, pointCount), - ); - }, - [pointCount, view.end, view.start], - ); + const resetAllViews = useCallback(() => { + const next: Record = {}; + for (const series of charts) { + next[series.key] = fullView(series.points.length); + } + setViews(next); + }, [charts]); - const resetView = useCallback(() => { - setView(fullView(pointCount)); - }, [pointCount]); - - const handleViewChange = useCallback( - (next: ChartView) => { - setView(clampView(next, pointCount)); - }, - [pointCount], - ); + const handleViewChange = useCallback((key: string, view: ChartView, pointCount: number) => { + setViews((prev) => ({ ...prev, [key]: clampView(view, pointCount) })); + }, []); if (loading) { return

Загрузка графиков…

; @@ -376,8 +398,10 @@ export default function FitnessCharts({ data, showTrend, loading }: FitnessChart return null; } - const rangeLabel = formatViewRange(referencePoints, view, granularity); - const isZoomed = view.start > 0.01 || view.end < pointCount - 1.01; + const anyZoomed = charts.some((series) => { + const view = views[series.key] ?? fullView(series.points.length); + return view.start > 0.01 || view.end < series.points.length - 1.01; + }); return (
@@ -390,20 +414,11 @@ export default function FitnessCharts({ data, showTrend, loading }: FitnessChart : `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}

- {charts.length > 0 && pointCount > MIN_VIEW_SPAN ? ( + {charts.length > 0 && anyZoomed ? (
- - - {isZoomed ? ( - - ) : null} - {rangeLabel ? {rangeLabel} : null}
) : null} @@ -414,20 +429,28 @@ export default function FitnessCharts({ data, showTrend, loading }: FitnessChart

) : (
- {charts.map((series) => ( - - ))} + {charts.map((series) => { + const pointCount = series.points.length; + const view = views[series.key] ?? fullView(pointCount); + return ( + handleViewChange(series.key, next, pointCount)} + onResetView={() => + handleViewChange(series.key, fullView(pointCount), pointCount) + } + /> + ); + })}
)} - {charts.length > 0 && pointCount > MIN_VIEW_SPAN ? ( + {charts.length > 0 ? (

Колесо мыши — масштаб · перетаскивание — прокрутка

) : null}