Batch 1: dashboard, maps, segments rewrite, health, sync UX
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

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>
This commit is contained in:
2026-06-08 19:59:06 +01:00
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
+214
View File
@@ -0,0 +1,214 @@
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()