568dc31e97
- Body battery: replace circular ring with compact full-height colored bar chart,
level as line overlay, legend shows only types present in data
- Dashboard: add mini body battery summary card above health today panel
- Profile: remove editable resting HR and manual weight log; show 7-day avg
resting HR and latest Garmin weight as read-only
- Backend: add GET /routes/{id}/segment-bests bulk endpoint (fetches all matched
activity data points in one query, computes best segment time per segment)
- Backend: add GET /records/routes for fastest activity per named route
- Routes page: add Segments panel to route detail (grouped as 1km splits vs
hills/turns, best times, delete, theoretical best)
- Activity detail page: show segment times computed client-side from data points,
🏆 badge if new best
- Records page: add Route Records and Segment Records tabs alongside Distance PRs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
555 lines
17 KiB
Python
555 lines
17 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc
|
|
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, RouteSegment, Activity
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class SegmentCreate(BaseModel):
|
|
name: str
|
|
start_distance_m: float
|
|
end_distance_m: float
|
|
description: Optional[str] = None
|
|
|
|
|
|
class RouteCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
sport_type: Optional[str] = None
|
|
activity_id: int
|
|
|
|
|
|
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
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class SegmentOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
start_distance_m: float
|
|
end_distance_m: float
|
|
description: Optional[str]
|
|
auto_generated: Optional[bool] = False
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class AutoGenerateRequest(BaseModel):
|
|
type: str # "1km" | "turns" | "hills"
|
|
gradient_pct: float = 5.0
|
|
turn_angle_deg: float = 45.0
|
|
|
|
|
|
class SegmentTimeEntry(BaseModel):
|
|
activity_id: int
|
|
date: datetime
|
|
name: str
|
|
duration_s: float
|
|
|
|
|
|
@router.get("/", response_model=List[RouteOut])
|
|
async def list_routes(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
result = await db.execute(
|
|
select(NamedRoute)
|
|
.where(NamedRoute.user_id == current_user.id)
|
|
.order_by(desc(NamedRoute.created_at))
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@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.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"}
|
|
|
|
|
|
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
|
|
result = await db.execute(
|
|
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
|
|
)
|
|
route = result.scalar_one_or_none()
|
|
if not route:
|
|
raise HTTPException(status_code=404, detail="Route not found")
|
|
return route
|
|
|
|
|
|
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
|
async def list_segments(
|
|
route_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _get_owned_route(route_id, current_user.id, db)
|
|
result = await db.execute(
|
|
select(RouteSegment)
|
|
.where(RouteSegment.route_id == route_id)
|
|
.order_by(RouteSegment.start_distance_m)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("/{route_id}/segments", response_model=SegmentOut)
|
|
async def create_segment(
|
|
route_id: int,
|
|
body: SegmentCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _get_owned_route(route_id, current_user.id, db)
|
|
segment = RouteSegment(
|
|
route_id=route_id,
|
|
name=body.name,
|
|
start_distance_m=body.start_distance_m,
|
|
end_distance_m=body.end_distance_m,
|
|
description=body.description,
|
|
auto_generated=False,
|
|
)
|
|
db.add(segment)
|
|
await db.commit()
|
|
await db.refresh(segment)
|
|
return segment
|
|
|
|
|
|
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
|
|
async def delete_segment(
|
|
route_id: int,
|
|
segment_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _get_owned_route(route_id, current_user.id, db)
|
|
result = await db.execute(
|
|
select(RouteSegment).where(
|
|
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
|
)
|
|
)
|
|
seg = result.scalar_one_or_none()
|
|
if not seg:
|
|
raise HTTPException(status_code=404, detail="Segment not found")
|
|
await db.delete(seg)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
|
|
async def auto_generate_segments(
|
|
route_id: int,
|
|
body: AutoGenerateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Auto-generate segments: 1km splits, turns, or hills."""
|
|
from app.services.route_matcher import (
|
|
generate_1km_segments, generate_turn_segments, generate_hill_segments,
|
|
)
|
|
from sqlalchemy import delete as sql_delete
|
|
|
|
route = await _get_owned_route(route_id, current_user.id, db)
|
|
|
|
if body.type not in ("1km", "turns", "hills"):
|
|
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
|
|
|
|
# Clear existing auto-generated segments of this type
|
|
await db.execute(
|
|
sql_delete(RouteSegment).where(
|
|
RouteSegment.route_id == route_id,
|
|
RouteSegment.auto_generated == True,
|
|
)
|
|
)
|
|
|
|
raw_segments: list[tuple[str, float, float]] = []
|
|
|
|
if body.type == "1km":
|
|
if not route.distance_m:
|
|
raise HTTPException(status_code=400, detail="Route has no distance recorded")
|
|
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
|
|
|
|
elif body.type == "turns":
|
|
if not route.reference_polyline:
|
|
raise HTTPException(status_code=400, detail="Route has no polyline")
|
|
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
|
|
|
|
elif body.type == "hills":
|
|
if not route.reference_polyline:
|
|
raise HTTPException(status_code=400, detail="Route has no polyline")
|
|
# Find most recent matched activity for elevation data
|
|
act_result = await db.execute(
|
|
select(Activity)
|
|
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
|
.order_by(desc(Activity.start_time))
|
|
.limit(1)
|
|
)
|
|
act = act_result.scalar_one_or_none()
|
|
if not act:
|
|
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
|
|
from app.models.user import ActivityDataPoint
|
|
dp_result = await db.execute(
|
|
select(ActivityDataPoint)
|
|
.where(ActivityDataPoint.activity_id == act.id)
|
|
.order_by(ActivityDataPoint.timestamp)
|
|
)
|
|
dps = dp_result.scalars().all()
|
|
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
|
|
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
|
|
|
|
new_segments = []
|
|
for name, start_m, end_m in raw_segments:
|
|
seg = RouteSegment(
|
|
route_id=route_id,
|
|
name=name,
|
|
start_distance_m=start_m,
|
|
end_distance_m=end_m,
|
|
auto_generated=True,
|
|
)
|
|
db.add(seg)
|
|
new_segments.append(seg)
|
|
|
|
await db.commit()
|
|
for seg in new_segments:
|
|
await db.refresh(seg)
|
|
return new_segments
|
|
|
|
|
|
class SegmentBestOut(BaseModel):
|
|
segment_id: int
|
|
name: str
|
|
start_distance_m: float
|
|
end_distance_m: float
|
|
auto_generated: bool
|
|
best_s: Optional[float]
|
|
best_activity_id: Optional[int]
|
|
count: int
|
|
|
|
|
|
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
|
|
async def get_segment_bests(
|
|
route_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return best time per segment across all matched activities for a route."""
|
|
from app.services.route_matcher import find_segment_times
|
|
from app.models.user import ActivityDataPoint
|
|
from collections import defaultdict
|
|
|
|
await _get_owned_route(route_id, current_user.id, db)
|
|
|
|
segs_result = await db.execute(
|
|
select(RouteSegment)
|
|
.where(RouteSegment.route_id == route_id)
|
|
.order_by(RouteSegment.start_distance_m)
|
|
)
|
|
segments = segs_result.scalars().all()
|
|
if not segments:
|
|
return []
|
|
|
|
acts_result = await db.execute(
|
|
select(Activity)
|
|
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
|
.order_by(desc(Activity.start_time))
|
|
.limit(20)
|
|
)
|
|
activities = acts_result.scalars().all()
|
|
if not activities:
|
|
return [
|
|
SegmentBestOut(
|
|
segment_id=s.id, name=s.name,
|
|
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
|
|
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
|
|
)
|
|
for s in segments
|
|
]
|
|
|
|
act_ids = [a.id for a in activities]
|
|
|
|
dp_result = await db.execute(
|
|
select(ActivityDataPoint)
|
|
.where(ActivityDataPoint.activity_id.in_(act_ids))
|
|
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
|
|
)
|
|
all_dps = dp_result.scalars().all()
|
|
|
|
# Group data points by activity_id
|
|
dp_by_act = defaultdict(list)
|
|
for dp in all_dps:
|
|
if dp.distance_m is not None:
|
|
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
|
|
|
|
bests = []
|
|
for seg in segments:
|
|
best_s = None
|
|
best_act_id = None
|
|
count = 0
|
|
for act_id in act_ids:
|
|
dp_list = dp_by_act.get(act_id, [])
|
|
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
|
if duration is not None:
|
|
count += 1
|
|
if best_s is None or duration < best_s:
|
|
best_s = duration
|
|
best_act_id = act_id
|
|
bests.append(SegmentBestOut(
|
|
segment_id=seg.id, name=seg.name,
|
|
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
|
|
auto_generated=bool(seg.auto_generated),
|
|
best_s=best_s, best_activity_id=best_act_id, count=count,
|
|
))
|
|
return bests
|
|
|
|
|
|
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
|
|
async def get_segment_times(
|
|
route_id: int,
|
|
segment_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Return the last 10 times this segment was traversed across matched activities."""
|
|
from app.services.route_matcher import find_segment_times
|
|
from app.models.user import ActivityDataPoint
|
|
|
|
await _get_owned_route(route_id, current_user.id, db)
|
|
|
|
seg_result = await db.execute(
|
|
select(RouteSegment).where(
|
|
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
|
)
|
|
)
|
|
seg = seg_result.scalar_one_or_none()
|
|
if not seg:
|
|
raise HTTPException(status_code=404, detail="Segment not found")
|
|
|
|
acts_result = await db.execute(
|
|
select(Activity)
|
|
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
|
.order_by(desc(Activity.start_time))
|
|
.limit(10)
|
|
)
|
|
activities = acts_result.scalars().all()
|
|
|
|
times = []
|
|
for act in activities:
|
|
dp_result = await db.execute(
|
|
select(ActivityDataPoint)
|
|
.where(ActivityDataPoint.activity_id == act.id)
|
|
.order_by(ActivityDataPoint.timestamp)
|
|
)
|
|
dps = dp_result.scalars().all()
|
|
dp_list = [
|
|
{"distance_m": p.distance_m, "timestamp": p.timestamp}
|
|
for p in dps
|
|
if p.distance_m is not None
|
|
]
|
|
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
|
if duration:
|
|
times.append(SegmentTimeEntry(
|
|
activity_id=act.id,
|
|
date=act.start_time,
|
|
name=act.name,
|
|
duration_s=duration,
|
|
))
|
|
return times
|