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>
215 lines
7.0 KiB
Python
215 lines
7.0 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
from datetime import datetime
|
|
|
|
import polyline as polyline_lib
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
from app.models.user import User, Segment, SegmentEffort, Activity, ActivityDataPoint
|
|
from app.services.route_matcher import haversine_m
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class SegmentCreate(BaseModel):
|
|
name: str
|
|
activity_id: int
|
|
start_distance_m: float
|
|
end_distance_m: float
|
|
|
|
|
|
class EffortOut(BaseModel):
|
|
activity_id: int
|
|
activity_name: str
|
|
date: Optional[datetime]
|
|
duration_s: float
|
|
rank: Optional[int]
|
|
|
|
|
|
class SegmentOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
sport_type: Optional[str]
|
|
polyline: Optional[str]
|
|
distance_m: Optional[float]
|
|
created_from_activity_id: Optional[int]
|
|
effort_count: int = 0
|
|
best_s: Optional[float] = None
|
|
|
|
|
|
class SegmentDetailOut(SegmentOut):
|
|
leaderboard: List[EffortOut] = []
|
|
|
|
|
|
class ActivitySegmentOut(BaseModel):
|
|
segment_id: int
|
|
name: str
|
|
polyline: Optional[str]
|
|
distance_m: Optional[float]
|
|
duration_s: float
|
|
rank: Optional[int] # this activity's place on the leaderboard
|
|
best_s: Optional[float] # current gold time
|
|
effort_count: int
|
|
|
|
|
|
def _bbox(coords):
|
|
lats = [c[0] for c in coords]
|
|
lons = [c[1] for c in coords]
|
|
return {"min_lat": min(lats), "max_lat": max(lats), "min_lon": min(lons), "max_lon": max(lons)}
|
|
|
|
|
|
async def _own_segment(segment_id: int, user_id: int, db: AsyncSession) -> Segment:
|
|
seg = (await db.execute(
|
|
select(Segment).where(Segment.id == segment_id, Segment.user_id == user_id)
|
|
)).scalar_one_or_none()
|
|
if not seg:
|
|
raise HTTPException(status_code=404, detail="Segment not found")
|
|
return seg
|
|
|
|
|
|
@router.get("/", response_model=List[SegmentOut])
|
|
async def list_segments(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
segs = (await db.execute(
|
|
select(Segment).where(Segment.user_id == current_user.id).order_by(Segment.created_at.desc())
|
|
)).scalars().all()
|
|
out = []
|
|
for s in segs:
|
|
agg = (await db.execute(
|
|
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
|
|
.where(SegmentEffort.segment_id == s.id)
|
|
)).one()
|
|
out.append(SegmentOut(
|
|
id=s.id, name=s.name, sport_type=s.sport_type, polyline=s.polyline,
|
|
distance_m=s.distance_m, created_from_activity_id=s.created_from_activity_id,
|
|
effort_count=agg[0] or 0, best_s=agg[1],
|
|
))
|
|
return out
|
|
|
|
|
|
@router.post("/", response_model=SegmentOut)
|
|
async def create_segment(
|
|
body: SegmentCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
activity = (await db.execute(
|
|
select(Activity).where(Activity.id == body.activity_id, Activity.user_id == current_user.id)
|
|
)).scalar_one_or_none()
|
|
if not activity:
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
lo, hi = sorted((body.start_distance_m, body.end_distance_m))
|
|
dps = (await db.execute(
|
|
select(ActivityDataPoint)
|
|
.where(ActivityDataPoint.activity_id == activity.id)
|
|
.order_by(ActivityDataPoint.timestamp)
|
|
)).scalars().all()
|
|
|
|
coords = [
|
|
(p.latitude, p.longitude)
|
|
for p in dps
|
|
if p.distance_m is not None and p.latitude is not None and p.longitude is not None
|
|
and lo <= p.distance_m <= hi
|
|
]
|
|
if len(coords) < 2:
|
|
raise HTTPException(status_code=400, detail="Selected range has too few GPS points")
|
|
|
|
seg = Segment(
|
|
user_id=current_user.id,
|
|
name=body.name.strip() or "Segment",
|
|
sport_type=activity.sport_type,
|
|
polyline=polyline_lib.encode(coords),
|
|
start_lat=coords[0][0], start_lng=coords[0][1],
|
|
end_lat=coords[-1][0], end_lng=coords[-1][1],
|
|
distance_m=max(0.0, hi - lo),
|
|
bounding_box=_bbox(coords),
|
|
created_from_activity_id=activity.id,
|
|
)
|
|
db.add(seg)
|
|
await db.commit()
|
|
await db.refresh(seg)
|
|
|
|
# Match across all activities in the background.
|
|
from app.workers.tasks import match_segment
|
|
match_segment.delay(seg.id)
|
|
|
|
return SegmentOut(
|
|
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
|
|
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
|
|
effort_count=0, best_s=None,
|
|
)
|
|
|
|
|
|
@router.get("/by-activity/{activity_id}", response_model=List[ActivitySegmentOut])
|
|
async def segments_for_activity(
|
|
activity_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Segments that this activity has an effort on, with the activity's place + the gold time."""
|
|
rows = (await db.execute(
|
|
select(Segment, SegmentEffort)
|
|
.join(SegmentEffort, SegmentEffort.segment_id == Segment.id)
|
|
.where(Segment.user_id == current_user.id, SegmentEffort.activity_id == activity_id)
|
|
.order_by(Segment.created_at.desc())
|
|
)).all()
|
|
|
|
out = []
|
|
for seg, effort in rows:
|
|
agg = (await db.execute(
|
|
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
|
|
.where(SegmentEffort.segment_id == seg.id)
|
|
)).one()
|
|
out.append(ActivitySegmentOut(
|
|
segment_id=seg.id, name=seg.name, polyline=seg.polyline, distance_m=seg.distance_m,
|
|
duration_s=effort.duration_s, rank=effort.rank,
|
|
best_s=agg[1], effort_count=agg[0] or 0,
|
|
))
|
|
return out
|
|
|
|
|
|
@router.get("/{segment_id}", response_model=SegmentDetailOut)
|
|
async def get_segment(
|
|
segment_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
seg = await _own_segment(segment_id, current_user.id, db)
|
|
rows = (await db.execute(
|
|
select(SegmentEffort, Activity)
|
|
.join(Activity, Activity.id == SegmentEffort.activity_id)
|
|
.where(SegmentEffort.segment_id == seg.id)
|
|
.order_by(SegmentEffort.duration_s)
|
|
)).all()
|
|
leaderboard = [
|
|
EffortOut(
|
|
activity_id=e.activity_id, activity_name=a.name,
|
|
date=e.achieved_at or a.start_time, duration_s=e.duration_s, rank=e.rank,
|
|
)
|
|
for e, a in rows
|
|
]
|
|
return SegmentDetailOut(
|
|
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
|
|
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
|
|
effort_count=len(leaderboard), best_s=leaderboard[0].duration_s if leaderboard else None,
|
|
leaderboard=leaderboard,
|
|
)
|
|
|
|
|
|
@router.delete("/{segment_id}", status_code=204)
|
|
async def delete_segment(
|
|
segment_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
seg = await _own_segment(segment_id, current_user.id, db)
|
|
await db.delete(seg)
|
|
await db.commit()
|