diff --git a/backend/app/api/routes/fitness.py b/backend/app/api/routes/fitness.py index d6e44bd..085dc58 100644 --- a/backend/app/api/routes/fitness.py +++ b/backend/app/api/routes/fitness.py @@ -67,6 +67,16 @@ def get_summary( return FitnessService(db).get_daily_summary(d) +@router.get("/fitness/history") +def get_history( + days: int = 7, + end: str | None = None, + db: Session = Depends(get_db), +) -> dict[str, Any]: + end_day = date.fromisoformat(end) if end else None + return FitnessService(db).get_history(days=days, end_day=end_day) + + @router.get("/fitness/profile") def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]: profile = FitnessService(db).get_profile() diff --git a/backend/app/character/card.py b/backend/app/character/card.py index d885488..4254e44 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -12,7 +12,8 @@ TOOLS_INSTRUCTIONS = """ - «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. -- Фитнес: get_fitness_summary, set_fitness_profile, log_meal, log_water, log_weight, log_workout, +- Фитнес: get_fitness_summary (date/days_ago), get_fitness_history, set_fitness_profile, log_meal, log_water, log_weight, log_workout, +- «Что ел вчера» → get_fitness_summary days_ago=1. «За неделю» → get_fitness_history. calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder. - Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index d60566d..5606931 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -57,6 +57,7 @@ MEMORY_TOOL_NAMES = frozenset({ FITNESS_TOOL_NAMES = frozenset({ "get_fitness_summary", + "get_fitness_history", "set_fitness_profile", "calc_fitness_targets", "log_meal", @@ -90,6 +91,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({ "get_pomodoro_status", "recall_memories", "get_fitness_summary", + "get_fitness_history", "lookup_food", "lookup_exercise", "calc_fitness_targets", diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py index 47ace06..0dedc23 100644 --- a/backend/app/fitness/context.py +++ b/backend/app/fitness/context.py @@ -48,7 +48,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str: lines.append("") lines.append( - "Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary, " + "Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary (date/days_ago), get_fitness_history, " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "Еда — оценка LLM (≈), пользователь может уточнить." ) diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py index 50f5b20..adea1fa 100644 --- a/backend/app/fitness/service.py +++ b/backend/app/fitness/service.py @@ -1,5 +1,5 @@ import json -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from typing import Any from sqlalchemy import func, select @@ -346,10 +346,46 @@ class FitnessService: def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} + def get_history( + self, + *, + days: int = 7, + end_day: date | None = None, + ) -> dict[str, Any]: + days = max(1, min(days, 90)) + end = end_day or datetime.now(timezone.utc).date() + start = end - timedelta(days=days - 1) + summaries: list[dict[str, Any]] = [] + + for offset in range(days): + d = start + timedelta(days=offset) + full = self.get_daily_summary(d) + totals = full["totals"] + has_data = bool(full["meals"] or full["water"] or full["workouts"]) + summaries.append( + { + "date": full["date"], + "has_data": has_data, + "totals": totals, + "targets": full["targets"], + "meal_count": len(full["meals"]), + "workout_count": len(full["workouts"]), + } + ) + + return { + "start_date": start.isoformat(), + "end_date": end.isoformat(), + "days": days, + "summaries": summaries, + } + def snapshot(self) -> dict[str, Any]: + today = datetime.now(timezone.utc).date() return { "profile": self.get_profile(), - "today": self.get_daily_summary(), + "today": self.get_daily_summary(today), + "history": self.get_history(days=7, end_day=today), "body_metrics": self.list_body_metrics(limit=10), "reminders": self.list_reminders(), } diff --git a/backend/app/reminders/completion.py b/backend/app/reminders/completion.py new file mode 100644 index 0000000..59ca651 --- /dev/null +++ b/backend/app/reminders/completion.py @@ -0,0 +1,73 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat +from app.db.models import Reminder +from app.llm.client import LLMClient +from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local + +logger = logging.getLogger(__name__) + + +def format_reminder_notice(row: Reminder) -> str: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" + if row.notes: + notice += f"\n{row.notes}" + return notice + + +class ReminderCompletionHandler: + def __init__(self, db: Session): + self.db = db + self.llm = LLMClient() + self.character = CharacterService() + + async def _generate_llm_comment(self, row: Reminder, local_when: str) -> str: + notes_part = f"\nЗаметки: {row.notes}" if row.notes else "" + rec_part = "" + if row.recurrence and row.recurrence != RECURRENCE_NONE: + rec_part = f"\nПовтор: {row.recurrence}" + + system = self.character.get_system_prompt() + user_prompt = f"""Сработало напоминание. +Заголовок: {row.title} +Время: {local_when}{notes_part}{rec_part} + +Напиши пользователю короткое сообщение (2-4 предложения) на русском: напомни о деле, поддержи или предложи действие. Без markdown и без эмодзи.""" + + result = await self.llm.complete( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.8, + visible_reply=True, + ) + return (result.get("content") or "").strip() or f"Напоминание: {row.title}" + + def _mark_fired(self, row: Reminder, now: datetime) -> None: + row.last_fired_at = now + if row.recurrence == RECURRENCE_NONE: + row.completed_at = now + row.enabled = False + else: + row.due_at = _advance_due(row.due_at, row.recurrence) + row.last_fired_at = None + row.updated_at = now + + async def process(self, row: Reminder) -> None: + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + post_notice_to_latest_chat(format_reminder_notice(row)) + + try: + comment = await self._generate_llm_comment(row, local_when) + if comment: + post_character_comment_to_latest_chat(comment) + except Exception: + logger.exception("Reminder LLM comment failed (id=%s)", row.id) + + self._mark_fired(row, datetime.now(timezone.utc)) diff --git a/backend/app/reminders/fire.py b/backend/app/reminders/fire.py index f1d3cee..866c2c4 100644 --- a/backend/app/reminders/fire.py +++ b/backend/app/reminders/fire.py @@ -4,10 +4,9 @@ from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.orm import Session -from app.chat.notice_inbox import post_notice_to_latest_chat from app.db.models import Reminder +from app.reminders.completion import ReminderCompletionHandler from app.reminders.notify import bump_notify_seq -from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local logger = logging.getLogger(__name__) @@ -16,7 +15,7 @@ def _utcnow() -> datetime: return datetime.now(timezone.utc) -def check_due_reminders(db: Session) -> int: +def get_due_reminders(db: Session) -> list[Reminder]: now = _utcnow() stmt = ( select(Reminder) @@ -28,33 +27,19 @@ def check_due_reminders(db: Session) -> int: .order_by(Reminder.due_at.asc()) ) rows = list(db.scalars(stmt).all()) - fired = 0 + return [row for row in rows if not (row.last_fired_at and row.last_fired_at >= row.due_at)] - for row in rows: - if row.last_fired_at and row.last_fired_at >= row.due_at: - continue - local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) - notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" - if row.notes: - notice += f"\n{row.notes}" +async def process_due_reminders(db: Session) -> int: + due = get_due_reminders(db) + if not due: + return 0 - post_notice_to_latest_chat(notice) - row.last_fired_at = now + handler = ReminderCompletionHandler(db) + for row in due: + await handler.process(row) - if row.recurrence == RECURRENCE_NONE: - row.completed_at = now - row.enabled = False - else: - row.due_at = _advance_due(row.due_at, row.recurrence) - row.last_fired_at = None - - row.updated_at = now - fired += 1 - - if fired: - db.commit() - bump_notify_seq(db) - logger.info("Reminders fired: %d", fired) - - return fired + db.commit() + bump_notify_seq(db) + logger.info("Reminders fired: %d", len(due)) + return len(due) diff --git a/backend/app/reminders/watcher.py b/backend/app/reminders/watcher.py index 6129746..955c7ea 100644 --- a/backend/app/reminders/watcher.py +++ b/backend/app/reminders/watcher.py @@ -3,7 +3,7 @@ import logging from app.config import get_settings from app.db.base import SessionLocal -from app.reminders.fire import check_due_reminders +from app.reminders.fire import process_due_reminders logger = logging.getLogger(__name__) @@ -26,6 +26,6 @@ async def reminders_watcher_loop() -> None: async def _tick() -> None: db = SessionLocal() try: - check_due_reminders(db) + await process_due_reminders(db) finally: db.close() diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index d22c45d..855df7e 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -1,4 +1,5 @@ import json +from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy.orm import Session @@ -281,8 +282,39 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_fitness_summary", - "description": "Сводка фитнеса за сегодня: ккал, БЖУ, вода, тренировки.", - "parameters": {"type": "object", "properties": {}, "required": []}, + "description": ( + "Сводка фитнеса за день: ккал, БЖУ, вода, еда, тренировки. " + "Без даты — сегодня; date=YYYY-MM-DD или days_ago=1 (вчера)." + ), + "parameters": { + "type": "object", + "properties": { + "date": {"type": "string", "description": "Дата YYYY-MM-DD"}, + "days_ago": { + "type": "integer", + "description": "0 сегодня, 1 вчера, 2 позавчера…", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_fitness_history", + "description": ( + "Краткая история за несколько дней (ккал, вода, тренировки по дням). " + "«На прошлой неделе», «за 7 дней»." + ), + "parameters": { + "type": "object", + "properties": { + "days": {"type": "integer", "description": "Сколько дней, по умолчанию 7"}, + "end_date": {"type": "string", "description": "Конец периода YYYY-MM-DD, по умолчанию сегодня"}, + }, + "required": [], + }, }, }, { @@ -778,7 +810,22 @@ async def execute_tool( arguments.get("summary", ""), ) elif name == "get_fitness_summary": - result = fitness.get_daily_summary() + day: date | None = None + if arguments.get("date"): + day = date.fromisoformat(str(arguments["date"])) + elif arguments.get("days_ago") is not None: + day = datetime.now(timezone.utc).date() - timedelta( + days=int(arguments["days_ago"]) + ) + result = fitness.get_daily_summary(day) + elif name == "get_fitness_history": + end_day = None + if arguments.get("end_date"): + end_day = date.fromisoformat(str(arguments["end_date"])) + result = fitness.get_history( + days=int(arguments.get("days") or 7), + end_day=end_day, + ) elif name == "set_fitness_profile": updates = { k: arguments[k] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f7c11ff..db1255f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -168,9 +168,26 @@ export interface FitnessReminder { enabled: boolean; } +export interface FitnessDayOverview { + date: string; + has_data: boolean; + totals: FitnessDailySummary["totals"]; + targets: FitnessDailySummary["targets"]; + meal_count: number; + workout_count: number; +} + +export interface FitnessHistory { + start_date: string; + end_date: string; + days: number; + summaries: FitnessDayOverview[]; +} + export interface FitnessSnapshot { profile: FitnessProfile | null; today: FitnessDailySummary; + history?: FitnessHistory; body_metrics: BodyMetric[]; reminders: FitnessReminder[]; } @@ -355,6 +372,17 @@ export const api = { getFitnessSnapshot: () => request("/api/v1/fitness"), + getFitnessSummary: (day?: string) => + request( + `/api/v1/fitness/summary${day ? `?day=${encodeURIComponent(day)}` : ""}` + ), + + getFitnessHistory: (days = 7, end?: string) => { + const params = new URLSearchParams({ days: String(days) }); + if (end) params.set("end", end); + return request(`/api/v1/fitness/history?${params}`); + }, + updateFitnessProfile: (updates: Partial) => request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", { method: "PUT", diff --git a/frontend/src/pages/Fitness.css b/frontend/src/pages/Fitness.css index 9023d4d..c64a7dc 100644 --- a/frontend/src/pages/Fitness.css +++ b/frontend/src/pages/Fitness.css @@ -37,6 +37,89 @@ border-radius: 8px; } +.fitness-day-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.fitness-day-nav button { + width: 2rem; + height: 2rem; + border-radius: 8px; + border: 1px solid #2f3748; + background: #1a1f2b; + color: inherit; + cursor: pointer; +} + +.fitness-day-nav button:disabled { + opacity: 0.4; + cursor: default; +} + +.fitness-day-title { + text-align: center; + flex: 1; +} + +.fitness-day-title h3 { + margin: 0 0 0.25rem; +} + +.fitness-day-title input[type="date"] { + border: 1px solid #2f3748; + background: #1a1f2b; + color: inherit; + border-radius: 6px; + padding: 0.2rem 0.4rem; + font-size: 0.85rem; +} + +.fitness-week-strip { + display: flex; + gap: 0.35rem; + overflow-x: auto; + margin-bottom: 1rem; + padding-bottom: 0.25rem; +} + +.fitness-week-day { + flex: 0 0 auto; + min-width: 3.2rem; + border: 1px solid #2f3748; + border-radius: 8px; + background: #1a1f2b; + color: inherit; + padding: 0.35rem 0.4rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + +.fitness-week-day.active { + border-color: #4a7cff; + background: #1c2740; +} + +.fitness-week-day.empty .fitness-week-kcal { + color: #6b7280; +} + +.fitness-week-date { + font-size: 0.75rem; + color: #8b95a8; +} + +.fitness-week-kcal { + font-size: 0.8rem; + font-weight: 600; +} + .fitness-section { background: #151922; border: 1px solid #2a2f3a; diff --git a/frontend/src/pages/Fitness.tsx b/frontend/src/pages/Fitness.tsx index c4ea5d8..55af3c7 100644 --- a/frontend/src/pages/Fitness.tsx +++ b/frontend/src/pages/Fitness.tsx @@ -1,12 +1,35 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; import { api, + FitnessDailySummary, + FitnessHistory, FitnessProfile, FitnessReminder, FitnessSnapshot, } from "../api/client"; import "./Fitness.css"; +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +function shiftDate(iso: string, deltaDays: number) { + const d = new Date(`${iso}T12:00:00`); + d.setDate(d.getDate() + deltaDays); + return d.toISOString().slice(0, 10); +} + +function formatDayLabel(iso: string) { + const today = todayIso(); + if (iso === today) return "Сегодня"; + if (iso === shiftDate(today, -1)) return "Вчера"; + return new Date(`${iso}T12:00:00`).toLocaleDateString("ru-RU", { + weekday: "short", + day: "numeric", + month: "short", + }); +} + function ProgressBar({ label, current, target, unit }: { label: string; current: number; @@ -31,27 +54,41 @@ function ProgressBar({ label, current, target, unit }: { export default function Fitness() { const [snapshot, setSnapshot] = useState(null); + const [selectedDate, setSelectedDate] = useState(todayIso); + const [daySummary, setDaySummary] = useState(null); + const [history, setHistory] = useState(null); const [profile, setProfile] = useState>({}); const [message, setMessage] = useState(""); const [showRaw, setShowRaw] = useState(false); const [loading, setLoading] = useState(false); - const load = useCallback(async () => { + const load = useCallback(async (day: string = selectedDate) => { setLoading(true); try { - const data = await api.getFitnessSnapshot(); + const [data, summary, hist] = await Promise.all([ + api.getFitnessSnapshot(), + api.getFitnessSummary(day), + api.getFitnessHistory(7, day), + ]); setSnapshot(data); + setDaySummary(summary); + setHistory(hist); if (data.profile) setProfile(data.profile); + setMessage(""); } catch (err) { setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); } finally { setLoading(false); } - }, []); + }, [selectedDate]); useEffect(() => { - load().catch(console.error); - }, [load]); + load(selectedDate).catch(console.error); + }, [selectedDate, load]); + + const pickDay = (iso: string) => { + setSelectedDate(iso); + }; const handleProfileSave = async (e: FormEvent) => { e.preventDefault(); @@ -83,9 +120,9 @@ export default function Fitness() { await load(); }; - const today = snapshot?.today; - const totals = today?.totals; - const targets = today?.targets; + const totals = daySummary?.totals; + const targets = daySummary?.targets; + const isToday = selectedDate === todayIso(); return (
@@ -95,7 +132,7 @@ export default function Fitness() {

Дневник, цели, напоминания

- +
+

{formatDayLabel(selectedDate)}

+ pickDay(e.target.value)} + aria-label="Выбор дня" + /> +
+ +
+ + {history && history.summaries.length > 0 && ( +
+ {history.summaries.map((row) => ( + + ))} +
+ )} + {totals && targets ? (
) : ( -

Нет данных за сегодня

+

Нет записей за этот день

)} @@ -230,33 +312,37 @@ export default function Fitness() {
-

Логи за сегодня

+

Логи {isToday ? "за сегодня" : `за ${selectedDate}`}

Еда

    - {(today?.meals ?? []).map((m) => ( + {(daySummary?.meals ?? []).map((m) => (
  • {m.estimated ? "≈" : ""} {m.description} — {m.calories} ккал - + {isToday && ( + + )}
  • ))}

Вода

    - {(today?.water ?? []).map((w) => ( + {(daySummary?.water ?? []).map((w) => (
  • +{w.amount_ml} мл - + {isToday && ( + + )}
  • ))}

Тренировки

    - {(today?.workouts ?? []).map((w) => ( + {(daySummary?.workouts ?? []).map((w) => (
  • {w.title}
  • ))}