added fitness
This commit is contained in:
@@ -19,6 +19,11 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080
|
|||||||
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
SYSTEM_PROMPT_PATH=./prompts/assistant.md
|
||||||
MEMORY_AUTO_EXTRACT=true
|
MEMORY_AUTO_EXTRACT=true
|
||||||
|
|
||||||
|
# Fitness (wger + Open Food Facts — public HTTPS, no proxy)
|
||||||
|
WGER_BASE_URL=https://wger.de/api/v2
|
||||||
|
OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org
|
||||||
|
FITNESS_REMINDERS_ENABLED=true
|
||||||
|
|
||||||
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
# Taiga (on host :9000, nginx → taiga.grigowashere.ru)
|
||||||
TAIGA_BASE_URL=http://host.docker.internal:9000
|
TAIGA_BASE_URL=http://host.docker.internal:9000
|
||||||
TAIGA_USERNAME=your_taiga_user
|
TAIGA_USERNAME=your_taiga_user
|
||||||
|
|||||||
@@ -201,12 +201,19 @@ data/ SQLite БД (создаётся автоматически)
|
|||||||
| DELETE | `/api/v1/memory/facts/{id}` | забыть |
|
| DELETE | `/api/v1/memory/facts/{id}` | забыть |
|
||||||
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
|
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
|
||||||
|
|
||||||
|
## Фитнес-трекер
|
||||||
|
|
||||||
|
Профиль, дневник (еда/вода/вес/тренировки), калькуляторы TDEE, LLM-оценка ккал/БЖУ,
|
||||||
|
lookup wger + Open Food Facts, напоминания в чат (`💪`), вкладка `/fitness`.
|
||||||
|
|
||||||
|
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
||||||
|
|
||||||
## Следующие фазы
|
## Следующие фазы
|
||||||
|
|
||||||
- Фаза 4: инструменты с обращением к внешним API
|
- Фаза 4: инструменты с обращением к внешним API
|
||||||
- Фаза 5: RAG по файлам, Qdrant (если понадобится семантика)
|
- Фаза 5: RAG по файлам
|
||||||
- Проактивные чаты по расписанию
|
- Проактивные чаты по расписанию
|
||||||
- Фитнес-трекер
|
- Telegram, графики веса, LLM-мотивация в напоминаниях
|
||||||
|
|
||||||
## Модель
|
## Модель
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import character, chat, health, memory, pomodoro, projects, webhooks
|
from app.api.routes import character, chat, fitness, health, memory, pomodoro, projects, webhooks
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
@@ -9,4 +9,5 @@ api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]
|
|||||||
api_router.include_router(character.router, tags=["character"])
|
api_router.include_router(character.router, tags=["character"])
|
||||||
api_router.include_router(projects.router, tags=["projects"])
|
api_router.include_router(projects.router, tags=["projects"])
|
||||||
api_router.include_router(memory.router, tags=["memory"])
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
|
api_router.include_router(fitness.router, tags=["fitness"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
from app.fitness.structuring import structure_meal, structure_workout
|
||||||
|
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||||
|
from app.integrations.wger import WgerClient
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
sex: str | None = None
|
||||||
|
age: int | None = None
|
||||||
|
height_cm: float | None = None
|
||||||
|
weight_kg: float | None = None
|
||||||
|
activity_level: str | None = None
|
||||||
|
goal: str | None = None
|
||||||
|
target_weight_kg: float | None = None
|
||||||
|
weekly_workouts: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MealCreate(BaseModel):
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
meal_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WaterCreate(BaseModel):
|
||||||
|
amount_ml: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightCreate(BaseModel):
|
||||||
|
weight_kg: float = Field(gt=0)
|
||||||
|
body_fat_pct: float | None = None
|
||||||
|
chest_cm: float | None = None
|
||||||
|
waist_cm: float | None = None
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutCreate(BaseModel):
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
hour: int | None = Field(default=None, ge=0, le=23)
|
||||||
|
minute: int | None = Field(default=None, ge=0, le=59)
|
||||||
|
interval_hours: int | None = Field(default=None, ge=1, le=12)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness")
|
||||||
|
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/summary")
|
||||||
|
def get_summary(
|
||||||
|
day: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
d = date.fromisoformat(day) if day else None
|
||||||
|
return FitnessService(db).get_daily_summary(d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/profile")
|
||||||
|
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
profile = FitnessService(db).get_profile()
|
||||||
|
return profile or {"configured": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fitness/profile")
|
||||||
|
def update_profile(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).set_profile(payload.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/profile/calc")
|
||||||
|
def calc_targets(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params = payload.model_dump(exclude_none=True)
|
||||||
|
if not params:
|
||||||
|
raise HTTPException(status_code=400, detail="No parameters")
|
||||||
|
return FitnessService(db).calc_targets(params)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/meals")
|
||||||
|
async def create_meal(
|
||||||
|
payload: MealCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
service = FitnessService(db)
|
||||||
|
try:
|
||||||
|
structured = await structure_meal(payload.text)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return service.log_meal(
|
||||||
|
description=structured.get("description") or payload.text,
|
||||||
|
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
|
||||||
|
calories=float(structured.get("calories") or 0),
|
||||||
|
protein_g=float(structured.get("protein_g") or 0),
|
||||||
|
fat_g=float(structured.get("fat_g") or 0),
|
||||||
|
carbs_g=float(structured.get("carbs_g") or 0),
|
||||||
|
source="llm",
|
||||||
|
estimated=bool(structured.get("estimated", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/water")
|
||||||
|
def create_water(
|
||||||
|
payload: WaterCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).log_water(payload.amount_ml)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/weight")
|
||||||
|
def create_weight(
|
||||||
|
payload: WeightCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).log_weight(
|
||||||
|
payload.weight_kg,
|
||||||
|
body_fat_pct=payload.body_fat_pct,
|
||||||
|
chest_cm=payload.chest_cm,
|
||||||
|
waist_cm=payload.waist_cm,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fitness/workouts")
|
||||||
|
async def create_workout(
|
||||||
|
payload: WorkoutCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
service = FitnessService(db)
|
||||||
|
try:
|
||||||
|
structured = await structure_workout(payload.text)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return service.log_workout(
|
||||||
|
title=structured.get("title") or "Тренировка",
|
||||||
|
notes=structured.get("notes") or payload.text,
|
||||||
|
duration_min=structured.get("duration_min"),
|
||||||
|
exercises=structured.get("exercises"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/body-metrics")
|
||||||
|
def list_metrics(
|
||||||
|
limit: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return FitnessService(db).list_body_metrics(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/meals/{log_id}")
|
||||||
|
def delete_meal(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db).delete_food_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/water/{log_id}")
|
||||||
|
def delete_water(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db).delete_water_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fitness/workouts/{log_id}")
|
||||||
|
def delete_workout(log_id: int, db: Session = Depends(get_db)) -> dict[str, bool]:
|
||||||
|
if not FitnessService(db).delete_workout_log(log_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/reminders")
|
||||||
|
def list_reminders(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
||||||
|
return FitnessService(db).list_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fitness/reminders/{kind}")
|
||||||
|
def update_reminder(
|
||||||
|
kind: str,
|
||||||
|
payload: ReminderUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).set_reminder(
|
||||||
|
kind,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
hour=payload.hour,
|
||||||
|
minute=payload.minute,
|
||||||
|
interval_hours=payload.interval_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/lookup/food")
|
||||||
|
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
return OpenFoodFactsClient().search(q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fitness/lookup/exercise")
|
||||||
|
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
return WgerClient().search_exercises(q, limit=limit)
|
||||||
@@ -12,6 +12,8 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
||||||
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
||||||
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
||||||
|
- Фитнес: get_fitness_summary, set_fitness_profile, log_meal, log_water, log_weight, log_workout,
|
||||||
|
calc_fitness_targets, lookup_food, lookup_exercise, set_fitness_reminder.
|
||||||
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
|
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
|
||||||
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
|
- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
|
||||||
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
||||||
|
|||||||
@@ -55,10 +55,27 @@ MEMORY_TOOL_NAMES = frozenset({
|
|||||||
"update_session_summary",
|
"update_session_summary",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
FITNESS_TOOL_NAMES = frozenset({
|
||||||
|
"get_fitness_summary",
|
||||||
|
"set_fitness_profile",
|
||||||
|
"calc_fitness_targets",
|
||||||
|
"log_meal",
|
||||||
|
"log_water",
|
||||||
|
"log_weight",
|
||||||
|
"log_workout",
|
||||||
|
"lookup_food",
|
||||||
|
"lookup_exercise",
|
||||||
|
"set_fitness_reminder",
|
||||||
|
})
|
||||||
|
|
||||||
# Не засорять чат служебными ответами
|
# Не засорять чат служебными ответами
|
||||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||||
"get_pomodoro_status",
|
"get_pomodoro_status",
|
||||||
"recall_memories",
|
"recall_memories",
|
||||||
|
"get_fitness_summary",
|
||||||
|
"lookup_food",
|
||||||
|
"lookup_exercise",
|
||||||
|
"calc_fitness_targets",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +93,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
prefix = "⏱"
|
prefix = "⏱"
|
||||||
elif tool_name in MEMORY_TOOL_NAMES:
|
elif tool_name in MEMORY_TOOL_NAMES:
|
||||||
prefix = "🧠"
|
prefix = "🧠"
|
||||||
|
elif tool_name in FITNESS_TOOL_NAMES:
|
||||||
|
prefix = "💪"
|
||||||
else:
|
else:
|
||||||
prefix = "📋"
|
prefix = "📋"
|
||||||
return f"{prefix} {data['error']}"
|
return f"{prefix} {data['error']}"
|
||||||
@@ -138,6 +157,39 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
if tool_name == "update_session_summary" and data.get("ok"):
|
if tool_name == "update_session_summary" and data.get("ok"):
|
||||||
return "🧠 **Сводка чата сохранена**"
|
return "🧠 **Сводка чата сохранена**"
|
||||||
|
|
||||||
|
if tool_name == "log_meal" and data.get("ok"):
|
||||||
|
meal = data.get("meal", {})
|
||||||
|
est = "≈" if meal.get("estimated") else ""
|
||||||
|
return (
|
||||||
|
f"💪 **Приём пищи** · {meal.get('description')} · "
|
||||||
|
f"{est}{meal.get('calories', 0):.0f} ккал "
|
||||||
|
f"(Б{meal.get('protein_g', 0):.0f}/Ж{meal.get('fat_g', 0):.0f}/У{meal.get('carbs_g', 0):.0f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if tool_name == "log_water" and data.get("ok"):
|
||||||
|
w = data.get("water", {})
|
||||||
|
return f"💪 **Вода** +{w.get('amount_ml')} мл"
|
||||||
|
|
||||||
|
if tool_name == "log_weight" and data.get("ok"):
|
||||||
|
m = data.get("metric", {})
|
||||||
|
return f"💪 **Вес** {m.get('weight_kg')} кг"
|
||||||
|
|
||||||
|
if tool_name == "log_workout" and data.get("ok"):
|
||||||
|
wo = data.get("workout", {})
|
||||||
|
return f"💪 **Тренировка** · {wo.get('title')}"
|
||||||
|
|
||||||
|
if tool_name == "set_fitness_profile" and data.get("ok"):
|
||||||
|
p = data.get("profile", {})
|
||||||
|
return (
|
||||||
|
f"💪 **Профиль** · {p.get('calorie_target')} ккал, "
|
||||||
|
f"вода {p.get('water_l')} л"
|
||||||
|
)
|
||||||
|
|
||||||
|
if tool_name == "set_fitness_reminder" and data.get("ok"):
|
||||||
|
r = data.get("reminder", {})
|
||||||
|
state = "вкл" if r.get("enabled") else "выкл"
|
||||||
|
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.chat.notices import (
|
|||||||
format_pomodoro_context,
|
format_pomodoro_context,
|
||||||
format_tool_notice,
|
format_tool_notice,
|
||||||
)
|
)
|
||||||
|
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||||
from app.memory.context import (
|
from app.memory.context import (
|
||||||
format_identity_hint,
|
format_identity_hint,
|
||||||
format_memory_context,
|
format_memory_context,
|
||||||
@@ -59,10 +60,12 @@ class ChatService:
|
|||||||
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
||||||
status = PomodoroService(self.db).get_status()
|
status = PomodoroService(self.db).get_status()
|
||||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||||
|
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||||
projects_snapshot = get_projects_snapshot(self.db)
|
projects_snapshot = get_projects_snapshot(self.db)
|
||||||
return (
|
return (
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
f"{self.character.get_system_prompt()}\n\n"
|
||||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||||
|
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
f"{format_pomodoro_context(status)}\n\n"
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
f"{format_projects_context(projects_snapshot)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
repos_dir: str = "/data/repos"
|
repos_dir: str = "/data/repos"
|
||||||
|
|
||||||
|
wger_base_url: str = "https://wger.de/api/v2"
|
||||||
|
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||||
|
fitness_reminders_enabled: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -135,6 +135,86 @@ class SessionSummary(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessProfile(Base):
|
||||||
|
__tablename__ = "fitness_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
sex: Mapped[str] = mapped_column(String(16), default="male")
|
||||||
|
age: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
height_cm: Mapped[float] = mapped_column(Float, default=170.0)
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float, default=70.0)
|
||||||
|
activity_level: Mapped[str] = mapped_column(String(32), default="moderate")
|
||||||
|
goal: Mapped[str] = mapped_column(String(32), default="maintain")
|
||||||
|
target_weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
weekly_workouts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
calorie_target: Mapped[float] = mapped_column(Float, default=2000.0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=140.0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=65.0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=200.0)
|
||||||
|
water_l: Mapped[float] = mapped_column(Float, default=2.5)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BodyMetric(Base):
|
||||||
|
__tablename__ = "body_metrics"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
weight_kg: Mapped[float] = mapped_column(Float)
|
||||||
|
body_fat_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
chest_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
waist_cm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class FoodLog(Base):
|
||||||
|
__tablename__ = "food_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
meal_type: Mapped[str] = mapped_column(String(32), default="snack")
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
calories: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
protein_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
fat_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
carbs_g: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="llm")
|
||||||
|
estimated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WaterLog(Base):
|
||||||
|
__tablename__ = "water_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
amount_ml: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkoutLog(Base):
|
||||||
|
__tablename__ = "workout_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
title: Mapped[str] = mapped_column(String(255), default="Тренировка")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
duration_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
exercises_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessReminder(Base):
|
||||||
|
__tablename__ = "fitness_reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
|
hour: Mapped[int] = mapped_column(Integer, default=12)
|
||||||
|
minute: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
interval_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class WorkItem(Base):
|
class WorkItem(Base):
|
||||||
__tablename__ = "work_items"
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ACTIVITY_MULTIPLIERS = {
|
||||||
|
"sedentary": 1.2,
|
||||||
|
"light": 1.375,
|
||||||
|
"moderate": 1.55,
|
||||||
|
"active": 1.725,
|
||||||
|
"very_active": 1.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
GOAL_CALORIE_ADJUST = {
|
||||||
|
"lose": -500,
|
||||||
|
"maintain": 0,
|
||||||
|
"gain": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
|
||||||
|
base = 10 * weight_kg + 6.25 * height_cm - 5 * age
|
||||||
|
if sex.lower() in ("m", "male", "м", "мужской"):
|
||||||
|
return base + 5
|
||||||
|
return base - 161
|
||||||
|
|
||||||
|
|
||||||
|
def tdee(
|
||||||
|
*,
|
||||||
|
sex: str,
|
||||||
|
weight_kg: float,
|
||||||
|
height_cm: float,
|
||||||
|
age: int,
|
||||||
|
activity_level: str = "moderate",
|
||||||
|
) -> float:
|
||||||
|
bmr = bmr_mifflin(sex=sex, weight_kg=weight_kg, height_cm=height_cm, age=age)
|
||||||
|
mult = ACTIVITY_MULTIPLIERS.get(activity_level, 1.55)
|
||||||
|
return bmr * mult
|
||||||
|
|
||||||
|
|
||||||
|
def bmi(weight_kg: float, height_cm: float) -> float:
|
||||||
|
if height_cm <= 0:
|
||||||
|
return 0.0
|
||||||
|
h = height_cm / 100
|
||||||
|
return weight_kg / (h * h)
|
||||||
|
|
||||||
|
|
||||||
|
def water_target_l(weight_kg: float) -> float:
|
||||||
|
return round(weight_kg * 0.033, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def macro_targets(
|
||||||
|
calorie_target: float,
|
||||||
|
weight_kg: float,
|
||||||
|
goal: str = "maintain",
|
||||||
|
) -> dict[str, float]:
|
||||||
|
protein_g = round(weight_kg * (2.0 if goal == "gain" else 1.8), 0)
|
||||||
|
fat_g = round((calorie_target * 0.25) / 9, 0)
|
||||||
|
protein_cal = protein_g * 4
|
||||||
|
fat_cal = fat_g * 9
|
||||||
|
carbs_g = max(0, round((calorie_target - protein_cal - fat_cal) / 4, 0))
|
||||||
|
return {"protein_g": protein_g, "fat_g": fat_g, "carbs_g": carbs_g}
|
||||||
|
|
||||||
|
|
||||||
|
def one_rep_max(weight_kg: float, reps: int) -> float:
|
||||||
|
if reps <= 0:
|
||||||
|
return weight_kg
|
||||||
|
if reps == 1:
|
||||||
|
return weight_kg
|
||||||
|
return round(weight_kg * (1 + reps / 30), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_targets(profile: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
weight = float(profile.get("weight_kg") or 70)
|
||||||
|
height = float(profile.get("height_cm") or 170)
|
||||||
|
age = int(profile.get("age") or 30)
|
||||||
|
sex = str(profile.get("sex") or "male")
|
||||||
|
activity = str(profile.get("activity_level") or "moderate")
|
||||||
|
goal = str(profile.get("goal") or "maintain")
|
||||||
|
|
||||||
|
tdee_val = tdee(
|
||||||
|
sex=sex, weight_kg=weight, height_cm=height, age=age, activity_level=activity
|
||||||
|
)
|
||||||
|
calorie_target = round(tdee_val + GOAL_CALORIE_ADJUST.get(goal, 0), 0)
|
||||||
|
macros = macro_targets(calorie_target, weight, goal)
|
||||||
|
water = water_target_l(weight)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bmr": round(bmr_mifflin(sex=sex, weight_kg=weight, height_cm=height, age=age), 0),
|
||||||
|
"tdee": round(tdee_val, 0),
|
||||||
|
"bmi": round(bmi(weight, height), 1),
|
||||||
|
"calorie_target": calorie_target,
|
||||||
|
"protein_g": macros["protein_g"],
|
||||||
|
"fat_g": macros["fat_g"],
|
||||||
|
"carbs_g": macros["carbs_g"],
|
||||||
|
"water_l": water,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
|
||||||
|
|
||||||
|
def get_fitness_snapshot(db: Session) -> dict[str, Any]:
|
||||||
|
return FitnessService(db).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def format_fitness_context(snapshot: dict[str, Any]) -> str:
|
||||||
|
lines = ["[Фитнес — сводка на сегодня]"]
|
||||||
|
|
||||||
|
profile = snapshot.get("profile")
|
||||||
|
if not profile:
|
||||||
|
lines.append("Профиль не настроен. set_fitness_profile для целей ккал/БЖУ/воды.")
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"Цели: {profile.get('calorie_target')} ккал, "
|
||||||
|
f"Б {profile.get('protein_g')} / Ж {profile.get('fat_g')} / У {profile.get('carbs_g')} г, "
|
||||||
|
f"вода {profile.get('water_l')} л"
|
||||||
|
)
|
||||||
|
if profile.get("goal"):
|
||||||
|
lines.append(
|
||||||
|
f"Цель: {profile.get('goal')}, вес {profile.get('weight_kg')} кг, "
|
||||||
|
f"рост {profile.get('height_cm')} см"
|
||||||
|
)
|
||||||
|
|
||||||
|
today = snapshot.get("today") or {}
|
||||||
|
totals = today.get("totals") or {}
|
||||||
|
targets = today.get("targets") or {}
|
||||||
|
water_l = totals.get("water_ml", 0) / 1000
|
||||||
|
water_target = targets.get("water_ml", 2500) / 1000
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · "
|
||||||
|
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · "
|
||||||
|
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · "
|
||||||
|
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г"
|
||||||
|
)
|
||||||
|
lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
|
||||||
|
|
||||||
|
workouts = today.get("workouts") or []
|
||||||
|
if workouts:
|
||||||
|
lines.append(f"Тренировок сегодня: {len(workouts)}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Правила: log_meal, log_water, log_weight, log_workout, get_fitness_summary, "
|
||||||
|
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
|
||||||
|
"Еда — оценка LLM (≈), пользователь может уточнить."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import ChatSession, FitnessReminder, Message
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
|
||||||
|
KIND_LABELS = {
|
||||||
|
"water": "Вода",
|
||||||
|
"meal": "Еда",
|
||||||
|
"workout": "Тренировка",
|
||||||
|
"weigh_in": "Взвешивание",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post_fitness_notice(content: str) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
session = db.scalar(
|
||||||
|
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
if not session:
|
||||||
|
session = ChatSession(title="Фитнес")
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_notice(kind: str, summary: dict) -> str:
|
||||||
|
label = KIND_LABELS.get(kind, kind)
|
||||||
|
totals = summary.get("totals") or {}
|
||||||
|
targets = summary.get("targets") or {}
|
||||||
|
water_l = totals.get("water_ml", 0) / 1000
|
||||||
|
water_target = targets.get("water_ml", 2500) / 1000
|
||||||
|
cals = totals.get("calories", 0)
|
||||||
|
cal_target = targets.get("calories", 2000)
|
||||||
|
|
||||||
|
if kind == "water":
|
||||||
|
return (
|
||||||
|
f"💪 **{label}** · выпито {water_l:.1f}/{water_target:.1f} л сегодня. "
|
||||||
|
"Пора выпить стакан воды."
|
||||||
|
)
|
||||||
|
if kind == "meal":
|
||||||
|
return (
|
||||||
|
f"💪 **{label}** · {cals:.0f}/{cal_target:.0f} ккал за день. "
|
||||||
|
"Не забудь залогировать приём пищи."
|
||||||
|
)
|
||||||
|
if kind == "workout":
|
||||||
|
workouts = summary.get("workouts") or []
|
||||||
|
if workouts:
|
||||||
|
return f"💪 **{label}** · сегодня уже была тренировка. Отдыхай или лёгкая активность."
|
||||||
|
return "💪 **Тренировка** · запланирована на сегодня. Время двигаться!"
|
||||||
|
if kind == "weigh_in":
|
||||||
|
return "💪 **Взвешивание** · пора записать вес (log_weight)."
|
||||||
|
return f"💪 **{label}** · напоминание"
|
||||||
|
|
||||||
|
|
||||||
|
def check_reminders(db: Session) -> list[str]:
|
||||||
|
if not get_settings().fitness_reminders_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
service = FitnessService(db)
|
||||||
|
summary = service.get_daily_summary()
|
||||||
|
fired: list[str] = []
|
||||||
|
|
||||||
|
reminders = db.scalars(
|
||||||
|
select(FitnessReminder).where(FitnessReminder.enabled.is_(True))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for rem in reminders:
|
||||||
|
should_fire = False
|
||||||
|
|
||||||
|
if rem.interval_hours:
|
||||||
|
if rem.last_fired_at is None:
|
||||||
|
should_fire = now.hour >= rem.hour
|
||||||
|
else:
|
||||||
|
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
|
||||||
|
should_fire = delta >= timedelta(hours=rem.interval_hours)
|
||||||
|
else:
|
||||||
|
if rem.kind == "weigh_in":
|
||||||
|
if rem.last_fired_at:
|
||||||
|
delta = now - rem.last_fired_at.replace(tzinfo=timezone.utc)
|
||||||
|
should_fire = delta >= timedelta(days=7)
|
||||||
|
else:
|
||||||
|
should_fire = now.hour == rem.hour and now.minute >= rem.minute
|
||||||
|
else:
|
||||||
|
if rem.last_fired_at:
|
||||||
|
last = rem.last_fired_at.replace(tzinfo=timezone.utc)
|
||||||
|
already_today = last.date() == now.date()
|
||||||
|
if already_today:
|
||||||
|
continue
|
||||||
|
should_fire = now.hour == rem.hour and now.minute >= rem.minute
|
||||||
|
|
||||||
|
if not should_fire:
|
||||||
|
continue
|
||||||
|
|
||||||
|
notice = _build_notice(rem.kind, summary)
|
||||||
|
rem.last_fired_at = now
|
||||||
|
fired.append(notice)
|
||||||
|
|
||||||
|
if fired:
|
||||||
|
db.commit()
|
||||||
|
for notice in fired:
|
||||||
|
_post_fitness_notice(notice)
|
||||||
|
|
||||||
|
return fired
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date, datetime, time, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import (
|
||||||
|
BodyMetric,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FoodLog,
|
||||||
|
WaterLog,
|
||||||
|
WorkoutLog,
|
||||||
|
)
|
||||||
|
from app.fitness.calculators import compute_targets, one_rep_max
|
||||||
|
|
||||||
|
DEFAULT_REMINDERS = [
|
||||||
|
{"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2},
|
||||||
|
{"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None},
|
||||||
|
{"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FitnessService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def _get_profile_row(self) -> FitnessProfile | None:
|
||||||
|
return self.db.scalar(select(FitnessProfile).limit(1))
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any] | None:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return self._profile_to_dict(row)
|
||||||
|
|
||||||
|
def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
"target_weight_kg": row.target_weight_kg,
|
||||||
|
"weekly_workouts": row.weekly_workouts,
|
||||||
|
"calorie_target": row.calorie_target,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"water_l": row.water_l,
|
||||||
|
"computed": targets,
|
||||||
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self._get_profile_row()
|
||||||
|
is_new = row is None
|
||||||
|
if is_new:
|
||||||
|
row = FitnessProfile()
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||||
|
"goal", "target_weight_kg", "weekly_workouts",
|
||||||
|
):
|
||||||
|
if key in updates and updates[key] is not None:
|
||||||
|
setattr(row, key, updates[key])
|
||||||
|
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": row.sex,
|
||||||
|
"age": row.age,
|
||||||
|
"height_cm": row.height_cm,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"activity_level": row.activity_level,
|
||||||
|
"goal": row.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row.calorie_target = targets["calorie_target"]
|
||||||
|
row.protein_g = targets["protein_g"]
|
||||||
|
row.fat_g = targets["fat_g"]
|
||||||
|
row.carbs_g = targets["carbs_g"]
|
||||||
|
row.water_l = targets["water_l"]
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
self._ensure_default_reminders()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "profile": self._profile_to_dict(row)}
|
||||||
|
|
||||||
|
def _ensure_default_reminders(self) -> None:
|
||||||
|
existing = self.db.scalars(select(FitnessReminder)).all()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for item in DEFAULT_REMINDERS:
|
||||||
|
self.db.add(FitnessReminder(**item))
|
||||||
|
|
||||||
|
def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return compute_targets(params)
|
||||||
|
|
||||||
|
def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
|
||||||
|
d = day or datetime.now(timezone.utc).date()
|
||||||
|
start = datetime.combine(d, time.min, tzinfo=timezone.utc)
|
||||||
|
end = datetime.combine(d, time.max, tzinfo=timezone.utc)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
|
||||||
|
start, end = self._day_bounds(day)
|
||||||
|
profile = self.get_profile()
|
||||||
|
|
||||||
|
foods = self.db.scalars(
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.logged_at >= start, FoodLog.logged_at <= end)
|
||||||
|
.order_by(FoodLog.logged_at)
|
||||||
|
).all()
|
||||||
|
waters = self.db.scalars(
|
||||||
|
select(WaterLog)
|
||||||
|
.where(WaterLog.logged_at >= start, WaterLog.logged_at <= end)
|
||||||
|
.order_by(WaterLog.logged_at)
|
||||||
|
).all()
|
||||||
|
workouts = self.db.scalars(
|
||||||
|
select(WorkoutLog)
|
||||||
|
.where(WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end)
|
||||||
|
.order_by(WorkoutLog.logged_at)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"calories": sum(f.calories for f in foods),
|
||||||
|
"protein_g": sum(f.protein_g for f in foods),
|
||||||
|
"fat_g": sum(f.fat_g for f in foods),
|
||||||
|
"carbs_g": sum(f.carbs_g for f in foods),
|
||||||
|
"water_ml": sum(w.amount_ml for w in waters),
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = profile or {
|
||||||
|
"calorie_target": 2000,
|
||||||
|
"protein_g": 140,
|
||||||
|
"fat_g": 65,
|
||||||
|
"carbs_g": 200,
|
||||||
|
"water_l": 2.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": (day or datetime.now(timezone.utc).date()).isoformat(),
|
||||||
|
"profile_configured": profile is not None,
|
||||||
|
"totals": totals,
|
||||||
|
"targets": {
|
||||||
|
"calories": targets.get("calorie_target", 2000),
|
||||||
|
"protein_g": targets.get("protein_g", 140),
|
||||||
|
"fat_g": targets.get("fat_g", 65),
|
||||||
|
"carbs_g": targets.get("carbs_g", 200),
|
||||||
|
"water_ml": targets.get("water_l", 2.5) * 1000,
|
||||||
|
},
|
||||||
|
"meals": [self._food_to_dict(f) for f in foods],
|
||||||
|
"water": [self._water_to_dict(w) for w in waters],
|
||||||
|
"workouts": [self._workout_to_dict(w) for w in workouts],
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_meal(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
meal_type: str = "snack",
|
||||||
|
calories: float = 0,
|
||||||
|
protein_g: float = 0,
|
||||||
|
fat_g: float = 0,
|
||||||
|
carbs_g: float = 0,
|
||||||
|
source: str = "llm",
|
||||||
|
estimated: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = FoodLog(
|
||||||
|
meal_type=meal_type[:32],
|
||||||
|
description=description[:2000],
|
||||||
|
calories=calories,
|
||||||
|
protein_g=protein_g,
|
||||||
|
fat_g=fat_g,
|
||||||
|
carbs_g=carbs_g,
|
||||||
|
source=source[:32],
|
||||||
|
estimated=estimated,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "meal": self._food_to_dict(row)}
|
||||||
|
|
||||||
|
def log_water(self, amount_ml: int) -> dict[str, Any]:
|
||||||
|
row = WaterLog(amount_ml=max(0, amount_ml))
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "water": self._water_to_dict(row)}
|
||||||
|
|
||||||
|
def log_weight(
|
||||||
|
self,
|
||||||
|
weight_kg: float,
|
||||||
|
*,
|
||||||
|
body_fat_pct: float | None = None,
|
||||||
|
chest_cm: float | None = None,
|
||||||
|
waist_cm: float | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = BodyMetric(
|
||||||
|
weight_kg=weight_kg,
|
||||||
|
body_fat_pct=body_fat_pct,
|
||||||
|
chest_cm=chest_cm,
|
||||||
|
waist_cm=waist_cm,
|
||||||
|
notes=notes[:1000],
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
profile = self._get_profile_row()
|
||||||
|
if profile:
|
||||||
|
profile.weight_kg = weight_kg
|
||||||
|
targets = compute_targets(
|
||||||
|
{
|
||||||
|
"sex": profile.sex,
|
||||||
|
"age": profile.age,
|
||||||
|
"height_cm": profile.height_cm,
|
||||||
|
"weight_kg": weight_kg,
|
||||||
|
"activity_level": profile.activity_level,
|
||||||
|
"goal": profile.goal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile.calorie_target = targets["calorie_target"]
|
||||||
|
profile.protein_g = targets["protein_g"]
|
||||||
|
profile.fat_g = targets["fat_g"]
|
||||||
|
profile.carbs_g = targets["carbs_g"]
|
||||||
|
profile.water_l = targets["water_l"]
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"metric": {
|
||||||
|
"id": row.id,
|
||||||
|
"weight_kg": row.weight_kg,
|
||||||
|
"recorded_at": row.recorded_at.isoformat() if row.recorded_at else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_workout(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
notes: str = "",
|
||||||
|
duration_min: int | None = None,
|
||||||
|
exercises: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = WorkoutLog(
|
||||||
|
title=title[:255],
|
||||||
|
notes=notes[:2000],
|
||||||
|
duration_min=duration_min,
|
||||||
|
exercises_json=json.dumps(exercises or [], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "workout": self._workout_to_dict(row)}
|
||||||
|
|
||||||
|
def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(
|
||||||
|
select(BodyMetric).order_by(BodyMetric.recorded_at.desc()).limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"weight_kg": r.weight_kg,
|
||||||
|
"body_fat_pct": r.body_fat_pct,
|
||||||
|
"chest_cm": r.chest_cm,
|
||||||
|
"waist_cm": r.waist_cm,
|
||||||
|
"notes": r.notes,
|
||||||
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_food_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(FoodLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_water_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WaterLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_workout_log(self, log_id: int) -> bool:
|
||||||
|
row = self.db.get(WorkoutLog, log_id)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_reminders(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self.db.scalars(select(FitnessReminder).order_by(FitnessReminder.kind)).all()
|
||||||
|
return [self._reminder_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
def set_reminder(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
hour: int | None = None,
|
||||||
|
minute: int | None = None,
|
||||||
|
interval_hours: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(
|
||||||
|
select(FitnessReminder).where(FitnessReminder.kind == kind)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
row = FitnessReminder(kind=kind)
|
||||||
|
self.db.add(row)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if hour is not None:
|
||||||
|
row.hour = hour
|
||||||
|
if minute is not None:
|
||||||
|
row.minute = minute
|
||||||
|
if interval_hours is not None:
|
||||||
|
row.interval_hours = interval_hours
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "reminder": self._reminder_to_dict(row)}
|
||||||
|
|
||||||
|
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 snapshot(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"today": self.get_daily_summary(),
|
||||||
|
"body_metrics": self.list_body_metrics(limit=10),
|
||||||
|
"reminders": self.list_reminders(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"meal_type": row.meal_type,
|
||||||
|
"description": row.description,
|
||||||
|
"calories": row.calories,
|
||||||
|
"protein_g": row.protein_g,
|
||||||
|
"fat_g": row.fat_g,
|
||||||
|
"carbs_g": row.carbs_g,
|
||||||
|
"source": row.source,
|
||||||
|
"estimated": row.estimated,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _water_to_dict(row: WaterLog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"amount_ml": row.amount_ml,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
exercises = json.loads(row.exercises_json or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
exercises = []
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"notes": row.notes,
|
||||||
|
"duration_min": row.duration_min,
|
||||||
|
"exercises": exercises,
|
||||||
|
"logged_at": row.logged_at.isoformat() if row.logged_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"kind": row.kind,
|
||||||
|
"hour": row.hour,
|
||||||
|
"minute": row.minute,
|
||||||
|
"interval_hours": row.interval_hours,
|
||||||
|
"enabled": row.enabled,
|
||||||
|
"last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.llm.client import LLMClient
|
||||||
|
from app.projects.structuring import strip_markdown_json
|
||||||
|
|
||||||
|
MEAL_PROMPT = """
|
||||||
|
Преобразуй описание еды в JSON. Только JSON, без markdown.
|
||||||
|
Схема:
|
||||||
|
{
|
||||||
|
"meal_type": "breakfast|lunch|dinner|snack",
|
||||||
|
"description": "краткое описание",
|
||||||
|
"calories": 0,
|
||||||
|
"protein_g": 0,
|
||||||
|
"fat_g": 0,
|
||||||
|
"carbs_g": 0,
|
||||||
|
"estimated": true
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- Оцени ккал и БЖУ по типичным значениям для России/СНГ.
|
||||||
|
- Все числа — float/int, метрическая система (г, ккал).
|
||||||
|
- meal_type угадай из контекста или snack.
|
||||||
|
- estimated всегда true для LLM-оценки.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
WORKOUT_PROMPT = """
|
||||||
|
Преобразуй описание тренировки в JSON. Только JSON.
|
||||||
|
Схема:
|
||||||
|
{
|
||||||
|
"title": "название",
|
||||||
|
"duration_min": null,
|
||||||
|
"notes": "",
|
||||||
|
"exercises": [
|
||||||
|
{"name": "жим лёжа", "sets": 3, "reps": 8, "weight_kg": 80}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Правила:
|
||||||
|
- weight_kg в кг, метрическая система.
|
||||||
|
- Если данных нет — null или пустой массив.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def structure_meal(raw_text: str) -> dict[str, Any]:
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": MEAL_PROMPT},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def structure_workout(raw_text: str) -> dict[str, Any]:
|
||||||
|
llm = LLMClient()
|
||||||
|
result = await llm.complete(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": WORKOUT_PROMPT},
|
||||||
|
{"role": "user", "content": raw_text},
|
||||||
|
],
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
raw = strip_markdown_json(result.get("content") or "")
|
||||||
|
return json.loads(raw)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.fitness.reminders import check_reminders
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATCH_INTERVAL_SEC = 60
|
||||||
|
|
||||||
|
|
||||||
|
async def fitness_watcher_loop() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||||
|
await _tick()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Fitness watcher error")
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
check_reminders(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class OpenFoodFactsClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.openfoodfacts_base_url.rstrip("/")
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
response = client.get(
|
||||||
|
f"{self.base_url}/cgi/search.pl",
|
||||||
|
params={
|
||||||
|
"search_terms": query,
|
||||||
|
"search_simple": 1,
|
||||||
|
"action": "process",
|
||||||
|
"json": 1,
|
||||||
|
"page_size": limit,
|
||||||
|
"lc": "ru",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
products = response.json().get("products") or []
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for p in products[:limit]:
|
||||||
|
nutriments = p.get("nutriments") or {}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"name": p.get("product_name") or p.get("product_name_ru") or query,
|
||||||
|
"brand": p.get("brands", ""),
|
||||||
|
"barcode": p.get("code"),
|
||||||
|
"calories_per_100g": nutriments.get("energy-kcal_100g"),
|
||||||
|
"protein_g_per_100g": nutriments.get("proteins_100g"),
|
||||||
|
"fat_g_per_100g": nutriments.get("fat_100g"),
|
||||||
|
"carbs_g_per_100g": nutriments.get("carbohydrates_100g"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_by_barcode(self, barcode: str) -> dict[str, Any] | None:
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
response = client.get(f"{self.base_url}/api/v2/product/{barcode}.json")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
product = response.json().get("product")
|
||||||
|
if not product:
|
||||||
|
return None
|
||||||
|
nutriments = product.get("nutriments") or {}
|
||||||
|
return {
|
||||||
|
"name": product.get("product_name") or product.get("product_name_ru"),
|
||||||
|
"barcode": barcode,
|
||||||
|
"calories_per_100g": nutriments.get("energy-kcal_100g"),
|
||||||
|
"protein_g_per_100g": nutriments.get("proteins_100g"),
|
||||||
|
"fat_g_per_100g": nutriments.get("fat_100g"),
|
||||||
|
"carbs_g_per_100g": nutriments.get("carbohydrates_100g"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class WgerClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.wger_base_url.rstrip("/")
|
||||||
|
|
||||||
|
def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
response = client.get(
|
||||||
|
f"{self.base_url}/exercise/search/",
|
||||||
|
params={"term": query, "language": "ru"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
sug = data.get("suggestions", data) if isinstance(data, dict) else []
|
||||||
|
if isinstance(sug, dict):
|
||||||
|
results = sug.get("results", [])
|
||||||
|
elif isinstance(sug, list):
|
||||||
|
results = sug
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for item in results[:limit]:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
name = item.get("value") or item.get("name") or str(item)
|
||||||
|
out.append({"name": name, "data": item})
|
||||||
|
elif isinstance(item, str):
|
||||||
|
out.append({"name": item})
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
|
||||||
|
response2 = client.get(
|
||||||
|
f"{self.base_url}/exerciseinfo/",
|
||||||
|
params={"language": 2, "limit": limit},
|
||||||
|
)
|
||||||
|
response2.raise_for_status()
|
||||||
|
for item in (response2.json().get("results") or [])[:limit]:
|
||||||
|
name = item.get("name") or f"#{item.get('id')}"
|
||||||
|
if query.lower() in name.lower():
|
||||||
|
out.append({"id": item.get("id"), "name": name, "category": item.get("category")})
|
||||||
|
return out[:limit]
|
||||||
+8
-3
@@ -7,17 +7,22 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.base import init_db
|
from app.db.base import init_db
|
||||||
|
from app.fitness.watcher import fitness_watcher_loop
|
||||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
watcher_task = asyncio.create_task(pomodoro_watcher_loop())
|
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||||
|
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||||
yield
|
yield
|
||||||
watcher_task.cancel()
|
pomodoro_task.cancel()
|
||||||
|
fitness_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await watcher_task
|
await pomodoro_task
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await fitness_task
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.fitness.service import FitnessService
|
||||||
|
from app.fitness.structuring import structure_meal, structure_workout
|
||||||
|
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||||
|
from app.integrations.wger import WgerClient
|
||||||
from app.memory.service import MemoryService
|
from app.memory.service import MemoryService
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
@@ -268,6 +272,164 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_fitness_summary",
|
||||||
|
"description": "Сводка фитнеса за сегодня: ккал, БЖУ, вода, тренировки.",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "set_fitness_profile",
|
||||||
|
"description": "Настроить фитнес-профиль и пересчитать цели ккал/БЖУ/воды.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sex": {"type": "string", "description": "male/female"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"height_cm": {"type": "number"},
|
||||||
|
"weight_kg": {"type": "number"},
|
||||||
|
"activity_level": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "sedentary/light/moderate/active/very_active",
|
||||||
|
},
|
||||||
|
"goal": {"type": "string", "description": "lose/maintain/gain"},
|
||||||
|
"target_weight_kg": {"type": "number"},
|
||||||
|
"weekly_workouts": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "calc_fitness_targets",
|
||||||
|
"description": "Калькулятор BMR/TDEE/макросов без сохранения.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sex": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"height_cm": {"type": "number"},
|
||||||
|
"weight_kg": {"type": "number"},
|
||||||
|
"activity_level": {"type": "string"},
|
||||||
|
"goal": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["weight_kg", "height_cm", "age"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "log_meal",
|
||||||
|
"description": "Записать приём пищи. LLM оценит ккал и БЖУ из текста.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string", "description": "Что съел"},
|
||||||
|
"meal_type": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "log_water",
|
||||||
|
"description": "Записать воду в мл.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"amount_ml": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["amount_ml"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "log_weight",
|
||||||
|
"description": "Записать вес в кг.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"weight_kg": {"type": "number"},
|
||||||
|
"body_fat_pct": {"type": "number"},
|
||||||
|
"notes": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["weight_kg"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "log_workout",
|
||||||
|
"description": "Записать тренировку из текста.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "lookup_food",
|
||||||
|
"description": "Поиск продукта в Open Food Facts (ккал на 100г).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "lookup_exercise",
|
||||||
|
"description": "Поиск упражнения в базе wger.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "set_fitness_reminder",
|
||||||
|
"description": "Вкл/выкл или настроить напоминание: water, meal, workout, weigh_in.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"type": "string"},
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"hour": {"type": "integer"},
|
||||||
|
"minute": {"type": "integer"},
|
||||||
|
"interval_hours": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["kind"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -299,6 +461,7 @@ async def execute_tool(
|
|||||||
pomodoro = PomodoroService(db)
|
pomodoro = PomodoroService(db)
|
||||||
projects = ProjectService(db)
|
projects = ProjectService(db)
|
||||||
memory = MemoryService(db)
|
memory = MemoryService(db)
|
||||||
|
fitness = FitnessService(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name == "get_pomodoro_status":
|
if name == "get_pomodoro_status":
|
||||||
@@ -370,6 +533,66 @@ async def execute_tool(
|
|||||||
int(arguments["session_id"]),
|
int(arguments["session_id"]),
|
||||||
arguments.get("summary", ""),
|
arguments.get("summary", ""),
|
||||||
)
|
)
|
||||||
|
elif name == "get_fitness_summary":
|
||||||
|
result = fitness.get_daily_summary()
|
||||||
|
elif name == "set_fitness_profile":
|
||||||
|
updates = {
|
||||||
|
k: arguments[k]
|
||||||
|
for k in (
|
||||||
|
"sex", "age", "height_cm", "weight_kg", "activity_level",
|
||||||
|
"goal", "target_weight_kg", "weekly_workouts",
|
||||||
|
)
|
||||||
|
if k in arguments and arguments[k] is not None
|
||||||
|
}
|
||||||
|
result = fitness.set_profile(updates)
|
||||||
|
elif name == "calc_fitness_targets":
|
||||||
|
result = fitness.calc_targets(arguments)
|
||||||
|
elif name == "log_meal":
|
||||||
|
structured = await structure_meal(arguments.get("text", ""))
|
||||||
|
result = fitness.log_meal(
|
||||||
|
description=structured.get("description") or arguments.get("text", ""),
|
||||||
|
meal_type=arguments.get("meal_type") or structured.get("meal_type") or "snack",
|
||||||
|
calories=float(structured.get("calories") or 0),
|
||||||
|
protein_g=float(structured.get("protein_g") or 0),
|
||||||
|
fat_g=float(structured.get("fat_g") or 0),
|
||||||
|
carbs_g=float(structured.get("carbs_g") or 0),
|
||||||
|
source="llm",
|
||||||
|
estimated=True,
|
||||||
|
)
|
||||||
|
elif name == "log_water":
|
||||||
|
result = fitness.log_water(int(arguments.get("amount_ml", 250)))
|
||||||
|
elif name == "log_weight":
|
||||||
|
result = fitness.log_weight(
|
||||||
|
float(arguments["weight_kg"]),
|
||||||
|
body_fat_pct=arguments.get("body_fat_pct"),
|
||||||
|
notes=arguments.get("notes", ""),
|
||||||
|
)
|
||||||
|
elif name == "log_workout":
|
||||||
|
structured = await structure_workout(arguments.get("text", ""))
|
||||||
|
result = fitness.log_workout(
|
||||||
|
title=structured.get("title") or "Тренировка",
|
||||||
|
notes=structured.get("notes") or arguments.get("text", ""),
|
||||||
|
duration_min=structured.get("duration_min"),
|
||||||
|
exercises=structured.get("exercises"),
|
||||||
|
)
|
||||||
|
elif name == "lookup_food":
|
||||||
|
result = OpenFoodFactsClient().search(
|
||||||
|
arguments.get("query", ""),
|
||||||
|
limit=arguments.get("limit", 5),
|
||||||
|
)
|
||||||
|
elif name == "lookup_exercise":
|
||||||
|
result = WgerClient().search_exercises(
|
||||||
|
arguments.get("query", ""),
|
||||||
|
limit=arguments.get("limit", 8),
|
||||||
|
)
|
||||||
|
elif name == "set_fitness_reminder":
|
||||||
|
result = fitness.set_reminder(
|
||||||
|
arguments.get("kind", "water"),
|
||||||
|
enabled=arguments.get("enabled"),
|
||||||
|
hour=arguments.get("hour"),
|
||||||
|
minute=arguments.get("minute"),
|
||||||
|
interval_hours=arguments.get("interval_hours"),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PomodoroWidget from "./components/PomodoroWidget";
|
|||||||
import { PomodoroProvider } from "./context/PomodoroContext";
|
import { PomodoroProvider } from "./context/PomodoroContext";
|
||||||
import Character from "./pages/Character";
|
import Character from "./pages/Character";
|
||||||
import Chat from "./pages/Chat";
|
import Chat from "./pages/Chat";
|
||||||
|
import Fitness from "./pages/Fitness";
|
||||||
import Memory from "./pages/Memory";
|
import Memory from "./pages/Memory";
|
||||||
import Pomodoro from "./pages/Pomodoro";
|
import Pomodoro from "./pages/Pomodoro";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -20,6 +21,7 @@ export default function App() {
|
|||||||
<NavLink to="/pomodoro">Помидоро</NavLink>
|
<NavLink to="/pomodoro">Помидоро</NavLink>
|
||||||
<NavLink to="/character">Персонаж</NavLink>
|
<NavLink to="/character">Персонаж</NavLink>
|
||||||
<NavLink to="/memory">Память</NavLink>
|
<NavLink to="/memory">Память</NavLink>
|
||||||
|
<NavLink to="/fitness">Фитнес</NavLink>
|
||||||
<PomodoroWidget compact />
|
<PomodoroWidget compact />
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -29,6 +31,7 @@ export default function App() {
|
|||||||
<Route path="/pomodoro" element={<Pomodoro />} />
|
<Route path="/pomodoro" element={<Pomodoro />} />
|
||||||
<Route path="/character" element={<Character />} />
|
<Route path="/character" element={<Character />} />
|
||||||
<Route path="/memory" element={<Memory />} />
|
<Route path="/memory" element={<Memory />} />
|
||||||
|
<Route path="/fitness" element={<Fitness />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,6 +81,99 @@ export interface MemoryFact {
|
|||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FitnessComputed {
|
||||||
|
bmr: number;
|
||||||
|
tdee: number;
|
||||||
|
bmi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessProfile {
|
||||||
|
sex?: string;
|
||||||
|
age?: number;
|
||||||
|
height_cm?: number;
|
||||||
|
weight_kg?: number;
|
||||||
|
activity_level?: string;
|
||||||
|
goal?: string;
|
||||||
|
target_weight_kg?: number | null;
|
||||||
|
weekly_workouts?: number;
|
||||||
|
calorie_target?: number;
|
||||||
|
protein_g?: number;
|
||||||
|
fat_g?: number;
|
||||||
|
carbs_g?: number;
|
||||||
|
water_l?: number;
|
||||||
|
computed?: FitnessComputed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodLogItem {
|
||||||
|
id: number;
|
||||||
|
meal_type: string;
|
||||||
|
description: string;
|
||||||
|
calories: number;
|
||||||
|
protein_g: number;
|
||||||
|
fat_g: number;
|
||||||
|
carbs_g: number;
|
||||||
|
estimated: boolean;
|
||||||
|
logged_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterLogItem {
|
||||||
|
id: number;
|
||||||
|
amount_ml: number;
|
||||||
|
logged_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutLogItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
duration_min?: number | null;
|
||||||
|
exercises?: unknown[];
|
||||||
|
logged_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessDailySummary {
|
||||||
|
date: string;
|
||||||
|
totals: {
|
||||||
|
calories: number;
|
||||||
|
protein_g: number;
|
||||||
|
fat_g: number;
|
||||||
|
carbs_g: number;
|
||||||
|
water_ml: number;
|
||||||
|
};
|
||||||
|
targets: {
|
||||||
|
calories: number;
|
||||||
|
protein_g: number;
|
||||||
|
fat_g: number;
|
||||||
|
carbs_g: number;
|
||||||
|
water_ml: number;
|
||||||
|
};
|
||||||
|
meals: FoodLogItem[];
|
||||||
|
water: WaterLogItem[];
|
||||||
|
workouts: WorkoutLogItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyMetric {
|
||||||
|
id: number;
|
||||||
|
weight_kg: number;
|
||||||
|
recorded_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessReminder {
|
||||||
|
id: number;
|
||||||
|
kind: string;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
interval_hours?: number | null;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessSnapshot {
|
||||||
|
profile: FitnessProfile | null;
|
||||||
|
today: FitnessDailySummary;
|
||||||
|
body_metrics: BodyMetric[];
|
||||||
|
reminders: FitnessReminder[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemorySnapshot {
|
export interface MemorySnapshot {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
facts: MemoryFact[];
|
facts: MemoryFact[];
|
||||||
@@ -245,4 +338,29 @@ export const api = {
|
|||||||
|
|
||||||
forgetMemoryFact: (id: number) =>
|
forgetMemoryFact: (id: number) =>
|
||||||
request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }),
|
request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
getFitnessSnapshot: () => request<FitnessSnapshot>("/api/v1/fitness"),
|
||||||
|
|
||||||
|
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
|
||||||
|
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteFitnessMeal: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
deleteFitnessWater: (id: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/fitness/water/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
updateFitnessReminder: (
|
||||||
|
kind: string,
|
||||||
|
updates: { enabled?: boolean; hour?: number; minute?: number; interval_hours?: number }
|
||||||
|
) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/fitness/reminders/${kind}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function noticeLabel(content: string): string {
|
|||||||
if (content.startsWith("📋")) return "задачи";
|
if (content.startsWith("📋")) return "задачи";
|
||||||
if (content.startsWith("🔀")) return "git";
|
if (content.startsWith("🔀")) return "git";
|
||||||
if (content.startsWith("🧠")) return "память";
|
if (content.startsWith("🧠")) return "память";
|
||||||
|
if (content.startsWith("💪")) return "фитнес";
|
||||||
return "система";
|
return "система";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
.fitness-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-header h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8b95a8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-message {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: #1a2433;
|
||||||
|
border: 1px solid #2a3f5a;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-section {
|
||||||
|
background: #151922;
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-section h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-section h4 {
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-progress-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-progress-track {
|
||||||
|
height: 8px;
|
||||||
|
background: #0f1218;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3d7a5a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-profile-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-profile-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-profile-form input,
|
||||||
|
.fitness-profile-form select {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
background: #0f1218;
|
||||||
|
color: #e8ecf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-profile-form button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-computed {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #8b95a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-log-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-log-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid #1e2430;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-log-list button {
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-table th,
|
||||||
|
.fitness-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #1e2430;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-reminders {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-reminders li {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-empty {
|
||||||
|
color: #8b95a8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitness-raw {
|
||||||
|
background: #0f1218;
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
FitnessProfile,
|
||||||
|
FitnessReminder,
|
||||||
|
FitnessSnapshot,
|
||||||
|
} from "../api/client";
|
||||||
|
import "./Fitness.css";
|
||||||
|
|
||||||
|
function ProgressBar({ label, current, target, unit }: {
|
||||||
|
label: string;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
unit: string;
|
||||||
|
}) {
|
||||||
|
const pct = target > 0 ? Math.min(100, (current / target) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="fitness-progress">
|
||||||
|
<div className="fitness-progress-header">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>
|
||||||
|
{current.toFixed(0)}/{target.toFixed(0)} {unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fitness-progress-track">
|
||||||
|
<div className="fitness-progress-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Fitness() {
|
||||||
|
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
|
||||||
|
const [profile, setProfile] = useState<Partial<FitnessProfile>>({});
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getFitnessSnapshot();
|
||||||
|
setSnapshot(data);
|
||||||
|
if (data.profile) setProfile(data.profile);
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(console.error);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleProfileSave = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api.updateFitnessProfile(profile);
|
||||||
|
setMessage("Профиль сохранён");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleReminder = async (rem: FitnessReminder) => {
|
||||||
|
try {
|
||||||
|
await api.updateFitnessReminder(rem.kind, { enabled: !rem.enabled });
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteMeal = async (id: number) => {
|
||||||
|
await api.deleteFitnessMeal(id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWater = async (id: number) => {
|
||||||
|
await api.deleteFitnessWater(id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = snapshot?.today;
|
||||||
|
const totals = today?.totals;
|
||||||
|
const targets = today?.targets;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fitness-page">
|
||||||
|
<header className="fitness-header">
|
||||||
|
<div>
|
||||||
|
<h2>Фитнес</h2>
|
||||||
|
<p>Дневник, цели, напоминания</p>
|
||||||
|
</div>
|
||||||
|
<div className="fitness-header-actions">
|
||||||
|
<button type="button" onClick={() => load()} disabled={loading}>
|
||||||
|
{loading ? "…" : "Обновить"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowRaw((v) => !v)}>
|
||||||
|
{showRaw ? "UI" : "JSON"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{message && <div className="fitness-message">{message}</div>}
|
||||||
|
|
||||||
|
{showRaw ? (
|
||||||
|
<pre className="fitness-raw">{JSON.stringify(snapshot, null, 2)}</pre>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="fitness-section">
|
||||||
|
<h3>Сегодня</h3>
|
||||||
|
{totals && targets ? (
|
||||||
|
<div className="fitness-progress-grid">
|
||||||
|
<ProgressBar
|
||||||
|
label="Калории"
|
||||||
|
current={totals.calories}
|
||||||
|
target={targets.calories}
|
||||||
|
unit="ккал"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Белок"
|
||||||
|
current={totals.protein_g}
|
||||||
|
target={targets.protein_g}
|
||||||
|
unit="г"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Жиры"
|
||||||
|
current={totals.fat_g}
|
||||||
|
target={targets.fat_g}
|
||||||
|
unit="г"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Углеводы"
|
||||||
|
current={totals.carbs_g}
|
||||||
|
target={targets.carbs_g}
|
||||||
|
unit="г"
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
label="Вода"
|
||||||
|
current={totals.water_ml / 1000}
|
||||||
|
target={targets.water_ml / 1000}
|
||||||
|
unit="л"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="fitness-empty">Нет данных за сегодня</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fitness-section">
|
||||||
|
<h3>Профиль и цели</h3>
|
||||||
|
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
|
||||||
|
<label>
|
||||||
|
<span>пол</span>
|
||||||
|
<select
|
||||||
|
value={profile.sex ?? "male"}
|
||||||
|
onChange={(e) => setProfile((p) => ({ ...p, sex: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="male">male</option>
|
||||||
|
<option value="female">female</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>возраст</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={profile.age ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => ({ ...p, age: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>рост см</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={profile.height_cm ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => ({ ...p, height_cm: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>вес кг</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={profile.weight_kg ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => ({ ...p, weight_kg: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>активность</span>
|
||||||
|
<select
|
||||||
|
value={profile.activity_level ?? "moderate"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => ({ ...p, activity_level: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="sedentary">sedentary</option>
|
||||||
|
<option value="light">light</option>
|
||||||
|
<option value="moderate">moderate</option>
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="very_active">very_active</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>цель</span>
|
||||||
|
<select
|
||||||
|
value={profile.goal ?? "maintain"}
|
||||||
|
onChange={(e) => setProfile((p) => ({ ...p, goal: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="lose">lose</option>
|
||||||
|
<option value="maintain">maintain</option>
|
||||||
|
<option value="gain">gain</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Сохранить и пересчитать TDEE</button>
|
||||||
|
</form>
|
||||||
|
{profile.computed && (
|
||||||
|
<p className="fitness-computed">
|
||||||
|
BMR {profile.computed.bmr} · TDEE {profile.computed.tdee} · BMI{" "}
|
||||||
|
{profile.computed.bmi}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fitness-section">
|
||||||
|
<h3>Логи за сегодня</h3>
|
||||||
|
<h4>Еда</h4>
|
||||||
|
<ul className="fitness-log-list">
|
||||||
|
{(today?.meals ?? []).map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
{m.estimated ? "≈" : ""}
|
||||||
|
{m.description} — {m.calories} ккал
|
||||||
|
<button type="button" onClick={() => handleDeleteMeal(m.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h4>Вода</h4>
|
||||||
|
<ul className="fitness-log-list">
|
||||||
|
{(today?.water ?? []).map((w) => (
|
||||||
|
<li key={w.id}>
|
||||||
|
+{w.amount_ml} мл
|
||||||
|
<button type="button" onClick={() => handleDeleteWater(w.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<h4>Тренировки</h4>
|
||||||
|
<ul className="fitness-log-list">
|
||||||
|
{(today?.workouts ?? []).map((w) => (
|
||||||
|
<li key={w.id}>{w.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fitness-section">
|
||||||
|
<h3>История веса</h3>
|
||||||
|
<table className="fitness-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>кг</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(snapshot?.body_metrics ?? []).map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.recorded_at?.slice(0, 10)}</td>
|
||||||
|
<td>{m.weight_kg}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fitness-section">
|
||||||
|
<h3>Напоминания</h3>
|
||||||
|
<ul className="fitness-reminders">
|
||||||
|
{(snapshot?.reminders ?? []).map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<span>{r.kind}</span>
|
||||||
|
<span>
|
||||||
|
{r.interval_hours
|
||||||
|
? `каждые ${r.interval_hours}ч`
|
||||||
|
: `${String(r.hour).padStart(2, "0")}:${String(r.minute).padStart(2, "0")}`}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => handleToggleReminder(r)}>
|
||||||
|
{r.enabled ? "вкл" : "выкл"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user