Files
MileVault/backend/app/api/routes.py
T
owain bc437cce92
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
Batch 1: dashboard, maps, segments rewrite, health, sync UX
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:59:06 +01:00

281 lines
8.5 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, Activity
router = APIRouter()
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteUpdate(BaseModel):
name: Optional[str] = None
sport_type: Optional[str] = None
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
class Config:
from_attributes = True
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.patch("/{route_id}", response_model=RouteOut)
async def update_route(
route_id: int,
body: RouteUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
if body.name is not None and body.name.strip():
route.name = body.name.strip()
if body.sport_type is not None:
route.sport_type = body.sport_type
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}