This commit is contained in:
xds
2026-03-16 14:46:20 +03:00
parent 00db55720c
commit de8c2472e2
45 changed files with 3714 additions and 140 deletions

View File

@@ -8,7 +8,8 @@
"WebFetch(domain:primevue.org)",
"WebFetch(domain:core.telegram.org)",
"WebFetch(domain:docs.telegram-mini-apps.com)",
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(find:*)"
]
}
}

View File

@@ -0,0 +1,51 @@
"""add coaching tables and rider coaching fields
Revision ID: 4c6a3c01542f
Revises: 928b78044640
Create Date: 2026-03-16 14:26:46.753457
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4c6a3c01542f'
down_revision: Union[str, None] = '928b78044640'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('coaching_chats',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('rider_id', sa.UUID(), nullable=False),
sa.Column('chat_type', sa.String(length=50), nullable=False),
sa.Column('messages_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('context_snapshot', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['rider_id'], ['riders.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('riders', sa.Column('coaching_profile', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('riders', sa.Column('onboarding_completed', sa.Boolean(), server_default=sa.text('false'), nullable=False))
op.add_column('training_plans', sa.Column('status', sa.String(length=20), server_default=sa.text("'active'"), nullable=False))
op.add_column('training_plans', sa.Column('onboarding_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('training_plans', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('training_plans', 'updated_at')
op.drop_column('training_plans', 'onboarding_data')
op.drop_column('training_plans', 'status')
op.drop_column('riders', 'onboarding_completed')
op.drop_column('riders', 'coaching_profile')
op.drop_table('coaching_chats')
# ### end Alembic commands ###

View File

@@ -5,24 +5,34 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.config import settings
from backend.app.core.database import get_session
from backend.app.models.activity import Activity, DataPoint
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval
from backend.app.models.fitness import PowerCurve
from backend.app.models.rider import Rider
from backend.app.schemas.activity import (
ActivityResponse,
ActivityListResponse,
DataPointResponse,
ZonesResponse,
PowerCurveResponse,
)
from backend.app.models.fitness import DiaryEntry
from backend.app.services.fit_parser import parse_fit_file
from backend.app.services.metrics import calculate_metrics
from backend.app.services.zones import calculate_power_zones, calculate_hr_zones
from backend.app.services.power_curve import calculate_power_curve
from backend.app.services.intervals import detect_intervals
from backend.app.services.ai_summary import generate_summary
router = APIRouter()
@router.post("/upload", response_model=ActivityResponse)
async def upload_activity(
rider_id: uuid.UUID,
file: UploadFile = File(...),
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
if not file.filename or not file.filename.lower().endswith(".fit"):
@@ -37,18 +47,33 @@ async def upload_activity(
content = await file.read()
file_path.write_bytes(content)
activity, data_points = parse_fit_file(content, rider_id, str(file_path))
# 1. Parse FIT
activity, data_points = parse_fit_file(content, rider.id, str(file_path))
session.add(activity)
await session.flush()
# 2. Save data points
for dp in data_points:
dp.activity_id = activity.id
session.add_all(data_points)
metrics = calculate_metrics(data_points, activity, rider_id, session)
# 3. Calculate & save metrics (with FTP if available)
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
if metrics:
session.add(metrics)
# 4. Detect & save intervals
intervals = detect_intervals(data_points, ftp=rider.ftp)
for interval in intervals:
interval.activity_id = activity.id
session.add_all(intervals)
# 5. Calculate & save power curve
curve_data = calculate_power_curve(data_points)
if curve_data:
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
session.add(pc)
await session.commit()
await session.refresh(activity)
return activity
@@ -56,17 +81,17 @@ async def upload_activity(
@router.get("", response_model=ActivityListResponse)
async def list_activities(
rider_id: uuid.UUID,
limit: int = 20,
offset: int = 0,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider_id)
count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider.id)
total = (await session.execute(count_query)).scalar() or 0
query = (
select(Activity)
.where(Activity.rider_id == rider_id)
.where(Activity.rider_id == rider.id)
.order_by(Activity.date.desc())
.limit(limit)
.offset(offset)
@@ -80,10 +105,11 @@ async def list_activities(
@router.get("/{activity_id}", response_model=ActivityResponse)
async def get_activity(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity:
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@@ -91,8 +117,14 @@ async def get_activity(
@router.get("/{activity_id}/stream", response_model=list[DataPointResponse])
async def get_activity_stream(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
# Verify ownership
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
@@ -100,3 +132,173 @@ async def get_activity_stream(
)
result = await session.execute(query)
return result.scalars().all()
@router.get("/{activity_id}/zones", response_model=ZonesResponse)
async def get_activity_zones(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
.order_by(DataPoint.timestamp)
)
result = await session.execute(query)
data_points = list(result.scalars().all())
power_zones = []
hr_zones = []
if rider.ftp:
power_zones = calculate_power_zones(data_points, rider.ftp)
if rider.lthr:
hr_zones = calculate_hr_zones(data_points, rider.lthr)
return ZonesResponse(power_zones=power_zones, hr_zones=hr_zones)
@router.get("/{activity_id}/power-curve", response_model=PowerCurveResponse)
async def get_activity_power_curve(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Try cached
query = select(PowerCurve).where(PowerCurve.activity_id == activity_id)
result = await session.execute(query)
pc = result.scalar_one_or_none()
if pc:
return PowerCurveResponse(curve=pc.curve_data)
# Calculate on the fly
dp_query = (
select(DataPoint)
.where(DataPoint.activity_id == activity_id)
.order_by(DataPoint.timestamp)
)
dp_result = await session.execute(dp_query)
data_points = list(dp_result.scalars().all())
curve = calculate_power_curve(data_points)
return PowerCurveResponse(curve=curve)
@router.post("/{activity_id}/ai-summary")
async def generate_ai_summary(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Check for existing diary entry with summary
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if diary and diary.ai_summary:
return {"summary": diary.ai_summary}
# Generate new summary
summary = await generate_summary(activity, rider_ftp=rider.ftp)
if diary:
diary.ai_summary = summary
else:
diary = DiaryEntry(activity_id=activity_id, ai_summary=summary)
session.add(diary)
await session.commit()
return {"summary": summary}
@router.delete("/{activity_id}")
async def delete_activity(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Delete related records
for model in [DataPoint, Interval, ActivityMetrics, PowerCurve, DiaryEntry]:
q = select(model).where(model.activity_id == activity_id)
result = await session.execute(q)
for row in result.scalars().all():
await session.delete(row)
await session.delete(activity)
await session.commit()
return {"ok": True}
@router.get("/{activity_id}/diary")
async def get_diary(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if not diary:
return {"rider_notes": None, "mood": None, "rpe": None, "sleep_hours": None}
return {
"rider_notes": diary.rider_notes,
"mood": diary.mood,
"rpe": diary.rpe,
"sleep_hours": diary.sleep_hours,
}
@router.put("/{activity_id}/diary")
async def update_diary(
activity_id: uuid.UUID,
data: dict,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
result = await session.execute(query)
diary = result.scalar_one_or_none()
if not diary:
diary = DiaryEntry(activity_id=activity_id)
session.add(diary)
for field in ["rider_notes", "mood", "rpe", "sleep_hours"]:
if field in data:
setattr(diary, field, data[field])
await session.commit()
return {
"rider_notes": diary.rider_notes,
"mood": diary.mood,
"rpe": diary.rpe,
"sleep_hours": diary.sleep_hours,
}

307
backend/app/api/coaching.py Normal file
View File

@@ -0,0 +1,307 @@
"""Coaching API endpoints — onboarding, chat, plan, compliance."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.database import get_session
from backend.app.models.coaching import CoachingChat
from backend.app.models.rider import Rider
from backend.app.models.training import TrainingPlan
from backend.app.services.coaching import (
process_chat_message,
generate_plan,
get_today_workout,
calculate_compliance,
)
router = APIRouter()
class MessageRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
chat_id: str
chat_type: str
status: str
messages: list[dict]
class PlanResponse(BaseModel):
id: str
goal: str
start_date: str
end_date: str
phase: str | None
description: str | None
status: str
weeks: list[dict]
# --- Onboarding ---
@router.post("/onboarding/start")
async def start_onboarding(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Start or resume onboarding chat."""
# Check for existing active onboarding
query = (
select(CoachingChat)
.where(CoachingChat.rider_id == rider.id)
.where(CoachingChat.chat_type == "onboarding")
.where(CoachingChat.status == "active")
.order_by(CoachingChat.created_at.desc())
.limit(1)
)
result = await session.execute(query)
chat = result.scalar_one_or_none()
if chat:
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"onboarding_completed": rider.onboarding_completed,
}
# Create new onboarding chat
chat = CoachingChat(
rider_id=rider.id,
chat_type="onboarding",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
# Send initial greeting by processing an empty-ish message
response = await process_chat_message(rider, chat.id, "Привет! Я хочу начать тренировки.", session)
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"onboarding_completed": rider.onboarding_completed,
}
@router.get("/onboarding/status")
async def get_onboarding_status(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Check onboarding status."""
return {
"onboarding_completed": rider.onboarding_completed,
"coaching_profile": rider.coaching_profile,
}
# --- Chat ---
@router.post("/chat/new")
async def create_chat(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Create a new general coaching chat."""
chat = CoachingChat(
rider_id=rider.id,
chat_type="general",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
return {"chat_id": str(chat.id), "status": "active", "messages": []}
@router.get("/chats")
async def list_chats(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""List all coaching chats."""
query = (
select(CoachingChat)
.where(CoachingChat.rider_id == rider.id)
.order_by(CoachingChat.updated_at.desc())
.limit(20)
)
result = await session.execute(query)
chats = result.scalars().all()
return [
{
"id": str(c.id),
"chat_type": c.chat_type,
"status": c.status,
"message_count": len(c.messages_json or []),
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
"last_message": (c.messages_json[-1]["text"][:100] if c.messages_json else None),
}
for c in chats
]
@router.get("/chat/{chat_id}")
async def get_chat(
chat_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get chat with messages."""
chat = await session.get(CoachingChat, chat_id)
if not chat or chat.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Chat not found")
return {
"chat_id": str(chat.id),
"chat_type": chat.chat_type,
"status": chat.status,
"messages": chat.messages_json or [],
}
@router.post("/chat/{chat_id}/message")
async def send_message(
chat_id: uuid.UUID,
body: MessageRequest,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Send a message to the coaching chat."""
response = await process_chat_message(rider, chat_id, body.message, session)
chat = await session.get(CoachingChat, chat_id)
return {
"response": response,
"chat_id": str(chat_id),
"status": chat.status if chat else "active",
"messages": chat.messages_json if chat else [],
"onboarding_completed": rider.onboarding_completed,
}
# --- Training Plan ---
@router.post("/plan/generate")
async def create_plan(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Generate a new AI training plan."""
plan = await generate_plan(rider, session)
return _plan_to_dict(plan)
@router.get("/plan/active")
async def get_active_plan(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get the active training plan."""
query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
result = await session.execute(query)
plan = result.scalar_one_or_none()
if not plan:
return None
return _plan_to_dict(plan)
@router.get("/plan/{plan_id}/compliance")
async def get_plan_compliance(
plan_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get compliance data for a plan."""
plan = await session.get(TrainingPlan, plan_id)
if not plan or plan.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Plan not found")
compliance = await calculate_compliance(plan, session)
return compliance
@router.get("/today")
async def get_today(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Get today's planned workout."""
workout = await get_today_workout(rider, session)
return workout
# --- Adjustment chat ---
@router.post("/plan/adjust")
async def start_plan_adjustment(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Start an adjustment chat for the active plan."""
# Check active plan exists
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.limit(1)
)
plan_result = await session.execute(plan_query)
plan = plan_result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=400, detail="No active plan to adjust")
chat = CoachingChat(
rider_id=rider.id,
chat_type="adjustment",
status="active",
messages_json=[],
)
session.add(chat)
await session.commit()
await session.refresh(chat)
# Start with context about what needs adjustment
response = await process_chat_message(
rider, chat.id,
"Мне нужно скорректировать мой текущий план тренировок. Посмотри на мои последние данные и предложи изменения.",
session,
)
return {
"chat_id": str(chat.id),
"status": chat.status,
"messages": chat.messages_json or [],
"response": response,
}
def _plan_to_dict(plan: TrainingPlan) -> dict:
weeks = plan.weeks_json.get("weeks", []) if plan.weeks_json else []
return {
"id": str(plan.id),
"goal": plan.goal,
"start_date": plan.start_date.isoformat(),
"end_date": plan.end_date.isoformat(),
"phase": plan.phase,
"description": plan.description,
"status": plan.status,
"weeks": weeks,
}

View File

@@ -1,52 +1,146 @@
import uuid
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider
from backend.app.core.database import get_session
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.models.fitness import PowerCurve
from backend.app.models.rider import Rider
from backend.app.schemas.rider import RiderCreate, RiderUpdate, RiderResponse
from backend.app.schemas.rider import RiderUpdate, RiderResponse, FitnessHistoryResponse
from backend.app.services.fitness import get_fitness_history
router = APIRouter()
@router.post("/profile", response_model=RiderResponse)
async def create_rider(
data: RiderCreate,
session: AsyncSession = Depends(get_session),
):
rider = Rider(**data.model_dump())
session.add(rider)
await session.commit()
await session.refresh(rider)
@router.get("/profile", response_model=RiderResponse)
async def get_rider(rider: Rider = Depends(get_current_rider)):
return rider
@router.get("/profile/{rider_id}", response_model=RiderResponse)
async def get_rider(
rider_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
return rider
@router.put("/profile/{rider_id}", response_model=RiderResponse)
@router.put("/profile", response_model=RiderResponse)
async def update_rider(
rider_id: uuid.UUID,
data: RiderUpdate,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
rider = await session.get(Rider, rider_id)
if not rider:
raise HTTPException(status_code=404, detail="Rider not found")
update_data = data.model_dump(exclude_unset=True)
ftp_changed = "ftp" in update_data and update_data["ftp"] != rider.ftp
for key, value in update_data.items():
setattr(rider, key, value)
# Recalculate TSS/IF for all activities when FTP changes
if ftp_changed and rider.ftp:
query = (
select(ActivityMetrics)
.join(Activity, Activity.id == ActivityMetrics.activity_id)
.where(Activity.rider_id == rider.id)
.where(ActivityMetrics.normalized_power.isnot(None))
)
result = await session.execute(query)
for metrics in result.scalars().all():
np_val = metrics.normalized_power
duration_q = await session.execute(
select(Activity.duration).where(Activity.id == metrics.activity_id)
)
duration = duration_q.scalar()
if np_val and duration:
metrics.intensity_factor = round(np_val / rider.ftp, 2)
metrics.tss = round(
(duration * np_val * (np_val / rider.ftp))
/ (rider.ftp * 3600)
* 100,
1,
)
await session.commit()
await session.refresh(rider)
return rider
@router.get("/fitness", response_model=list[FitnessHistoryResponse])
async def get_fitness(
days: int = 90,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
entries = await get_fitness_history(rider.id, session, days=days)
return entries
@router.get("/weekly-stats")
async def get_weekly_stats(
weeks: int = 8,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
today = date.today()
# Start from Monday of current week
start_of_week = today - timedelta(days=today.weekday())
start_date = start_of_week - timedelta(weeks=weeks - 1)
week_col = func.date_trunc('week', Activity.date).label("week")
query = (
select(
week_col,
func.count(Activity.id).label("rides"),
func.sum(Activity.duration).label("duration"),
func.sum(Activity.distance).label("distance"),
func.sum(ActivityMetrics.tss).label("tss"),
)
.select_from(Activity)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= start_date)
.group_by(week_col)
.order_by(week_col)
)
result = await session.execute(query)
return [
{
"week": row.week.strftime("%Y-%m-%d") if row.week else None,
"rides": row.rides,
"duration": row.duration or 0,
"distance": round(float(row.distance or 0) / 1000, 1),
"tss": round(float(row.tss or 0), 0),
}
for row in result
]
@router.get("/personal-records")
async def get_personal_records(
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Best power at each standard duration across all activities."""
query = (
select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id)
.join(Activity, Activity.id == PowerCurve.activity_id)
.where(Activity.rider_id == rider.id)
)
result = await session.execute(query)
# Merge all curves: keep best power + source activity for each duration
records: dict[int, dict] = {}
for row in result:
curve_data = row.curve_data
for dur_str, power in curve_data.items():
dur = int(dur_str)
if dur not in records or power > records[dur]["power"]:
records[dur] = {
"duration": dur,
"power": power,
"activity_id": str(row.id),
"activity_name": row.name or "Ride",
"date": row.date.isoformat(),
}
# Sort by duration
return sorted(records.values(), key=lambda r: r["duration"])

View File

@@ -3,9 +3,11 @@ from fastapi import APIRouter
from backend.app.api.auth import router as auth_router
from backend.app.api.activities import router as activities_router
from backend.app.api.rider import router as rider_router
from backend.app.api.coaching import router as coaching_router
api_router = APIRouter(prefix="/api")
api_router.include_router(auth_router, prefix="/auth", tags=["auth"])
api_router.include_router(activities_router, prefix="/activities", tags=["activities"])
api_router.include_router(rider_router, prefix="/rider", tags=["rider"])
api_router.include_router(coaching_router, prefix="/coaching", tags=["coaching"])

186
backend/app/bot.py Normal file
View File

@@ -0,0 +1,186 @@
"""Telegram bot for uploading .FIT files."""
import logging
import uuid
from pathlib import Path
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo
from aiogram.filters import CommandStart
from sqlalchemy import select
from backend.app.core.config import settings
from backend.app.core.database import async_session
from backend.app.models.rider import Rider
from backend.app.models.activity import Activity
from backend.app.models.fitness import PowerCurve
from backend.app.services.fit_parser import parse_fit_file
from backend.app.services.metrics import calculate_metrics
from backend.app.services.intervals import detect_intervals
from backend.app.services.power_curve import calculate_power_curve
logger = logging.getLogger(__name__)
router = Router()
async def get_or_create_rider(telegram_id: int, name: str, username: str | None) -> Rider:
"""Find rider by telegram_id or create a new one."""
async with async_session() as session:
query = select(Rider).where(Rider.telegram_id == telegram_id)
result = await session.execute(query)
rider = result.scalar_one_or_none()
if not rider:
rider = Rider(
telegram_id=telegram_id,
telegram_username=username,
name=name,
)
session.add(rider)
await session.commit()
await session.refresh(rider)
return rider
async def process_fit_upload(content: bytes, rider: Rider) -> Activity:
"""Parse FIT file and save activity with all related data."""
upload_dir = Path(settings.UPLOAD_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
file_id = uuid.uuid4()
file_path = upload_dir / f"{file_id}.fit"
file_path.write_bytes(content)
async with async_session() as session:
# Re-attach rider to this session
rider = await session.get(Rider, rider.id)
activity, data_points = parse_fit_file(content, rider.id, str(file_path))
session.add(activity)
await session.flush()
for dp in data_points:
dp.activity_id = activity.id
session.add_all(data_points)
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
if metrics:
session.add(metrics)
intervals = detect_intervals(data_points, ftp=rider.ftp)
for interval in intervals:
interval.activity_id = activity.id
session.add_all(intervals)
curve_data = calculate_power_curve(data_points)
if curve_data:
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
session.add(pc)
await session.commit()
await session.refresh(activity)
return activity
def format_duration(seconds: int) -> str:
h = seconds // 3600
m = (seconds % 3600) // 60
return f"{h}h {m}m" if h > 0 else f"{m}m"
@router.message(CommandStart())
async def cmd_start(message: Message):
user = message.from_user
await get_or_create_rider(
telegram_id=user.id,
name=user.full_name,
username=user.username,
)
await message.answer(
f"Welcome to VeloBrain, {user.first_name}!\n\n"
"Send me a .FIT file from your bike computer and I'll analyze your ride.\n\n"
"I'll calculate:\n"
"- Power metrics (NP, TSS, IF)\n"
"- Power zones & HR zones\n"
"- Power curve\n"
"- Interval detection\n\n"
"Just drag & drop your .FIT file here!"
)
@router.message(F.document)
async def handle_document(message: Message, bot: Bot):
doc = message.document
if not doc.file_name or not doc.file_name.lower().endswith(".fit"):
await message.answer("Please send a .FIT file.")
return
user = message.from_user
rider = await get_or_create_rider(
telegram_id=user.id,
name=user.full_name,
username=user.username,
)
status_msg = await message.answer("Processing your .FIT file...")
try:
file = await bot.download(doc)
content = file.read()
activity = await process_fit_upload(content, rider)
m = activity.metrics
lines = [
f"*{activity.name or 'Ride'}*",
f"Duration: {format_duration(activity.duration)}",
]
if activity.distance:
lines.append(f"Distance: {activity.distance / 1000:.1f} km")
if activity.elevation_gain:
lines.append(f"Elevation: {activity.elevation_gain:.0f} m")
if m:
if m.avg_power:
lines.append(f"Avg Power: {m.avg_power:.0f} W")
if m.normalized_power:
lines.append(f"NP: {m.normalized_power:.0f} W")
if m.tss:
lines.append(f"TSS: {m.tss:.0f}")
if m.intensity_factor:
lines.append(f"IF: {m.intensity_factor:.2f}")
if m.avg_hr:
lines.append(f"Avg HR: {m.avg_hr} bpm")
if m.avg_cadence:
lines.append(f"Avg Cadence: {m.avg_cadence} rpm")
intervals_count = len(activity.intervals or [])
if intervals_count > 0:
work = [i for i in activity.intervals if i.interval_type == "work"]
lines.append(f"Intervals: {len(work)} work / {intervals_count - len(work)} rest")
lines.append(f"\nView details in the web app!")
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Открыть в WebApp", web_app=WebAppInfo(url=f"https://sport.luminic.space//activities/{activity.id}"))]])
await status_msg.edit_text("\n".join(lines), parse_mode="Markdown", reply_markup=keyboard)
except Exception as e:
logger.exception("Error processing FIT file")
await status_msg.edit_text(f"Error processing file: {str(e)}")
@router.message()
async def handle_other(message: Message):
await message.answer(
"Send me a .FIT file to analyze your ride!\n"
"Use /start to see what I can do."
)
def create_bot() -> tuple[Bot, Dispatcher]:
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
dp = Dispatcher()
dp.include_router(router)
return bot, dp

View File

@@ -1,3 +1,5 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -6,12 +8,29 @@ from fastapi.middleware.cors import CORSMiddleware
from backend.app.api.router import api_router
from backend.app.core.config import settings
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
# Start Telegram bot polling in background
bot_task = None
if settings.TELEGRAM_BOT_TOKEN:
from backend.app.bot import create_bot
bot, dp = create_bot()
bot_task = asyncio.create_task(dp.start_polling(bot))
logger.info("Telegram bot polling started")
yield
# Shutdown
if bot_task:
bot_task.cancel()
try:
await bot_task
except asyncio.CancelledError:
pass
logger.info("Telegram bot polling stopped")
app = FastAPI(

View File

@@ -2,6 +2,7 @@ from backend.app.models.rider import Rider
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval
from backend.app.models.fitness import FitnessHistory, PowerCurve, DiaryEntry
from backend.app.models.training import TrainingPlan
from backend.app.models.coaching import CoachingChat
__all__ = [
"Rider",
@@ -13,4 +14,5 @@ __all__ = [
"PowerCurve",
"DiaryEntry",
"TrainingPlan",
"CoachingChat",
]

View File

@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class CoachingChat(Base):
__tablename__ = "coaching_chats"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
rider_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("riders.id"))
chat_type: Mapped[str] = mapped_column(String(50)) # onboarding | general | adjustment
messages_json: Mapped[list] = mapped_column(JSONB, default=list) # [{role, text, timestamp}]
status: Mapped[str] = mapped_column(String(20), default="active") # active | completed
context_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Float, BigInteger, DateTime, func
from sqlalchemy import String, Float, Boolean, BigInteger, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -22,6 +22,8 @@ class Rider(Base):
zones_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
goals: Mapped[str | None] = mapped_column(String(500), nullable=True)
experience_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
coaching_profile: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
onboarding_completed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -19,5 +19,8 @@ class TrainingPlan(Base):
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
weeks_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active") # draft | active | completed | cancelled
onboarding_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -17,6 +17,19 @@ class ActivityMetricsResponse(BaseModel):
max_hr: int | None = None
avg_cadence: int | None = None
avg_speed: float | None = None
calories: int | None = None
class IntervalResponse(BaseModel):
model_config = {"from_attributes": True}
id: UUID
start_ts: datetime
end_ts: datetime
interval_type: str
avg_power: float | None = None
avg_hr: int | None = None
duration: int | None = None
class ActivityResponse(BaseModel):
@@ -31,6 +44,7 @@ class ActivityResponse(BaseModel):
distance: float | None = None
elevation_gain: float | None = None
metrics: ActivityMetricsResponse | None = None
intervals: list[IntervalResponse] = []
class ActivityListResponse(BaseModel):
@@ -50,3 +64,29 @@ class DataPointResponse(BaseModel):
longitude: float | None = None
altitude: float | None = None
temperature: int | None = None
class ZoneItem(BaseModel):
zone: int
name: str
seconds: int
percentage: float
class PowerZoneItem(ZoneItem):
min_watts: int
max_watts: int | None = None
class HrZoneItem(ZoneItem):
min_bpm: int
max_bpm: int | None = None
class ZonesResponse(BaseModel):
power_zones: list[PowerZoneItem] = []
hr_zones: list[HrZoneItem] = []
class PowerCurveResponse(BaseModel):
curve: dict[int, int] # {duration_seconds: max_power}

View File

@@ -1,3 +1,4 @@
from datetime import date
from uuid import UUID
from pydantic import BaseModel
@@ -22,6 +23,16 @@ class RiderUpdate(BaseModel):
experience_level: str | None = None
class FitnessHistoryResponse(BaseModel):
model_config = {"from_attributes": True}
date: date
ctl: float
atl: float
tsb: float
ramp_rate: float | None = None
class RiderResponse(BaseModel):
model_config = {"from_attributes": True}
@@ -36,3 +47,5 @@ class RiderResponse(BaseModel):
zones_config: dict | None = None
goals: str | None = None
experience_level: str | None = None
coaching_profile: dict | None = None
onboarding_completed: bool = False

View File

@@ -0,0 +1,62 @@
"""Generate AI activity summaries using Gemini."""
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.services.gemini_client import chat_async
SYSTEM_PROMPT = """You are VeloBrain, an AI cycling coach.
Analyze the cycling activity data and provide a concise, insightful summary in 3-5 sentences.
Focus on: performance highlights, pacing strategy, areas for improvement, and training effect.
Be specific with numbers. Use a friendly, coaching tone.
Respond in Russian.
Use a HTML text formatting."""
def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) -> str:
m: ActivityMetrics | None = activity.metrics
lines = [
f"Activity: {activity.name or 'Ride'}",
f"Type: {activity.activity_type}",
f"Duration: {activity.duration // 3600}h {(activity.duration % 3600) // 60}m",
]
if activity.distance:
lines.append(f"Distance: {activity.distance / 1000:.1f} km")
if activity.elevation_gain:
lines.append(f"Elevation: {activity.elevation_gain:.0f} m")
if m:
if m.avg_power:
lines.append(f"Avg Power: {m.avg_power:.0f} W")
if m.normalized_power:
lines.append(f"Normalized Power: {m.normalized_power:.0f} W")
if m.tss:
lines.append(f"TSS: {m.tss:.0f}")
if m.intensity_factor:
lines.append(f"IF: {m.intensity_factor:.2f}")
if m.variability_index:
lines.append(f"VI: {m.variability_index:.2f}")
if m.avg_hr:
lines.append(f"Avg HR: {m.avg_hr} bpm")
if m.max_hr:
lines.append(f"Max HR: {m.max_hr} bpm")
if m.avg_cadence:
lines.append(f"Avg Cadence: {m.avg_cadence} rpm")
if rider_ftp:
lines.append(f"Rider FTP: {rider_ftp:.0f} W")
intervals = activity.intervals or []
work_intervals = [i for i in intervals if i.interval_type == "work"]
if work_intervals:
lines.append(f"Work intervals: {len(work_intervals)}")
powers = [i.avg_power for i in work_intervals if i.avg_power]
if powers:
lines.append(f"Interval avg powers: {', '.join(f'{p:.0f}W' for p in powers)}")
return "\n".join(lines)
async def generate_summary(activity: Activity, rider_ftp: float | None = None) -> str:
prompt = _build_activity_prompt(activity, rider_ftp)
messages = [{"role": "user", "text": prompt}]
return await chat_async(messages, system_instruction=SYSTEM_PROMPT, temperature=0.5)

View File

@@ -0,0 +1,461 @@
"""AI Coaching service — onboarding, plan generation, chat."""
import json
import re
from datetime import date, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.models.coaching import CoachingChat
from backend.app.models.fitness import FitnessHistory, PowerCurve
from backend.app.models.rider import Rider
from backend.app.models.training import TrainingPlan
from backend.app.services.gemini_client import chat_async
ONBOARDING_SYSTEM = """You are VeloBrain AI Coach — a professional cycling coach.
You are conducting an onboarding interview with a new athlete.
Ask questions ONE AT A TIME, in a friendly conversational tone.
Keep responses short (2-3 sentences + question).
Questions to cover (in rough order):
1. Main cycling goal (fitness, racing, gran fondo, weight loss, etc.)
2. Target event/race (if any) and its date
3. Current weekly training volume (hours/week)
4. How many days per week they can train
5. Which days are available for training
6. Indoor trainer availability (smart trainer, basic, none)
7. Power meter availability
8. Any injuries or health concerns
9. Previous coaching or structured training experience
10. What they enjoy most about cycling
When ALL questions are answered, respond with your summary and then output EXACTLY this marker on a new line:
[ONBOARDING_COMPLETE]
Followed by a JSON block with the structured data:
```json
{
"goal": "...",
"target_event": "...",
"target_event_date": "...",
"hours_per_week": N,
"days_per_week": N,
"available_days": ["monday", ...],
"has_indoor_trainer": true/false,
"trainer_type": "smart/basic/none",
"has_power_meter": true/false,
"injuries": "...",
"coaching_experience": "...",
"enjoys": "..."
}
```
Respond in Russian."""
PLAN_GENERATION_SYSTEM = """You are VeloBrain AI Coach generating a structured training plan.
Based on the rider's profile, current fitness, and goals, create a detailed multi-week training plan.
Output ONLY a valid JSON block with this structure:
```json
{
"goal": "short goal description",
"description": "plan overview in 2-3 sentences",
"phase": "base/build/peak/recovery",
"duration_weeks": N,
"weeks": [
{
"week_number": 1,
"focus": "week focus description",
"target_tss": 300,
"target_hours": 8,
"days": [
{
"day": "monday",
"workout_type": "rest",
"title": "Rest Day",
"description": "",
"duration_minutes": 0,
"target_tss": 0,
"target_if": 0
}
]
}
]
}
```
workout_type options: rest, endurance, tempo, sweetspot, threshold, vo2max, sprint, recovery, race
Plan duration: 4-8 weeks based on goal.
Include progressive overload with recovery weeks every 3-4 weeks.
Adjust intensity based on rider's FTP and experience level.
All descriptions in Russian."""
ADJUSTMENT_SYSTEM = """You are VeloBrain AI Coach reviewing a training plan.
The rider's plan needs adjustment based on their recent performance, compliance, and fatigue.
Analyze the data provided and suggest specific changes to upcoming weeks.
When you've decided on adjustments, output:
[PLAN_ADJUSTED]
Followed by the updated weeks JSON.
Respond in Russian."""
GENERAL_CHAT_SYSTEM = """You are VeloBrain AI Coach — a knowledgeable and supportive cycling coach.
You have access to the rider's training data and can answer questions about:
- Training methodology and periodization
- Nutrition and recovery
- Equipment and bike fit
- Race strategy and pacing
- Interpreting their power/HR data
Be concise, specific, and actionable. Use the rider's actual data when relevant.
Respond in Russian."""
async def build_rider_context(rider: Rider, session: AsyncSession) -> str:
"""Build a concise context string with rider's current state."""
lines = [
f"Rider: {rider.name}",
f"FTP: {rider.ftp or 'not set'} W",
f"Weight: {rider.weight or 'not set'} kg",
f"LTHR: {rider.lthr or 'not set'} bpm",
f"Experience: {rider.experience_level or 'not set'}",
f"Goals: {rider.goals or 'not set'}",
]
if rider.ftp and rider.weight:
lines.append(f"W/kg: {rider.ftp / rider.weight:.2f}")
# Coaching profile
if rider.coaching_profile:
cp = rider.coaching_profile
lines.append(f"\nCoaching Profile:")
for k, v in cp.items():
lines.append(f" {k}: {v}")
# Fitness (latest CTL/ATL/TSB)
fh_query = (
select(FitnessHistory)
.where(FitnessHistory.rider_id == rider.id)
.order_by(FitnessHistory.date.desc())
.limit(1)
)
fh_result = await session.execute(fh_query)
fh = fh_result.scalar_one_or_none()
if fh:
lines.append(f"\nFitness: CTL={fh.ctl:.0f} ATL={fh.atl:.0f} TSB={fh.tsb:.0f}")
# Recent 4 weeks volume
four_weeks_ago = date.today() - timedelta(weeks=4)
vol_query = (
select(
Activity.date,
Activity.duration,
ActivityMetrics.tss,
)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider.id)
.where(Activity.date >= four_weeks_ago)
.order_by(Activity.date.desc())
)
vol_result = await session.execute(vol_query)
rides = list(vol_result.all())
if rides:
total_hours = sum(r.duration for r in rides) / 3600
total_tss = sum(float(r.tss or 0) for r in rides)
lines.append(f"\nLast 4 weeks: {len(rides)} rides, {total_hours:.1f}h, TSS={total_tss:.0f}")
lines.append(f"Avg/week: {total_hours / 4:.1f}h, TSS={total_tss / 4:.0f}")
# Personal records (from power curves)
pc_query = (
select(PowerCurve.curve_data)
.join(Activity, Activity.id == PowerCurve.activity_id)
.where(Activity.rider_id == rider.id)
)
pc_result = await session.execute(pc_query)
best: dict[int, int] = {}
for row in pc_result:
for dur_str, power in row.curve_data.items():
dur = int(dur_str)
if dur not in best or power > best[dur]:
best[dur] = power
if best:
pr_strs = []
for dur in sorted(best.keys()):
if dur < 60:
pr_strs.append(f"{dur}s={best[dur]}W")
elif dur < 3600:
pr_strs.append(f"{dur // 60}m={best[dur]}W")
else:
pr_strs.append(f"{dur // 3600}h={best[dur]}W")
lines.append(f"\nPower PRs: {', '.join(pr_strs)}")
# Active plan status
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
plan_result = await session.execute(plan_query)
plan = plan_result.scalar_one_or_none()
if plan:
lines.append(f"\nActive plan: '{plan.goal}' ({plan.start_date} to {plan.end_date}), phase: {plan.phase}")
return "\n".join(lines)
async def process_chat_message(
rider: Rider,
chat_id,
user_message: str,
session: AsyncSession,
) -> str:
"""Process a user message in a coaching chat and return AI response."""
chat = await session.get(CoachingChat, chat_id)
if not chat or chat.rider_id != rider.id:
raise ValueError("Chat not found")
# Build context
rider_context = await build_rider_context(rider, session)
# Select system prompt
system_prompts = {
"onboarding": ONBOARDING_SYSTEM,
"general": GENERAL_CHAT_SYSTEM,
"adjustment": ADJUSTMENT_SYSTEM,
}
system = system_prompts.get(chat.chat_type, GENERAL_CHAT_SYSTEM)
system = f"{system}\n\n--- Rider Data ---\n{rider_context}"
# Build message history
messages = list(chat.messages_json or [])
messages.append({"role": "user", "text": user_message})
# Call Gemini
gemini_messages = [{"role": m["role"], "text": m["text"]} for m in messages]
response = await chat_async(gemini_messages, system_instruction=system, temperature=0.7)
# Save messages
now = datetime.utcnow().isoformat()
messages_to_save = list(chat.messages_json or [])
messages_to_save.append({"role": "user", "text": user_message, "timestamp": now})
messages_to_save.append({"role": "model", "text": response, "timestamp": now})
chat.messages_json = messages_to_save
# Check for onboarding completion
if chat.chat_type == "onboarding" and "[ONBOARDING_COMPLETE]" in response:
chat.status = "completed"
profile_data = _extract_json(response)
if profile_data:
rider.coaching_profile = profile_data
rider.onboarding_completed = True
if profile_data.get("goal"):
rider.goals = profile_data["goal"]
# Check for plan adjustment
if chat.chat_type == "adjustment" and "[PLAN_ADJUSTED]" in response:
chat.status = "completed"
plan_data = _extract_json(response)
if plan_data:
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
plan_result = await session.execute(plan_query)
plan = plan_result.scalar_one_or_none()
if plan and "weeks" in plan_data:
current = plan.weeks_json or {}
current["weeks"] = plan_data["weeks"]
plan.weeks_json = current
await session.commit()
return response
async def generate_plan(rider: Rider, session: AsyncSession) -> TrainingPlan:
"""Generate a new training plan using AI."""
rider_context = await build_rider_context(rider, session)
prompt = f"Generate a training plan for this rider.\n\n{rider_context}"
messages = [{"role": "user", "text": prompt}]
response = await chat_async(
messages,
system_instruction=PLAN_GENERATION_SYSTEM,
temperature=0.5,
)
plan_data = _extract_json(response)
if not plan_data or "weeks" not in plan_data:
raise ValueError("Failed to parse plan from AI response")
# Cancel existing active plans
existing_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
)
existing_result = await session.execute(existing_query)
for old_plan in existing_result.scalars().all():
old_plan.status = "cancelled"
duration_weeks = plan_data.get("duration_weeks", len(plan_data["weeks"]))
start = date.today()
# Align to next Monday
days_until_monday = (7 - start.weekday()) % 7
if days_until_monday == 0:
days_until_monday = 0
start = start + timedelta(days=days_until_monday)
plan = TrainingPlan(
rider_id=rider.id,
goal=plan_data.get("goal", rider.goals or "General fitness"),
start_date=start,
end_date=start + timedelta(weeks=duration_weeks),
phase=plan_data.get("phase", "base"),
weeks_json=plan_data,
description=plan_data.get("description", ""),
status="active",
onboarding_data=rider.coaching_profile,
)
session.add(plan)
await session.commit()
await session.refresh(plan)
return plan
async def get_today_workout(rider: Rider, session: AsyncSession) -> dict | None:
"""Get today's planned workout from the active plan."""
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider.id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
result = await session.execute(plan_query)
plan = result.scalar_one_or_none()
if not plan or not plan.weeks_json:
return None
today = date.today()
if today < plan.start_date or today > plan.end_date:
return None
week_num = (today - plan.start_date).days // 7 + 1
day_name = today.strftime("%A").lower()
weeks = plan.weeks_json.get("weeks", [])
for week in weeks:
if week.get("week_number") == week_num:
for day in week.get("days", []):
if day.get("day") == day_name:
return {
"plan_id": str(plan.id),
"plan_goal": plan.goal,
"week_number": week_num,
"week_focus": week.get("focus", ""),
**day,
}
return None
async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> list[dict]:
"""Compare planned vs actual per week."""
if not plan.weeks_json:
return []
weeks = plan.weeks_json.get("weeks", [])
results = []
for week in weeks:
week_num = week.get("week_number", 0)
week_start = plan.start_date + timedelta(weeks=week_num - 1)
week_end = week_start + timedelta(days=7)
# Skip future weeks
if week_start > date.today():
results.append({
"week_number": week_num,
"focus": week.get("focus", ""),
"planned_tss": week.get("target_tss", 0),
"actual_tss": 0,
"planned_hours": week.get("target_hours", 0),
"actual_hours": 0,
"planned_rides": sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest"),
"actual_rides": 0,
"adherence_pct": 0,
"status": "upcoming",
})
continue
# Get actual activities in this week
act_query = (
select(Activity, ActivityMetrics.tss)
.outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == plan.rider_id)
.where(Activity.date >= week_start)
.where(Activity.date < week_end)
)
act_result = await session.execute(act_query)
acts = list(act_result.all())
actual_tss = sum(float(r.tss or 0) for r in acts)
actual_hours = sum(r[0].duration for r in acts) / 3600
actual_rides = len(acts)
planned_rides = sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest")
planned_tss = week.get("target_tss", 0)
adherence = 0
if planned_rides > 0:
adherence = min(100, round(actual_rides / planned_rides * 100))
is_current = week_start <= date.today() < week_end
results.append({
"week_number": week_num,
"focus": week.get("focus", ""),
"planned_tss": planned_tss,
"actual_tss": round(actual_tss, 0),
"planned_hours": week.get("target_hours", 0),
"actual_hours": round(actual_hours, 1),
"planned_rides": planned_rides,
"actual_rides": actual_rides,
"adherence_pct": adherence,
"status": "current" if is_current else "completed",
})
return results
def _extract_json(text: str) -> dict | None:
"""Extract JSON from AI response text."""
# Try to find JSON in code blocks
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
# Try to find raw JSON object
match = re.search(r"\{[\s\S]*\}", text)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
return None

View File

@@ -0,0 +1,107 @@
"""CTL / ATL / TSB (Fitness / Fatigue / Form) calculation service."""
from datetime import date, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.activity import Activity, ActivityMetrics
from backend.app.models.fitness import FitnessHistory
CTL_DAYS = 42 # Chronic Training Load time constant
ATL_DAYS = 7 # Acute Training Load time constant
async def rebuild_fitness_history(
rider_id,
session: AsyncSession,
days_back: int = 365,
) -> list[FitnessHistory]:
"""Rebuild CTL/ATL/TSB for a rider from scratch using exponential moving averages."""
end_date = date.today()
start_date = end_date - timedelta(days=days_back)
# Get all activities with TSS in the date range
query = (
select(
func.date(Activity.date).label("activity_date"),
func.sum(ActivityMetrics.tss).label("daily_tss"),
)
.join(ActivityMetrics, ActivityMetrics.activity_id == Activity.id)
.where(Activity.rider_id == rider_id)
.where(Activity.date >= start_date)
.where(ActivityMetrics.tss.isnot(None))
.group_by(func.date(Activity.date))
.order_by(func.date(Activity.date))
)
result = await session.execute(query)
tss_by_date: dict[date, float] = {}
for row in result:
tss_by_date[row.activity_date] = float(row.daily_tss)
# Delete existing history for this rider
existing = await session.execute(
select(FitnessHistory).where(FitnessHistory.rider_id == rider_id)
)
for entry in existing.scalars().all():
await session.delete(entry)
# Calculate EMA-based CTL/ATL/TSB
ctl = 0.0
atl = 0.0
prev_ctl = 0.0
entries: list[FitnessHistory] = []
current = start_date
while current <= end_date:
daily_tss = tss_by_date.get(current, 0.0)
ctl = ctl + (daily_tss - ctl) / CTL_DAYS
atl = atl + (daily_tss - atl) / ATL_DAYS
tsb = ctl - atl
ramp_rate = ctl - prev_ctl
entry = FitnessHistory(
rider_id=rider_id,
date=current,
ctl=round(ctl, 1),
atl=round(atl, 1),
tsb=round(tsb, 1),
ramp_rate=round(ramp_rate, 2),
)
entries.append(entry)
prev_ctl = ctl
current += timedelta(days=1)
session.add_all(entries)
await session.flush()
return entries
async def get_fitness_history(
rider_id,
session: AsyncSession,
days: int = 90,
) -> list[FitnessHistory]:
"""Get fitness history for a rider, rebuilding if needed."""
cutoff = date.today() - timedelta(days=days)
query = (
select(FitnessHistory)
.where(FitnessHistory.rider_id == rider_id)
.where(FitnessHistory.date >= cutoff)
.order_by(FitnessHistory.date)
)
result = await session.execute(query)
entries = list(result.scalars().all())
# If no data or stale, rebuild
if not entries or entries[-1].date < date.today() - timedelta(days=1):
all_entries = await rebuild_fitness_history(rider_id, session)
entries = [e for e in all_entries if e.date >= cutoff]
return entries

View File

@@ -0,0 +1,88 @@
import numpy as np
from backend.app.models.activity import DataPoint, Interval
def detect_intervals(
data_points: list[DataPoint],
ftp: float | None = None,
min_duration: int = 30,
) -> list[Interval]:
"""
Auto-detect work/rest intervals based on power thresholds.
Work = power >= 88% FTP (sweetspot and above)
Rest = power < 55% FTP (active recovery)
"""
powers = [dp.power for dp in data_points]
if not any(p is not None for p in powers):
return []
if not ftp or ftp <= 0:
# Without FTP, use median power as threshold
valid_powers = [p for p in powers if p is not None and p > 0]
if not valid_powers:
return []
threshold_high = np.median(valid_powers) * 1.15
threshold_low = np.median(valid_powers) * 0.65
else:
threshold_high = ftp * 0.88
threshold_low = ftp * 0.55
intervals: list[Interval] = []
current_type: str | None = None
start_idx: int = 0
for i, dp in enumerate(data_points):
p = dp.power if dp.power is not None else 0
if p >= threshold_high:
new_type = "work"
elif p < threshold_low:
new_type = "rest"
else:
continue # tempo zone — don't break interval
if current_type is None:
current_type = new_type
start_idx = i
elif new_type != current_type:
interval = _build_interval(data_points, start_idx, i - 1, current_type, min_duration)
if interval:
intervals.append(interval)
current_type = new_type
start_idx = i
# Close last interval
if current_type is not None:
interval = _build_interval(data_points, start_idx, len(data_points) - 1, current_type, min_duration)
if interval:
intervals.append(interval)
return intervals
def _build_interval(
data_points: list[DataPoint],
start_idx: int,
end_idx: int,
interval_type: str,
min_duration: int,
) -> Interval | None:
segment = data_points[start_idx:end_idx + 1]
if len(segment) < min_duration:
return None
powers = [dp.power for dp in segment if dp.power is not None]
hrs = [dp.heart_rate for dp in segment if dp.heart_rate is not None]
duration = int((segment[-1].timestamp - segment[0].timestamp).total_seconds())
if duration < min_duration:
return None
return Interval(
start_ts=segment[0].timestamp,
end_ts=segment[-1].timestamp,
interval_type=interval_type,
avg_power=round(sum(powers) / len(powers), 1) if powers else None,
avg_hr=round(sum(hrs) / len(hrs)) if hrs else None,
duration=duration,
)

View File

@@ -1,20 +1,14 @@
import uuid
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.activity import Activity, ActivityMetrics, DataPoint
from backend.app.models.rider import Rider
def calculate_metrics(
data_points: list[DataPoint],
activity: Activity,
rider_id: uuid.UUID,
session: AsyncSession,
ftp: float | None = None,
) -> ActivityMetrics | None:
"""Calculate power-based metrics for an activity."""
"""Calculate all power/HR-based metrics for an activity."""
if not data_points:
return None
@@ -23,61 +17,60 @@ def calculate_metrics(
cadences = np.array([dp.cadence for dp in data_points if dp.cadence is not None], dtype=float)
speeds = np.array([dp.speed for dp in data_points if dp.speed is not None], dtype=float)
avg_power = float(np.mean(powers)) if len(powers) > 0 else None
max_power = int(np.max(powers)) if len(powers) > 0 else None
has_power = len(powers) > 0
has_hr = len(hrs) > 0
avg_power = float(np.mean(powers)) if has_power else None
max_power = int(np.max(powers)) if has_power else None
np_value = _normalized_power(powers) if len(powers) >= 30 else avg_power
avg_hr = int(np.mean(hrs)) if len(hrs) > 0 else None
max_hr = int(np.max(hrs)) if len(hrs) > 0 else None
avg_hr = int(np.mean(hrs)) if has_hr else None
max_hr = int(np.max(hrs)) if has_hr else None
avg_cadence = int(np.mean(cadences)) if len(cadences) > 0 else None
avg_speed = float(np.mean(speeds)) if len(speeds) > 0 else None
# IF, VI, TSS require FTP — will be None if no FTP set
intensity_factor = None
# Variability Index
variability_index = None
tss = None
if np_value and avg_power and avg_power > 0:
variability_index = np_value / avg_power
variability_index = round(np_value / avg_power, 2)
# FTP-dependent metrics
intensity_factor = None
tss = None
if np_value and ftp and ftp > 0:
intensity_factor = round(np_value / ftp, 2)
tss = round(
(activity.duration * np_value * (np_value / ftp))
/ (ftp * 3600)
* 100,
1,
)
# Efficiency Factor: NP / avg HR (aerobic decoupling indicator)
calories = None
if has_power:
# Rough estimate: 1 kJ ≈ 1 kcal, power in watts * seconds / 1000
calories = int(np.sum(powers) / 1000)
return ActivityMetrics(
activity_id=activity.id,
tss=tss,
normalized_power=round(np_value, 1) if np_value else None,
intensity_factor=intensity_factor,
variability_index=round(variability_index, 2) if variability_index else None,
variability_index=variability_index,
avg_power=round(avg_power, 1) if avg_power else None,
max_power=max_power,
avg_hr=avg_hr,
max_hr=max_hr,
avg_cadence=avg_cadence,
avg_speed=round(avg_speed, 2) if avg_speed else None,
calories=calories,
)
def calculate_metrics_with_ftp(
metrics: ActivityMetrics,
ftp: float,
duration_seconds: int,
) -> ActivityMetrics:
"""Enrich metrics with FTP-dependent values (IF, TSS)."""
if metrics.normalized_power and ftp > 0:
metrics.intensity_factor = round(metrics.normalized_power / ftp, 2)
metrics.tss = round(
(duration_seconds * metrics.normalized_power * metrics.intensity_factor)
/ (ftp * 3600)
* 100,
1,
)
return metrics
def _normalized_power(powers: np.ndarray) -> float:
"""
NP = 4th root of mean of 4th powers of 30s rolling average.
"""
"""NP = 4th root of mean of (30s rolling average)^4."""
if len(powers) < 30:
return float(np.mean(powers))
rolling = np.convolve(powers, np.ones(30) / 30, mode="valid")
return float(np.power(np.mean(np.power(rolling, 4)), 0.25))

View File

@@ -0,0 +1,30 @@
import numpy as np
from backend.app.models.activity import DataPoint
# Standard durations for the power duration curve
DURATIONS = [1, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600]
def calculate_power_curve(data_points: list[DataPoint]) -> dict[int, int]:
"""
Calculate max average power for standard durations.
Returns {duration_seconds: max_avg_power}.
"""
powers = np.array([dp.power for dp in data_points if dp.power is not None], dtype=float)
if len(powers) == 0:
return {}
result = {}
for dur in DURATIONS:
if dur > len(powers):
break
if dur == 1:
result[dur] = int(np.max(powers))
else:
# Rolling mean via cumsum for efficiency
cumsum = np.cumsum(np.insert(powers, 0, 0))
rolling_avg = (cumsum[dur:] - cumsum[:-dur]) / dur
result[dur] = int(np.max(rolling_avg))
return result

View File

@@ -0,0 +1,75 @@
import numpy as np
from backend.app.models.activity import DataPoint
# Coggan 7-zone power model (% of FTP)
POWER_ZONES = [
{"zone": 1, "name": "Active Recovery", "min_pct": 0, "max_pct": 55},
{"zone": 2, "name": "Endurance", "min_pct": 55, "max_pct": 75},
{"zone": 3, "name": "Tempo", "min_pct": 75, "max_pct": 90},
{"zone": 4, "name": "Threshold", "min_pct": 90, "max_pct": 105},
{"zone": 5, "name": "VO2max", "min_pct": 105, "max_pct": 120},
{"zone": 6, "name": "Anaerobic", "min_pct": 120, "max_pct": 150},
{"zone": 7, "name": "Neuromuscular", "min_pct": 150, "max_pct": 10000},
]
# 5-zone HR model (% of LTHR)
HR_ZONES = [
{"zone": 1, "name": "Recovery", "min_pct": 0, "max_pct": 81},
{"zone": 2, "name": "Aerobic", "min_pct": 81, "max_pct": 90},
{"zone": 3, "name": "Tempo", "min_pct": 90, "max_pct": 95},
{"zone": 4, "name": "Threshold", "min_pct": 95, "max_pct": 100},
{"zone": 5, "name": "Anaerobic", "min_pct": 100, "max_pct": 10000},
]
def calculate_power_zones(
data_points: list[DataPoint],
ftp: float,
) -> list[dict]:
"""Calculate time-in-zone distribution for power."""
powers = np.array([dp.power for dp in data_points if dp.power is not None], dtype=float)
if len(powers) == 0 or ftp <= 0:
return []
total = len(powers)
result = []
for z in POWER_ZONES:
low = ftp * z["min_pct"] / 100
high = ftp * z["max_pct"] / 100
seconds = int(np.sum((powers >= low) & (powers < high)))
result.append({
"zone": z["zone"],
"name": z["name"],
"min_watts": round(low),
"max_watts": round(high) if z["max_pct"] < 10000 else None,
"seconds": seconds,
"percentage": round(seconds / total * 100, 1) if total > 0 else 0,
})
return result
def calculate_hr_zones(
data_points: list[DataPoint],
lthr: int,
) -> list[dict]:
"""Calculate time-in-zone distribution for heart rate."""
hrs = np.array([dp.heart_rate for dp in data_points if dp.heart_rate is not None], dtype=float)
if len(hrs) == 0 or lthr <= 0:
return []
total = len(hrs)
result = []
for z in HR_ZONES:
low = lthr * z["min_pct"] / 100
high = lthr * z["max_pct"] / 100
seconds = int(np.sum((hrs >= low) & (hrs < high)))
result.append({
"zone": z["zone"],
"name": z["name"],
"min_bpm": round(low),
"max_bpm": round(high) if z["max_pct"] < 10000 else None,
"seconds": seconds,
"percentage": round(seconds / total * 100, 1) if total > 0 else 0,
})
return result

BIN
fit.fit Normal file

Binary file not shown.

View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8099/api
VITE_ENABLE_DEVTOOLS=true

2
frontend/.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=https://sport.luminic.space/api
VITE_ENABLE_DEVTOOLS=false

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="app-dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View File

@@ -1,30 +1,61 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from './stores/auth'
import Button from 'primevue/button'
import Avatar from 'primevue/avatar'
const auth = useAuthStore()
const isAuthenticated = computed(() => auth.isAuthenticated)
const mobileMenuOpen = ref(false)
onMounted(async () => {
if (auth.isAuthenticated && !auth.rider) {
await auth.fetchRider()
}
})
</script>
<template>
<div class="min-h-screen bg-surface-950 text-surface-0">
<nav v-if="isAuthenticated" class="flex items-center justify-between px-8 h-14 bg-surface-900 border-b border-surface-700">
<div class="min-h-screen bg-surface-50 text-surface-900">
<nav v-if="isAuthenticated" class="flex items-center justify-between px-4 md:px-8 h-14 bg-white border-b border-surface-200 shadow-sm">
<RouterLink to="/" class="text-xl font-bold text-primary">VeloBrain</RouterLink>
<div class="flex items-center gap-6">
<RouterLink to="/" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Dashboard</RouterLink>
<RouterLink to="/activities" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Activities</RouterLink>
<RouterLink to="/settings" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Settings</RouterLink>
<!-- Desktop nav -->
<div class="hidden md:flex items-center gap-6">
<RouterLink to="/" class="text-surface-400 hover:text-primary text-sm font-medium transition-colors" active-class="!text-primary">Dashboard</RouterLink>
<RouterLink to="/activities" class="text-surface-400 hover:text-primary text-sm font-medium transition-colors" active-class="!text-primary">Activities</RouterLink>
<RouterLink to="/coach" class="text-surface-400 hover:text-primary text-sm font-medium transition-colors" active-class="!text-primary">Coach</RouterLink>
<RouterLink to="/settings" class="text-surface-400 hover:text-primary text-sm font-medium transition-colors" active-class="!text-primary">Settings</RouterLink>
<div class="flex items-center gap-3 ml-4">
<span v-if="auth.rider" class="text-surface-600 text-sm">{{ auth.rider.name }}</span>
<Avatar v-if="auth.rider?.avatar_url" :image="auth.rider.avatar_url" shape="circle" size="small" />
<Avatar v-else :label="auth.rider?.name?.charAt(0) ?? '?'" shape="circle" size="small" />
<Button icon="pi pi-sign-out" text rounded severity="secondary" size="small" @click="auth.logout()" />
</div>
</div>
<!-- Mobile hamburger -->
<Button icon="pi pi-bars" text rounded severity="secondary" size="small" class="md:hidden" @click="mobileMenuOpen = !mobileMenuOpen" />
</nav>
<main class="max-w-[1200px] mx-auto p-8">
<!-- Mobile menu dropdown -->
<div v-if="isAuthenticated && mobileMenuOpen" class="md:hidden bg-white border-b border-surface-200 shadow-sm px-4 py-3 flex flex-col gap-3">
<RouterLink to="/" class="text-surface-600 hover:text-primary text-sm font-medium py-1" @click="mobileMenuOpen = false">Dashboard</RouterLink>
<RouterLink to="/activities" class="text-surface-600 hover:text-primary text-sm font-medium py-1" @click="mobileMenuOpen = false">Activities</RouterLink>
<RouterLink to="/coach" class="text-surface-600 hover:text-primary text-sm font-medium py-1" @click="mobileMenuOpen = false">Coach</RouterLink>
<RouterLink to="/settings" class="text-surface-600 hover:text-primary text-sm font-medium py-1" @click="mobileMenuOpen = false">Settings</RouterLink>
<div class="flex items-center justify-between pt-2 border-t border-surface-100">
<div class="flex items-center gap-2">
<Avatar v-if="auth.rider?.avatar_url" :image="auth.rider.avatar_url" shape="circle" size="small" />
<Avatar v-else :label="auth.rider?.name?.charAt(0) ?? '?'" shape="circle" size="small" />
<span v-if="auth.rider" class="text-surface-600 text-sm">{{ auth.rider.name }}</span>
</div>
<Button icon="pi pi-sign-out" text rounded severity="secondary" size="small" @click="auth.logout()" />
</div>
</div>
<main class="max-w-[1200px] mx-auto p-4 md:p-8">
<RouterView />
</main>
</div>

View File

@@ -1,7 +1,7 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
baseURL: import.meta.env.VITE_API_URL,
})
api.interceptors.request.use((config) => {

View File

@@ -26,6 +26,12 @@ const routes = [
component: () => import('./views/ActivityDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/coach',
name: 'coach',
component: () => import('./views/CoachView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',

View File

@@ -1,32 +1,89 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { Activity } from '../types/models'
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, FitnessEntry, DiaryEntry, WeeklyStats, PersonalRecord } from '../types/models'
export const useActivitiesStore = defineStore('activities', () => {
const { api } = useApi()
const activities = ref<Activity[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchActivities(riderId: string, limit = 20, offset = 0) {
const { data } = await api.get('/activities', {
params: { rider_id: riderId, limit, offset },
})
activities.value = data.items
total.value = data.total
async function fetchActivities(limit = 20, offset = 0) {
loading.value = true
try {
const { data } = await api.get('/activities', { params: { limit, offset } })
activities.value = data.items
total.value = data.total
} finally {
loading.value = false
}
}
async function uploadFit(riderId: string, file: File) {
async function fetchActivity(id: string): Promise<Activity> {
const { data } = await api.get<Activity>(`/activities/${id}`)
return data
}
async function fetchStream(id: string): Promise<DataPoint[]> {
const { data } = await api.get<DataPoint[]>(`/activities/${id}/stream`)
return data
}
async function fetchZones(id: string): Promise<ZonesResponse> {
const { data } = await api.get<ZonesResponse>(`/activities/${id}/zones`)
return data
}
async function fetchPowerCurve(id: string): Promise<PowerCurveResponse> {
const { data } = await api.get<PowerCurveResponse>(`/activities/${id}/power-curve`)
return data
}
async function uploadFit(file: File): Promise<Activity> {
const form = new FormData()
form.append('file', file)
const { data } = await api.post<Activity>(
`/activities/upload?rider_id=${riderId}`,
form,
)
const { data } = await api.post<Activity>('/activities/upload', form)
activities.value.unshift(data)
total.value++
return data
}
return { activities, total, fetchActivities, uploadFit }
async function fetchAiSummary(id: string): Promise<string> {
const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`)
return data.summary
}
async function fetchFitness(days = 90): Promise<FitnessEntry[]> {
const { data } = await api.get<FitnessEntry[]>('/rider/fitness', { params: { days } })
return data
}
async function deleteActivity(id: string): Promise<void> {
await api.delete(`/activities/${id}`)
activities.value = activities.value.filter(a => a.id !== id)
total.value--
}
async function fetchDiary(id: string): Promise<DiaryEntry> {
const { data } = await api.get<DiaryEntry>(`/activities/${id}/diary`)
return data
}
async function updateDiary(id: string, diary: Partial<DiaryEntry>): Promise<DiaryEntry> {
const { data } = await api.put<DiaryEntry>(`/activities/${id}/diary`, diary)
return data
}
async function fetchPersonalRecords(): Promise<PersonalRecord[]> {
const { data } = await api.get<PersonalRecord[]>('/rider/personal-records')
return data
}
async function fetchWeeklyStats(weeks = 8): Promise<WeeklyStats[]> {
const { data } = await api.get<WeeklyStats[]>('/rider/weekly-stats', { params: { weeks } })
return data
}
return { activities, total, loading, fetchActivities, fetchActivity, fetchStream, fetchZones, fetchPowerCurve, uploadFit, fetchAiSummary, fetchFitness, deleteActivity, fetchDiary, updateDiary, fetchWeeklyStats, fetchPersonalRecords }
})

View File

@@ -29,6 +29,23 @@ export const useAuthStore = defineStore('auth', () => {
router.push({ name: 'login' })
}
async function fetchRider() {
if (!token.value) return
try {
const { data } = await api.get<Rider>('/rider/profile')
rider.value = data
} catch {
// Token invalid — logout
logout()
}
}
async function updateRider(updates: Partial<Rider>) {
const { data } = await api.put<Rider>('/rider/profile', updates)
rider.value = data
return data
}
async function loginWithTelegram(data: TelegramLoginData) {
const { data: response } = await api.post<AuthResponse>('/auth/telegram-login', data)
setAuth(response)
@@ -41,5 +58,5 @@ export const useAuthStore = defineStore('auth', () => {
router.push({ name: 'dashboard' })
}
return { token, rider, isAuthenticated, loginWithTelegram, loginWithWebApp, logout }
return { token, rider, isAuthenticated, fetchRider, updateRider, loginWithTelegram, loginWithWebApp, logout }
})

View File

@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { CoachingChat, ChatListItem, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models'
export const useCoachingStore = defineStore('coaching', () => {
const { api } = useApi()
const currentChat = ref<CoachingChat | null>(null)
const activePlan = ref<TrainingPlan | null>(null)
const todayWorkout = ref<TodayWorkout | null>(null)
const loading = ref(false)
const sending = ref(false)
async function startOnboarding(): Promise<CoachingChat> {
loading.value = true
try {
const { data } = await api.post<CoachingChat>('/coaching/onboarding/start')
currentChat.value = data
return data
} finally {
loading.value = false
}
}
async function getOnboardingStatus(): Promise<{ onboarding_completed: boolean; coaching_profile: Record<string, unknown> | null }> {
const { data } = await api.get('/coaching/onboarding/status')
return data
}
async function sendMessage(chatId: string, message: string): Promise<CoachingChat> {
sending.value = true
try {
const { data } = await api.post<CoachingChat & { response: string }>(`/coaching/chat/${chatId}/message`, { message })
currentChat.value = data
return data
} finally {
sending.value = false
}
}
async function createChat(): Promise<CoachingChat> {
const { data } = await api.post<CoachingChat>('/coaching/chat/new')
currentChat.value = data
return data
}
async function getChat(chatId: string): Promise<CoachingChat> {
const { data } = await api.get<CoachingChat>(`/coaching/chat/${chatId}`)
currentChat.value = data
return data
}
async function listChats(): Promise<ChatListItem[]> {
const { data } = await api.get<ChatListItem[]>('/coaching/chats')
return data
}
async function generatePlan(): Promise<TrainingPlan> {
loading.value = true
try {
const { data } = await api.post<TrainingPlan>('/coaching/plan/generate')
activePlan.value = data
return data
} finally {
loading.value = false
}
}
async function fetchActivePlan(): Promise<TrainingPlan | null> {
const { data } = await api.get<TrainingPlan | null>('/coaching/plan/active')
activePlan.value = data
return data
}
async function fetchCompliance(planId: string): Promise<ComplianceWeek[]> {
const { data } = await api.get<ComplianceWeek[]>(`/coaching/plan/${planId}/compliance`)
return data
}
async function fetchTodayWorkout(): Promise<TodayWorkout | null> {
const { data } = await api.get<TodayWorkout | null>('/coaching/today')
todayWorkout.value = data
return data
}
async function startPlanAdjustment(): Promise<CoachingChat & { response: string }> {
loading.value = true
try {
const { data } = await api.post<CoachingChat & { response: string }>('/coaching/plan/adjust')
currentChat.value = data
return data
} finally {
loading.value = false
}
}
return {
currentChat, activePlan, todayWorkout, loading, sending,
startOnboarding, getOnboardingStatus, sendMessage, createChat, getChat, listChats,
generatePlan, fetchActivePlan, fetchCompliance, fetchTodayWorkout, startPlanAdjustment,
}
})

View File

@@ -1,3 +1,31 @@
@import "tailwindcss";
@plugin "tailwindcss-primeui";
@import "primeicons/primeicons.css";
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}
.p-card {
border-radius: 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
transition: box-shadow 0.2s, border-color 0.2s;
}
.p-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.p-datatable .p-datatable-thead > tr > th {
background: #f9fafb;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
#route-map {
z-index: 0;
}

View File

@@ -10,6 +10,8 @@ export interface Rider {
zones_config: Record<string, unknown> | null
goals: string | null
experience_level: string | null
coaching_profile: Record<string, unknown> | null
onboarding_completed: boolean
}
export interface ActivityMetrics {
@@ -23,6 +25,17 @@ export interface ActivityMetrics {
max_hr: number | null
avg_cadence: number | null
avg_speed: number | null
calories: number | null
}
export interface Interval {
id: string
start_ts: string
end_ts: string
interval_type: string
avg_power: number | null
avg_hr: number | null
duration: number | null
}
export interface Activity {
@@ -35,6 +48,7 @@ export interface Activity {
distance: number | null
elevation_gain: number | null
metrics: ActivityMetrics | null
intervals: Interval[]
}
export interface DataPoint {
@@ -48,3 +62,140 @@ export interface DataPoint {
altitude: number | null
temperature: number | null
}
export interface ZoneItem {
zone: number
name: string
seconds: number
percentage: number
}
export interface PowerZoneItem extends ZoneItem {
min_watts: number
max_watts: number | null
}
export interface HrZoneItem extends ZoneItem {
min_bpm: number
max_bpm: number | null
}
export interface ZonesResponse {
power_zones: PowerZoneItem[]
hr_zones: HrZoneItem[]
}
export interface PowerCurveResponse {
curve: Record<number, number>
}
export interface DiaryEntry {
rider_notes: string | null
mood: string | null
rpe: number | null
sleep_hours: number | null
}
export interface WeeklyStats {
week: string
rides: number
duration: number
distance: number
tss: number
}
export interface PersonalRecord {
duration: number
power: number
activity_id: string
activity_name: string
date: string
}
export interface FitnessEntry {
date: string
ctl: number
atl: number
tsb: number
ramp_rate: number | null
}
export interface ChatMessage {
role: 'user' | 'model'
text: string
timestamp?: string
}
export interface CoachingChat {
chat_id: string
chat_type: string
status: string
messages: ChatMessage[]
onboarding_completed?: boolean
}
export interface ChatListItem {
id: string
chat_type: string
status: string
message_count: number
created_at: string | null
updated_at: string | null
last_message: string | null
}
export interface TrainingPlanWeekDay {
day: string
workout_type: string
title: string
description: string
duration_minutes: number
target_tss: number
target_if: number
}
export interface TrainingPlanWeek {
week_number: number
focus: string
target_tss: number
target_hours: number
days: TrainingPlanWeekDay[]
}
export interface TrainingPlan {
id: string
goal: string
start_date: string
end_date: string
phase: string | null
description: string | null
status: string
weeks: TrainingPlanWeek[]
}
export interface ComplianceWeek {
week_number: number
focus: string
planned_tss: number
actual_tss: number
planned_hours: number
actual_hours: number
planned_rides: number
actual_rides: number
adherence_pct: number
status: string
}
export interface TodayWorkout {
plan_id: string
plan_goal: string
week_number: number
week_focus: string
day: string
workout_type: string
title: string
description: string
duration_minutes: number
target_tss: number
target_if: number
}

View File

@@ -1,18 +1,135 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useActivitiesStore } from '../stores/activities'
import { useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import FileUpload from 'primevue/fileupload'
import Tag from 'primevue/tag'
import ProgressSpinner from 'primevue/progressspinner'
const store = useActivitiesStore()
const router = useRouter()
const uploading = ref(false)
const uploadError = ref('')
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatDistance(meters: number | null): string {
if (!meters) return '—'
return `${(meters / 1000).toFixed(1)} km`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
async function onUpload(event: any) {
const file = event.files?.[0]
if (!file) return
uploading.value = true
uploadError.value = ''
try {
const activity = await store.uploadFit(file)
router.push({ name: 'activity-detail', params: { id: activity.id } })
} catch (e: any) {
uploadError.value = e.response?.data?.detail || 'Upload failed'
} finally {
uploading.value = false
}
}
onMounted(() => {
store.fetchActivities()
})
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Activities</h1>
<Button label="Upload .FIT" icon="pi pi-upload" />
<FileUpload
mode="basic"
accept=".fit"
:auto="true"
choose-label="Upload .FIT"
choose-icon="pi pi-upload"
:custom-upload="true"
@uploader="onUpload"
/>
</div>
<div v-if="uploading" class="flex items-center gap-3 mb-4">
<ProgressSpinner style="width: 24px; height: 24px" />
<span class="text-surface-500 text-sm">Processing .FIT file...</span>
</div>
<p v-if="uploadError" class="text-red-500 text-sm mb-4">{{ uploadError }}</p>
<div v-if="store.loading && !uploading" class="flex justify-center py-12">
<ProgressSpinner style="width: 40px; height: 40px" />
</div>
<div v-else-if="store.activities.length === 0" class="text-surface-500 py-12 text-center">
No activities yet. Upload your first .FIT file!
</div>
<div v-else class="flex flex-col gap-3">
<Card
v-for="a in store.activities"
:key="a.id"
class="cursor-pointer hover:border-primary transition-colors"
@click="router.push({ name: 'activity-detail', params: { id: a.id } })"
>
<template #content>
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center gap-4">
<div>
<p class="font-semibold">{{ a.name || 'Ride' }}</p>
<p class="text-surface-500 text-sm">{{ formatDate(a.date) }}</p>
</div>
<Tag :value="a.activity_type" severity="info" class="text-xs" />
</div>
<div class="flex items-center gap-6 text-sm">
<div class="text-center">
<p class="text-surface-500 text-xs">Duration</p>
<p class="font-semibold">{{ formatDuration(a.duration) }}</p>
</div>
<div class="text-center">
<p class="text-surface-500 text-xs">Distance</p>
<p class="font-semibold">{{ formatDistance(a.distance) }}</p>
</div>
<div v-if="a.elevation_gain" class="text-center">
<p class="text-surface-500 text-xs">Elevation</p>
<p class="font-semibold">{{ a.elevation_gain }}m</p>
</div>
<div v-if="a.metrics?.avg_power" class="text-center">
<p class="text-surface-500 text-xs">Avg Power</p>
<p class="font-semibold">{{ a.metrics.avg_power }}W</p>
</div>
<div v-if="a.metrics?.normalized_power" class="text-center">
<p class="text-surface-500 text-xs">NP</p>
<p class="font-semibold">{{ a.metrics.normalized_power }}W</p>
</div>
<div v-if="a.metrics?.tss" class="text-center">
<p class="text-surface-500 text-xs">TSS</p>
<p class="font-semibold">{{ a.metrics.tss }}</p>
</div>
<div v-if="a.metrics?.avg_hr" class="text-center">
<p class="text-surface-500 text-xs">Avg HR</p>
<p class="font-semibold">{{ a.metrics.avg_hr }}</p>
</div>
</div>
</div>
</template>
</Card>
</div>
<Card>
<template #content>
<p class="text-surface-400">Activity list with filters coming soon</p>
</template>
</Card>
</div>
</template>

View File

@@ -1,39 +1,514 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { onMounted, ref, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useActivitiesStore } from '../stores/activities'
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, DiaryEntry } from '../types/models'
import Card from 'primevue/card'
import Tag from 'primevue/tag'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import InputNumber from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import {
TitleComponent, TooltipComponent, LegendComponent, GridComponent, DataZoomComponent, MarkLineComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, DataZoomComponent, MarkLineComponent])
const route = useRoute()
const router = useRouter()
const store = useActivitiesStore()
const activity = ref<Activity | null>(null)
const stream = ref<DataPoint[]>([])
const zones = ref<ZonesResponse | null>(null)
const powerCurve = ref<PowerCurveResponse | null>(null)
const loading = ref(true)
const aiSummary = ref('')
const aiLoading = ref(false)
// Diary
const diary = ref<DiaryEntry>({ rider_notes: null, mood: null, rpe: null, sleep_hours: null })
const diarySaving = ref(false)
const diarySaved = ref(false)
const moods = [
{ label: 'Great', value: 'great' },
{ label: 'Good', value: 'good' },
{ label: 'OK', value: 'ok' },
{ label: 'Tired', value: 'tired' },
{ label: 'Bad', value: 'bad' },
]
const hasGps = computed(() => stream.value.some(dp => dp.latitude && dp.longitude))
const hasAltitude = computed(() => stream.value.some(dp => dp.altitude != null))
const hasSpeed = computed(() => stream.value.some(dp => dp.speed != null && dp.speed > 0))
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`
}
function formatDistance(meters: number | null): string {
if (!meters) return '—'
return `${(meters / 1000).toFixed(1)} km`
}
function buildStreamChart() {
const times = stream.value.map(dp => new Date(dp.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
const powers = stream.value.map(dp => dp.power)
const hrs = stream.value.map(dp => dp.heart_rate)
const cadences = stream.value.map(dp => dp.cadence)
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: ['Power', 'HR', 'Cadence'], top: 0, textStyle: { color: '#6b7280' } },
grid: { left: 50, right: 30, top: 45, bottom: 60 },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20, bottom: 10 }],
xAxis: { type: 'category', data: times, axisLabel: { color: '#666', fontSize: 10 }, show: false },
yAxis: [
{ type: 'value', name: 'Watts', nameTextStyle: { color: '#6b7280' }, axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
{ type: 'value', name: 'bpm', nameTextStyle: { color: '#6b7280' }, axisLabel: { color: '#6b7280' }, splitLine: { show: false } },
],
series: [
{ name: 'Power', type: 'line', data: powers, showSymbol: false, lineStyle: { width: 1 }, itemStyle: { color: '#3b82f6' }, areaStyle: { color: 'rgba(59,130,246,0.08)' } },
{ name: 'HR', type: 'line', yAxisIndex: 1, data: hrs, showSymbol: false, lineStyle: { width: 1 }, itemStyle: { color: '#ef4444' } },
{ name: 'Cadence', type: 'line', yAxisIndex: 1, data: cadences, showSymbol: false, lineStyle: { width: 1, type: 'dashed' }, itemStyle: { color: '#10b981' } },
],
}
}
function buildElevationChart() {
const distances: number[] = []
let totalDist = 0
for (let i = 0; i < stream.value.length; i++) {
if (i > 0 && stream.value[i].speed != null) {
totalDist += (stream.value[i].speed || 0)
}
distances.push(totalDist / 1000)
}
const altitudes = stream.value.map(dp => dp.altitude)
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', formatter: (p: any) => `${p[0].name} km: ${p[0].value?.toFixed(0) ?? '—'}m` },
grid: { left: 50, right: 20, top: 10, bottom: 25 },
xAxis: {
type: 'category',
data: distances.map(d => d.toFixed(1)),
axisLabel: { color: '#6b7280', fontSize: 10, formatter: (v: string) => `${v}` },
boundaryGap: false,
},
yAxis: {
type: 'value', name: 'm',
nameTextStyle: { color: '#6b7280' },
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: '#e5e7eb' } },
},
series: [{
type: 'line',
data: altitudes,
showSymbol: false,
lineStyle: { width: 1.5, color: '#8b5cf6' },
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(139,92,246,0.25)' },
{ offset: 1, color: 'rgba(139,92,246,0.02)' },
],
},
},
}],
}
}
function buildSpeedChart() {
const times = stream.value.map(dp => new Date(dp.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
const speeds = stream.value.map(dp => dp.speed != null ? +(dp.speed * 3.6).toFixed(1) : null)
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', formatter: (p: any) => `${p[0].value?.toFixed(1) ?? '—'} km/h` },
grid: { left: 50, right: 20, top: 10, bottom: 50 },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20, bottom: 10 }],
xAxis: { type: 'category', data: times, show: false },
yAxis: {
type: 'value', name: 'km/h',
nameTextStyle: { color: '#6b7280' },
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: '#e5e7eb' } },
},
series: [{
type: 'line',
data: speeds,
showSymbol: false,
lineStyle: { width: 1, color: '#f59e0b' },
areaStyle: { color: 'rgba(245,158,11,0.08)' },
}],
}
}
function buildZonesChart() {
if (!zones.value?.power_zones?.length) return null
const z = zones.value.power_zones
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
grid: { left: 110, right: 30, top: 10, bottom: 10 },
xAxis: { type: 'value', axisLabel: { color: '#6b7280', formatter: (v: number) => `${v}%` }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
yAxis: { type: 'category', data: z.map(i => `Z${i.zone} ${i.name}`).reverse(), axisLabel: { color: '#6b7280', fontSize: 11 } },
series: [{
type: 'bar',
data: z.map(i => i.percentage).reverse(),
itemStyle: {
color: (params: any) => {
const colors = ['#22c55e', '#3b82f6', '#a855f7', '#f59e0b', '#ef4444', '#dc2626', '#991b1b']
return colors[z.length - 1 - params.dataIndex] || '#666'
},
},
}],
}
}
function buildHrZonesChart() {
if (!zones.value?.hr_zones?.length) return null
const z = zones.value.hr_zones
const hrColors = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#dc2626']
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
grid: { left: 110, right: 30, top: 10, bottom: 10 },
xAxis: { type: 'value', axisLabel: { color: '#6b7280', formatter: (v: number) => `${v}%` }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
yAxis: { type: 'category', data: z.map(i => `Z${i.zone} ${i.name}`).reverse(), axisLabel: { color: '#6b7280', fontSize: 11 } },
series: [{
type: 'bar',
data: z.map(i => i.percentage).reverse(),
itemStyle: {
color: (params: any) => hrColors[z.length - 1 - params.dataIndex] || '#666',
},
}],
}
}
function buildPowerCurveChart() {
if (!powerCurve.value?.curve) return null
const entries = Object.entries(powerCurve.value.curve)
.map(([dur, pow]) => [Number(dur), pow] as [number, number])
.sort((a, b) => a[0] - b[0])
const labels = entries.map(([d]) => {
if (d < 60) return `${d}s`
if (d < 3600) return `${d / 60}m`
return `${d / 3600}h`
})
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', formatter: (p: any) => `${p[0].name}: ${p[0].value}W` },
grid: { left: 50, right: 30, top: 10, bottom: 30 },
xAxis: { type: 'category', data: labels, axisLabel: { color: '#6b7280' } },
yAxis: { type: 'value', name: 'W', nameTextStyle: { color: '#6b7280' }, axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
series: [{
type: 'line',
data: entries.map(([, p]) => p),
showSymbol: true,
symbolSize: 6,
areaStyle: { color: 'rgba(168,85,247,0.12)' },
itemStyle: { color: '#a855f7' },
lineStyle: { width: 2 },
}],
}
}
function initMap() {
const gpsPoints = stream.value.filter(dp => dp.latitude && dp.longitude)
if (gpsPoints.length < 2) return
const map = L.map('route-map')
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map)
const coords: L.LatLngExpression[] = gpsPoints.map(dp => [dp.latitude!, dp.longitude!])
const polyline = L.polyline(coords, { color: '#3b82f6', weight: 3, opacity: 0.8 }).addTo(map)
L.circleMarker(coords[0] as L.LatLngExpression, { radius: 6, color: '#22c55e', fillColor: '#22c55e', fillOpacity: 1 }).addTo(map)
L.circleMarker(coords[coords.length - 1] as L.LatLngExpression, { radius: 6, color: '#ef4444', fillColor: '#ef4444', fillOpacity: 1 }).addTo(map)
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
}
async function loadAiSummary() {
if (!activity.value || aiSummary.value) return
aiLoading.value = true
try {
aiSummary.value = await store.fetchAiSummary(activity.value.id)
} catch {
aiSummary.value = 'Failed to generate summary. Check your Gemini API key.'
} finally {
aiLoading.value = false
}
}
async function saveDiary() {
if (!activity.value) return
diarySaving.value = true
diarySaved.value = false
try {
diary.value = await store.updateDiary(activity.value.id, diary.value)
diarySaved.value = true
setTimeout(() => { diarySaved.value = false }, 2000)
} finally {
diarySaving.value = false
}
}
async function handleDelete() {
if (!activity.value) return
if (!confirm('Delete this activity? This cannot be undone.')) return
await store.deleteActivity(activity.value.id)
router.push({ name: 'activities' })
}
onMounted(async () => {
const id = route.params.id as string
try {
const [act, str, zn, pc, di] = await Promise.all([
store.fetchActivity(id),
store.fetchStream(id),
store.fetchZones(id),
store.fetchPowerCurve(id),
store.fetchDiary(id),
])
activity.value = act
stream.value = str
zones.value = zn
powerCurve.value = pc
diary.value = di
} finally {
loading.value = false
}
// Init map after loading=false triggers DOM render of #route-map
if (stream.value.some(dp => dp.latitude && dp.longitude)) {
await nextTick()
initMap()
}
})
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Activity Detail</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<template #title>Power / HR Chart</template>
<div v-if="loading" class="flex justify-center py-20">
<ProgressSpinner style="width: 50px; height: 50px" />
</div>
<div v-else-if="activity">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<h1 class="text-2xl font-semibold">{{ activity.name || 'Ride' }}</h1>
<Tag :value="activity.activity_type" severity="info" />
</div>
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" />
</div>
<!-- Key metrics -->
<div class="grid grid-cols-3 md:grid-cols-6 gap-2 md:gap-3 mb-6">
<Card>
<template #content>
<p class="text-surface-500 text-xs uppercase">Duration</p>
<p class="text-lg font-bold">{{ formatDuration(activity.duration) }}</p>
</template>
</Card>
<Card>
<template #content>
<p class="text-surface-500 text-xs uppercase">Distance</p>
<p class="text-lg font-bold">{{ formatDistance(activity.distance) }}</p>
</template>
</Card>
<Card v-if="activity.metrics?.avg_power">
<template #content>
<p class="text-surface-500 text-xs uppercase">Avg / NP</p>
<p class="text-lg font-bold">{{ activity.metrics.avg_power }} / {{ activity.metrics.normalized_power }}W</p>
</template>
</Card>
<Card v-if="activity.metrics?.tss">
<template #content>
<p class="text-surface-500 text-xs uppercase">TSS / IF</p>
<p class="text-lg font-bold">{{ activity.metrics.tss }} / {{ activity.metrics.intensity_factor }}</p>
</template>
</Card>
<Card v-if="activity.metrics?.avg_hr">
<template #content>
<p class="text-surface-500 text-xs uppercase">Avg / Max HR</p>
<p class="text-lg font-bold">{{ activity.metrics.avg_hr }} / {{ activity.metrics.max_hr }}</p>
</template>
</Card>
<Card v-if="activity.elevation_gain">
<template #content>
<p class="text-surface-500 text-xs uppercase">Elevation</p>
<p class="text-lg font-bold">{{ activity.elevation_gain }}m</p>
</template>
</Card>
</div>
<!-- AI Summary -->
<Card class="mb-6">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-sparkles text-primary"></i>
AI Coach Summary
</div>
</template>
<template #content>
<p class="text-surface-400">ECharts coming soon</p>
<div v-if="aiSummary" v-html="aiSummary" class="text-surface-700 leading-relaxed whitespace-pre-line"></div>
<div v-else-if="aiLoading" class="flex items-center gap-3">
<ProgressSpinner style="width: 20px; height: 20px" />
<span class="text-surface-500 text-sm">Analyzing your ride...</span>
</div>
<div v-else>
<Button label="Generate AI Analysis" icon="pi pi-sparkles" severity="secondary" outlined size="small" @click="loadAiSummary" />
</div>
</template>
</Card>
<Card>
<template #title>Route Map</template>
<!-- Route Map -->
<Card v-if="hasGps" class="mb-6">
<template #title>Route</template>
<template #content>
<p class="text-surface-400">Leaflet map coming soon</p>
<div id="route-map" style="height: 350px; border-radius: 8px;"></div>
</template>
</Card>
<Card>
<template #title>Metrics</template>
<!-- Elevation Profile -->
<Card v-if="hasAltitude" class="mb-6">
<template #title>Elevation Profile</template>
<template #content>
<p class="text-surface-400">NP, IF, TSS, zones coming soon</p>
<VChart :option="buildElevationChart()" style="height: 200px" autoresize />
</template>
</Card>
<Card>
<!-- Speed Profile -->
<Card v-if="hasSpeed" class="mb-6">
<template #title>Speed</template>
<template #content>
<VChart :option="buildSpeedChart()" style="height: 200px" autoresize />
</template>
</Card>
<!-- Power/HR/Cadence chart -->
<Card v-if="stream.length > 0" class="mb-6">
<template #title>Power / HR / Cadence</template>
<template #content>
<VChart :option="buildStreamChart()" style="height: 300px" autoresize />
</template>
</Card>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Power zones -->
<Card v-if="zones?.power_zones?.length">
<template #title>Power Zones</template>
<template #content>
<VChart :option="buildZonesChart()" style="height: 250px" autoresize />
</template>
</Card>
<!-- HR zones -->
<Card v-if="zones?.hr_zones?.length">
<template #title>Heart Rate Zones</template>
<template #content>
<VChart :option="buildHrZonesChart()" style="height: 200px" autoresize />
</template>
</Card>
<!-- Power curve -->
<Card v-if="powerCurve?.curve && Object.keys(powerCurve.curve).length > 0">
<template #title>Power Curve</template>
<template #content>
<VChart :option="buildPowerCurveChart()" style="height: 250px" autoresize />
</template>
</Card>
</div>
<!-- Intervals table -->
<Card v-if="activity.intervals?.length" class="mb-6">
<template #title>Intervals</template>
<template #content>
<p class="text-surface-400">Detected intervals table coming soon</p>
<DataTable :value="activity.intervals" size="small" stripedRows>
<Column field="interval_type" header="Type">
<template #body="{ data }">
<Tag :value="data.interval_type" :severity="data.interval_type === 'work' ? 'danger' : 'success'" />
</template>
</Column>
<Column header="Duration">
<template #body="{ data }">{{ formatDuration(data.duration || 0) }}</template>
</Column>
<Column field="avg_power" header="Avg Power">
<template #body="{ data }">{{ data.avg_power ? `${data.avg_power}W` : '—' }}</template>
</Column>
<Column field="avg_hr" header="Avg HR">
<template #body="{ data }">{{ data.avg_hr ? `${data.avg_hr}bpm` : '—' }}</template>
</Column>
</DataTable>
</template>
</Card>
<!-- Training Diary -->
<Card class="mb-6">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-book text-primary"></i>
Training Diary
</div>
</template>
<template #content>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Mood</label>
<Select
v-model="diary.mood"
:options="moods"
option-label="label"
option-value="value"
placeholder="How did you feel?"
class="w-full"
/>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">RPE (1-10)</label>
<InputNumber v-model="diary.rpe" :min="1" :max="10" class="w-full" placeholder="Rate of Perceived Exertion" />
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Sleep (hours)</label>
<InputNumber v-model="diary.sleep_hours" :min="0" :max="24" :max-fraction-digits="1" class="w-full" placeholder="Hours slept" />
</div>
</div>
<div class="mb-4">
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Notes</label>
<Textarea v-model="diary.rider_notes" rows="3" class="w-full" placeholder="How was the ride? Any observations..." />
</div>
<div class="flex items-center gap-3">
<Button label="Save" icon="pi pi-check" size="small" :loading="diarySaving" @click="saveDiary" />
<span v-if="diarySaved" class="text-green-600 text-sm">Saved!</span>
</div>
</template>
</Card>
</div>
<p class="text-surface-500 text-sm mt-4">ID: {{ route.params.id }}</p>
</div>
</template>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { onMounted, ref, computed, nextTick } from 'vue'
import { useCoachingStore } from '../stores/coaching'
import { useAuthStore } from '../stores/auth'
import type { ChatMessage, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models'
import Card from 'primevue/card'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
const coaching = useCoachingStore()
const auth = useAuthStore()
const activeTab = ref<'chat' | 'plan' | 'progress'>('chat')
const messageInput = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
const compliance = ref<ComplianceWeek[]>([])
const messages = computed<ChatMessage[]>(() => coaching.currentChat?.messages || [])
const plan = computed<TrainingPlan | null>(() => coaching.activePlan)
const todayWorkout = computed<TodayWorkout | null>(() => coaching.todayWorkout)
const onboardingCompleted = ref(false)
const chatReady = ref(false)
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
async function sendMessage() {
const text = messageInput.value.trim()
if (!text || !coaching.currentChat?.chat_id) return
messageInput.value = ''
await coaching.sendMessage(coaching.currentChat.chat_id, text)
scrollToBottom()
// Check if onboarding just completed
if (coaching.currentChat?.onboarding_completed && !onboardingCompleted.value) {
onboardingCompleted.value = true
await auth.fetchRider()
}
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
await sendMessage()
}
}
async function startNewChat() {
await coaching.createChat()
scrollToBottom()
}
async function handleGeneratePlan() {
await coaching.generatePlan()
if (coaching.activePlan) {
activeTab.value = 'plan'
await loadCompliance()
}
}
async function handleAdjustPlan() {
await coaching.startPlanAdjustment()
activeTab.value = 'chat'
scrollToBottom()
}
async function loadCompliance() {
if (plan.value?.id) {
try {
compliance.value = await coaching.fetchCompliance(plan.value.id)
} catch { /* no compliance data */ }
}
}
function workoutTypeColor(type: string): string {
const colors: Record<string, string> = {
rest: 'secondary',
recovery: 'secondary',
endurance: 'info',
tempo: 'warn',
sweetspot: 'warn',
threshold: 'danger',
vo2max: 'danger',
sprint: 'contrast',
race: 'contrast',
}
return colors[type] || 'info'
}
function workoutTypeLabel(type: string): string {
const labels: Record<string, string> = {
rest: 'Отдых',
recovery: 'Восстановление',
endurance: 'Выносливость',
tempo: 'Темпо',
sweetspot: 'Sweet Spot',
threshold: 'Порог',
vo2max: 'VO2max',
sprint: 'Спринт',
race: 'Гонка',
}
return labels[type] || type
}
function dayLabel(day: string): string {
const labels: Record<string, string> = {
monday: 'Пн',
tuesday: 'Вт',
wednesday: 'Ср',
thursday: 'Чт',
friday: 'Пт',
saturday: 'Сб',
sunday: 'Вс',
}
return labels[day] || day
}
onMounted(async () => {
// Check onboarding status
try {
const status = await coaching.getOnboardingStatus()
onboardingCompleted.value = status.onboarding_completed
if (!status.onboarding_completed) {
// Start or resume onboarding
await coaching.startOnboarding()
chatReady.value = true
scrollToBottom()
} else {
// Load active plan
await coaching.fetchActivePlan()
await coaching.fetchTodayWorkout()
if (plan.value) {
await loadCompliance()
}
// Start a general chat
await coaching.createChat()
chatReady.value = true
}
} catch {
chatReady.value = true
}
})
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">AI Тренер</h1>
<div v-if="onboardingCompleted" class="flex gap-2">
<Button
:label="activeTab === 'chat' ? '' : 'Чат'"
icon="pi pi-comments"
:severity="activeTab === 'chat' ? 'primary' : 'secondary'"
:text="activeTab !== 'chat'"
size="small"
@click="activeTab = 'chat'"
/>
<Button
:label="activeTab === 'plan' ? '' : 'План'"
icon="pi pi-calendar"
:severity="activeTab === 'plan' ? 'primary' : 'secondary'"
:text="activeTab !== 'plan'"
size="small"
@click="activeTab = 'plan'"
/>
<Button
:label="activeTab === 'progress' ? '' : 'Прогресс'"
icon="pi pi-chart-bar"
:severity="activeTab === 'progress' ? 'primary' : 'secondary'"
:text="activeTab !== 'progress'"
size="small"
@click="activeTab = 'progress'"
/>
</div>
</div>
<!-- Today's workout card -->
<Card v-if="todayWorkout && onboardingCompleted" class="mb-6 border-l-4 border-l-primary">
<template #content>
<div class="flex items-center justify-between">
<div>
<p class="text-surface-500 text-xs uppercase mb-1">Тренировка на сегодня</p>
<p class="text-lg font-bold">{{ todayWorkout.title }}</p>
<p class="text-surface-600 text-sm mt-1">{{ todayWorkout.description }}</p>
</div>
<div class="flex items-center gap-3">
<Tag :value="workoutTypeLabel(todayWorkout.workout_type)" :severity="workoutTypeColor(todayWorkout.workout_type)" />
<div v-if="todayWorkout.duration_minutes" class="text-right">
<p class="text-sm text-surface-500">{{ todayWorkout.duration_minutes }} мин</p>
<p v-if="todayWorkout.target_tss" class="text-xs text-surface-400">TSS {{ todayWorkout.target_tss }}</p>
</div>
</div>
</div>
</template>
</Card>
<!-- Onboarding banner -->
<Card v-if="!onboardingCompleted && chatReady" class="mb-4 bg-blue-50 border-blue-200">
<template #content>
<div class="flex items-center gap-3">
<i class="pi pi-info-circle text-blue-500 text-xl"></i>
<div>
<p class="font-semibold text-blue-800">Интервью с тренером</p>
<p class="text-blue-600 text-sm">Ответьте на несколько вопросов, чтобы AI тренер мог создать персональный план тренировок.</p>
</div>
</div>
</template>
</Card>
<!-- Chat Tab -->
<div v-if="activeTab === 'chat'" class="flex flex-col" style="height: calc(100vh - 300px); min-height: 400px;">
<!-- Chat actions -->
<div v-if="onboardingCompleted" class="flex gap-2 mb-3">
<Button label="Новый чат" icon="pi pi-plus" size="small" severity="secondary" outlined @click="startNewChat" />
<Button v-if="!plan" label="Сгенерировать план" icon="pi pi-bolt" size="small" severity="primary" :loading="coaching.loading" @click="handleGeneratePlan" />
<Button v-if="plan" label="Корректировать план" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto space-y-3 p-4 bg-surface-50 rounded-xl border border-surface-200">
<div v-if="coaching.loading && messages.length === 0" class="flex items-center justify-center h-full text-surface-400">
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
Загрузка...
</div>
<div v-for="(msg, i) in messages" :key="i" :class="['flex', msg.role === 'user' ? 'justify-end' : 'justify-start']">
<div
:class="[
'max-w-[80%] md:max-w-[70%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
msg.role === 'user'
? 'bg-primary text-white rounded-br-md'
: 'bg-white border border-surface-200 text-surface-800 rounded-bl-md shadow-sm'
]"
>
<div v-if="msg.role === 'model'" class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold text-primary">VeloBrain</span>
</div>
<div class="whitespace-pre-wrap" v-html="formatMessage(msg.text)"></div>
</div>
</div>
<div v-if="coaching.sending" class="flex justify-start">
<div class="bg-white border border-surface-200 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div class="flex items-center gap-2 text-surface-400 text-sm">
<i class="pi pi-spin pi-spinner"></i>
Думаю...
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="flex gap-2 mt-3">
<InputText
v-model="messageInput"
:placeholder="onboardingCompleted ? 'Задайте вопрос тренеру...' : 'Ваш ответ...'"
class="flex-1"
:disabled="coaching.sending"
@keydown="handleKeydown"
/>
<Button
icon="pi pi-send"
:disabled="!messageInput.trim() || coaching.sending"
@click="sendMessage"
/>
</div>
</div>
<!-- Plan Tab -->
<div v-if="activeTab === 'plan'">
<div v-if="!plan" class="text-center py-12">
<i class="pi pi-calendar text-4xl text-surface-300 mb-4"></i>
<p class="text-surface-500 mb-4">План тренировок ещё не создан</p>
<Button label="Сгенерировать план" icon="pi pi-bolt" :loading="coaching.loading" @click="handleGeneratePlan" />
</div>
<div v-else>
<!-- Plan header -->
<Card class="mb-6">
<template #content>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h2 class="text-xl font-bold">{{ plan.goal }}</h2>
<p class="text-surface-500 text-sm mt-1">{{ plan.description }}</p>
<div class="flex items-center gap-3 mt-2">
<Tag :value="plan.phase || 'base'" severity="info" />
<span class="text-sm text-surface-500">
{{ new Date(plan.start_date).toLocaleDateString('ru-RU') }} —
{{ new Date(plan.end_date).toLocaleDateString('ru-RU') }}
</span>
</div>
</div>
<div class="flex gap-2">
<Button label="Корректировать" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
<Button label="Новый план" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="coaching.loading" @click="handleGeneratePlan" />
</div>
</div>
</template>
</Card>
<!-- Weeks -->
<div v-for="week in plan.weeks" :key="week.week_number" class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-lg">Неделя {{ week.week_number }}</h3>
<div class="flex items-center gap-3 text-sm text-surface-500">
<span>{{ week.focus }}</span>
<span v-if="week.target_tss">TSS {{ week.target_tss }}</span>
<span v-if="week.target_hours">{{ week.target_hours }}ч</span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<Card v-for="day in week.days" :key="day.day" class="!shadow-none border border-surface-200">
<template #content>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-surface-600">{{ dayLabel(day.day) }}</span>
<Tag :value="workoutTypeLabel(day.workout_type)" :severity="workoutTypeColor(day.workout_type)" class="text-xs" />
</div>
<p class="font-medium text-sm">{{ day.title }}</p>
<p v-if="day.description" class="text-xs text-surface-500 mt-1">{{ day.description }}</p>
<div v-if="day.duration_minutes > 0" class="flex items-center gap-3 mt-2 text-xs text-surface-400">
<span>{{ day.duration_minutes }} мин</span>
<span v-if="day.target_tss">TSS {{ day.target_tss }}</span>
<span v-if="day.target_if">IF {{ day.target_if }}</span>
</div>
</template>
</Card>
</div>
</div>
</div>
</div>
<!-- Progress Tab -->
<div v-if="activeTab === 'progress'">
<div v-if="compliance.length === 0" class="text-center py-12">
<i class="pi pi-chart-bar text-4xl text-surface-300 mb-4"></i>
<p class="text-surface-500">Нет данных о прогрессе</p>
</div>
<div v-else class="space-y-4">
<Card v-for="week in compliance" :key="week.week_number">
<template #content>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold">Неделя {{ week.week_number }}</h3>
<Tag
:value="week.status === 'upcoming' ? 'Впереди' : week.status === 'current' ? 'Текущая' : 'Завершена'"
:severity="week.status === 'upcoming' ? 'secondary' : week.status === 'current' ? 'info' : 'success'"
class="text-xs"
/>
<span class="text-sm text-surface-500">{{ week.focus }}</span>
</div>
<div class="mb-2">
<div class="flex items-center justify-between text-sm mb-1">
<span class="text-surface-500">Выполнение</span>
<span class="font-semibold">{{ week.adherence_pct }}%</span>
</div>
<ProgressBar
:value="week.adherence_pct"
:showValue="false"
style="height: 8px"
:class="week.adherence_pct >= 80 ? '' : week.adherence_pct >= 50 ? '[&_.p-progressbar-value]:!bg-amber-500' : '[&_.p-progressbar-value]:!bg-red-500'"
/>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<p class="text-surface-500 text-xs">Тренировки</p>
<p class="font-semibold">{{ week.actual_rides }}/{{ week.planned_rides }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">TSS</p>
<p class="font-semibold">{{ week.actual_tss }}/{{ week.planned_tss }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">Часы</p>
<p class="font-semibold">{{ week.actual_hours }}/{{ week.planned_hours }}</p>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</template>
<script lang="ts">
function formatMessage(text: string): string {
// Strip [ONBOARDING_COMPLETE] and JSON blocks from display
let cleaned = text.replace(/\[ONBOARDING_COMPLETE\]/g, '')
cleaned = cleaned.replace(/```json[\s\S]*?```/g, '')
cleaned = cleaned.replace(/\[PLAN_ADJUSTED\]/g, '')
return cleaned.trim()
}
</script>

View File

@@ -1,27 +1,318 @@
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useActivitiesStore } from '../stores/activities'
import { useCoachingStore } from '../stores/coaching'
import type { FitnessEntry, WeeklyStats, PersonalRecord, TodayWorkout } from '../types/models'
import Card from 'primevue/card'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
import { useRouter } from 'vue-router'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import {
TooltipComponent, LegendComponent, GridComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
const auth = useAuthStore()
const store = useActivitiesStore()
const coaching = useCoachingStore()
const router = useRouter()
const recentActivities = computed(() => store.activities.slice(0, 5))
const fitness = ref<FitnessEntry[]>([])
const weeklyStats = ref<WeeklyStats[]>([])
const records = ref<PersonalRecord[]>([])
const todayWorkout = ref<TodayWorkout | null>(null)
function formatDurationLabel(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${seconds / 60}m`
return `${seconds / 3600}h`
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatDistance(meters: number | null): string {
if (!meters) return '—'
return `${(meters / 1000).toFixed(1)} km`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
function buildFitnessChart() {
if (fitness.value.length === 0) return null
const dates = fitness.value.map(e => e.date)
const ctlData = fitness.value.map(e => e.ctl)
const atlData = fitness.value.map(e => e.atl)
const tsbData = fitness.value.map(e => e.tsb)
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const date = params[0].name
return `<b>${date}</b><br/>` +
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value.toFixed(1)}</b>`).join('<br/>')
},
},
legend: { data: ['Fitness (CTL)', 'Fatigue (ATL)', 'Form (TSB)'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
grid: { left: 40, right: 20, top: 40, bottom: 30 },
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: '#6b7280', fontSize: 10, formatter: (v: string) => { const d = new Date(v); return `${d.getDate()}/${d.getMonth() + 1}` } },
splitLine: { show: false },
},
yAxis: { type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
series: [
{ name: 'Fitness (CTL)', type: 'line', data: ctlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#3b82f6' } },
{ name: 'Fatigue (ATL)', type: 'line', data: atlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#ef4444' } },
{
name: 'Form (TSB)', type: 'line', data: tsbData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#10b981' },
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(16,185,129,0.15)' },
{ offset: 0.5, color: 'rgba(16,185,129,0)' },
{ offset: 0.5, color: 'rgba(239,68,68,0)' },
{ offset: 1, color: 'rgba(239,68,68,0.1)' },
],
},
},
},
],
}
}
function buildWeeklyChart() {
if (weeklyStats.value.length === 0) return null
const weeks = weeklyStats.value.map(w => {
const d = new Date(w.week)
return `${d.getDate()}/${d.getMonth() + 1}`
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const week = params[0].name
return `<b>Week of ${week}</b><br/>` +
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value}</b>`).join('<br/>')
},
},
legend: { data: ['Distance (km)', 'TSS', 'Hours'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
grid: { left: 40, right: 40, top: 40, bottom: 30 },
xAxis: { type: 'category', data: weeks, axisLabel: { color: '#6b7280', fontSize: 10 } },
yAxis: [
{ type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
{ type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { show: false } },
],
series: [
{
name: 'Distance (km)', type: 'bar', data: weeklyStats.value.map(w => w.distance),
itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
},
{
name: 'TSS', type: 'bar', data: weeklyStats.value.map(w => w.tss),
itemStyle: { color: '#a855f7', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
},
{
name: 'Hours', type: 'line', yAxisIndex: 1,
data: weeklyStats.value.map(w => +(w.duration / 3600).toFixed(1)),
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
},
],
}
}
onMounted(async () => {
store.fetchActivities(5)
try {
fitness.value = await store.fetchFitness(90)
} catch {
// Fitness data may not be available yet
}
try {
weeklyStats.value = await store.fetchWeeklyStats(8)
} catch {
// Weekly stats may not be available yet
}
try {
records.value = await store.fetchPersonalRecords()
} catch {
// PR data may not be available yet
}
try {
todayWorkout.value = await coaching.fetchTodayWorkout()
} catch {
// No active plan
}
})
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Rider stats cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-8">
<Card>
<template #title>Current Form</template>
<template #content>
<p class="text-surface-400">CTL / ATL / TSB coming soon</p>
<p class="text-surface-500 text-xs uppercase mb-1">FTP</p>
<p class="text-2xl font-bold">{{ auth.rider?.ftp ?? '—' }} <span class="text-sm text-surface-500 font-normal">W</span></p>
</template>
</Card>
<Card>
<template #title>Weekly Load</template>
<template #content>
<p class="text-surface-400">Weekly TSS summary coming soon</p>
<p class="text-surface-500 text-xs uppercase mb-1">Weight</p>
<p class="text-2xl font-bold">{{ auth.rider?.weight ?? '—' }} <span class="text-sm text-surface-500 font-normal">kg</span></p>
</template>
</Card>
<Card>
<template #title>Recent Rides</template>
<template #content>
<p class="text-surface-400">Last 5 activities coming soon</p>
<p class="text-surface-500 text-xs uppercase mb-1">W/kg</p>
<p class="text-2xl font-bold">
{{ auth.rider?.ftp && auth.rider?.weight ? (auth.rider.ftp / auth.rider.weight).toFixed(2) : '—' }}
</p>
</template>
</Card>
<Card>
<template #content>
<p class="text-surface-500 text-xs uppercase mb-1">Total Rides</p>
<p class="text-2xl font-bold">{{ store.total }}</p>
</template>
</Card>
</div>
<!-- Today's Workout -->
<Card v-if="todayWorkout" class="mb-8 border-l-4 border-l-primary cursor-pointer hover:shadow-md transition-shadow" @click="router.push({ name: 'coach' })">
<template #content>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2 mb-1">
<i class="pi pi-bolt text-primary"></i>
<p class="text-surface-500 text-xs uppercase">Тренировка на сегодня</p>
</div>
<p class="text-lg font-bold">{{ todayWorkout.title }}</p>
<p v-if="todayWorkout.description" class="text-surface-500 text-sm mt-1 line-clamp-2">{{ todayWorkout.description }}</p>
</div>
<div class="flex items-center gap-3">
<Tag :value="todayWorkout.workout_type" severity="info" />
<div v-if="todayWorkout.duration_minutes" class="text-right">
<p class="text-sm font-semibold">{{ todayWorkout.duration_minutes }} мин</p>
<p v-if="todayWorkout.target_tss" class="text-xs text-surface-400">TSS {{ todayWorkout.target_tss }}</p>
</div>
<i class="pi pi-chevron-right text-surface-300"></i>
</div>
</div>
</template>
</Card>
<!-- Personal Records -->
<Card v-if="records.length > 0" class="mb-8">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-trophy text-amber-500"></i>
Personal Records
</div>
</template>
<template #content>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div
v-for="pr in records"
:key="pr.duration"
class="bg-surface-50 rounded-lg p-3 text-center border border-surface-200 cursor-pointer hover:border-primary transition-colors"
@click="router.push({ name: 'activity-detail', params: { id: pr.activity_id } })"
>
<p class="text-surface-500 text-xs uppercase mb-1">{{ formatDurationLabel(pr.duration) }}</p>
<p class="text-xl font-bold">{{ pr.power }}<span class="text-sm font-normal text-surface-500">W</span></p>
<p v-if="auth.rider?.weight" class="text-xs text-surface-400">{{ (pr.power / auth.rider.weight).toFixed(1) }} W/kg</p>
</div>
</div>
</template>
</Card>
<!-- Fitness trend chart -->
<Card v-if="fitness.length > 0" class="mb-8">
<template #title>Fitness Trend</template>
<template #content>
<VChart :option="buildFitnessChart()" style="height: 280px" autoresize />
<div class="flex items-center justify-center gap-6 mt-2 text-xs text-surface-500">
<span>CTL: chronic training load (fitness)</span>
<span>ATL: acute training load (fatigue)</span>
<span>TSB: training stress balance (form)</span>
</div>
</template>
</Card>
<!-- Weekly summary -->
<Card v-if="weeklyStats.length > 0" class="mb-8">
<template #title>Weekly Summary</template>
<template #content>
<VChart :option="buildWeeklyChart()" style="height: 280px" autoresize />
</template>
</Card>
<!-- Recent rides -->
<h2 class="text-lg font-semibold mb-4">Recent Rides</h2>
<div v-if="recentActivities.length === 0" class="text-surface-500">
No activities yet. Upload your first .FIT file!
</div>
<div v-else class="flex flex-col gap-3">
<Card
v-for="a in recentActivities"
:key="a.id"
class="cursor-pointer hover:border-primary transition-colors"
@click="router.push({ name: 'activity-detail', params: { id: a.id } })"
>
<template #content>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="flex items-center gap-3">
<div>
<p class="font-semibold">{{ a.name || 'Ride' }}</p>
<p class="text-surface-500 text-sm">{{ formatDate(a.date) }}</p>
</div>
<Tag :value="a.activity_type" severity="info" class="text-xs" />
</div>
<div class="flex items-center gap-4 md:gap-6 text-sm flex-wrap">
<div class="text-center">
<p class="text-surface-500 text-xs">Duration</p>
<p class="font-semibold">{{ formatDuration(a.duration) }}</p>
</div>
<div class="text-center">
<p class="text-surface-500 text-xs">Distance</p>
<p class="font-semibold">{{ formatDistance(a.distance) }}</p>
</div>
<div v-if="a.metrics?.avg_power" class="text-center">
<p class="text-surface-500 text-xs">Power</p>
<p class="font-semibold">{{ a.metrics.avg_power }}W</p>
</div>
<div v-if="a.metrics?.tss" class="text-center">
<p class="text-surface-500 text-xs">TSS</p>
<p class="font-semibold">{{ a.metrics.tss }}</p>
</div>
<div v-if="a.metrics?.normalized_power" class="text-center hidden md:block">
<p class="text-surface-500 text-xs">NP</p>
<p class="font-semibold">{{ a.metrics.normalized_power }}W</p>
</div>
</div>
</div>
</template>
</Card>
</div>

View File

@@ -61,17 +61,17 @@ onMounted(async () => {
<template #title>
<div class="text-center">
<span class="text-primary text-3xl font-bold">VeloBrain</span>
<p class="text-surface-400 text-sm mt-2">AI-Powered Cycling Training Platform</p>
<p class="text-surface-500 text-sm mt-2">AI-Powered Cycling Training Platform</p>
</div>
</template>
<template #content>
<div class="flex flex-col items-center gap-4">
<div v-if="loading" class="flex flex-col items-center gap-3">
<ProgressSpinner style="width: 50px; height: 50px" />
<p class="text-surface-400 text-sm">Signing in via Telegram...</p>
<p class="text-surface-500 text-sm">Signing in via Telegram...</p>
</div>
<div v-else>
<p v-if="error" class="text-red-400 text-sm mb-4 text-center">{{ error }}</p>
<p v-if="error" class="text-red-500 text-sm mb-4 text-center">{{ error }}</p>
<div id="telegram-login-container" class="flex justify-center"></div>
</div>
</div>

View File

@@ -1,14 +1,139 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import Card from 'primevue/card'
import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
import Message from 'primevue/message'
const auth = useAuthStore()
const form = ref({
name: '',
ftp: null as number | null,
lthr: null as number | null,
weight: null as number | null,
goals: '',
experience_level: '',
})
const saving = ref(false)
const success = ref(false)
const experienceLevels = [
{ label: 'Beginner', value: 'beginner' },
{ label: 'Intermediate', value: 'intermediate' },
{ label: 'Advanced', value: 'advanced' },
{ label: 'Pro', value: 'pro' },
]
onMounted(() => {
if (auth.rider) {
form.value.name = auth.rider.name || ''
form.value.ftp = auth.rider.ftp
form.value.lthr = auth.rider.lthr
form.value.weight = auth.rider.weight
form.value.goals = auth.rider.goals || ''
form.value.experience_level = auth.rider.experience_level || ''
}
})
async function save() {
saving.value = true
success.value = false
try {
await auth.updateRider({
name: form.value.name || undefined,
ftp: form.value.ftp,
lthr: form.value.lthr,
weight: form.value.weight,
goals: form.value.goals || null,
experience_level: form.value.experience_level || null,
})
success.value = true
setTimeout(() => { success.value = false }, 3000)
} finally {
saving.value = false
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<Card>
<template #content>
<p class="text-surface-400">FTP, weight, zones, goals coming soon</p>
</template>
</Card>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Profile -->
<Card>
<template #title>Profile</template>
<template #content>
<div class="flex flex-col gap-4">
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Name</label>
<InputText v-model="form.name" class="w-full" />
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Experience Level</label>
<Select
v-model="form.experience_level"
:options="experienceLevels"
option-label="label"
option-value="value"
placeholder="Select level"
class="w-full"
/>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Goals</label>
<Textarea v-model="form.goals" rows="3" class="w-full" placeholder="e.g. Complete a century ride, improve FTP to 300W..." />
</div>
</div>
</template>
</Card>
<!-- Training Zones -->
<Card>
<template #title>Training Parameters</template>
<template #content>
<div class="flex flex-col gap-4">
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">FTP (Functional Threshold Power)</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="e.g. 250" />
<span class="text-surface-500 text-sm font-medium">W</span>
</div>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (Lactate Threshold Heart Rate)</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.lthr" :min="100" :max="220" class="w-full" placeholder="e.g. 170" />
<span class="text-surface-500 text-sm font-medium">bpm</span>
</div>
</div>
<div>
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">Weight</label>
<div class="flex items-center gap-2">
<InputNumber v-model="form.weight" :min="30" :max="200" :max-fraction-digits="1" class="w-full" placeholder="e.g. 75" />
<span class="text-surface-500 text-sm font-medium">kg</span>
</div>
</div>
<div v-if="form.ftp && form.weight" class="bg-surface-50 rounded-lg p-3 border border-surface-200">
<p class="text-surface-500 text-xs uppercase mb-1">W/kg ratio</p>
<p class="text-xl font-bold text-primary">{{ (form.ftp / form.weight).toFixed(2) }} <span class="text-sm font-normal text-surface-500">W/kg</span></p>
</div>
</div>
</template>
</Card>
</div>
<div class="flex items-center gap-4 mt-6">
<Button label="Save Changes" icon="pi pi-check" :loading="saving" @click="save" />
<transition enter-active-class="transition-opacity" leave-active-class="transition-opacity" enter-from-class="opacity-0" leave-to-class="opacity-0">
<Message v-if="success" severity="success" :closable="false" class="m-0">Settings saved successfully</Message>
</transition>
</div>
</div>
</template>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.