bc437cce92
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>
281 lines
8.5 KiB
Python
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"}
|