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
+31
View File
@@ -195,6 +195,37 @@ async def get_laps(
return result.scalars().all()
@router.get("/{activity_id}/lap-bests")
async def get_lap_bests(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Best (fastest) time per lap number across all activities on the same route."""
act = (await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)).scalar_one_or_none()
if not act:
raise HTTPException(status_code=404, detail="Activity not found")
if not act.named_route_id:
return {}
rows = (await db.execute(
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
.join(Activity, Activity.id == ActivityLap.activity_id)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
ActivityLap.duration_s.isnot(None),
)
.group_by(ActivityLap.lap_number)
)).all()
return {str(lap_number): best for lap_number, best in rows}
@router.patch("/{activity_id}/name")
async def rename_activity(
activity_id: int,
+6
View File
@@ -20,6 +20,7 @@ class ProfileUpdate(BaseModel):
birth_year: Optional[int] = None
height_cm: Optional[float] = None
biological_sex: Optional[str] = None
goal_weight_kg: Optional[float] = None
class ProfileOut(BaseModel):
@@ -31,6 +32,7 @@ class ProfileOut(BaseModel):
birth_year: Optional[int]
height_cm: Optional[float]
biological_sex: Optional[str]
goal_weight_kg: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
@@ -78,6 +80,10 @@ async def update_profile(
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
if body.goal_weight_kg is not None:
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
raise HTTPException(400, "Goal weight must be 20500 kg")
current_user.goal_weight_kg = body.goal_weight_kg or None
await db.commit()
await db.refresh(current_user)
+1 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
from app.models.user import User, PersonalRecord, NamedRoute, HealthMetric, Activity
router = APIRouter()
+31 -323
View File
@@ -7,18 +7,11 @@ 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
from app.models.user import User, NamedRoute, 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
@@ -26,6 +19,11 @@ class RouteCreate(BaseModel):
activity_id: int
class RouteUpdate(BaseModel):
name: Optional[str] = None
sport_type: Optional[str] = None
class RouteOut(BaseModel):
id: int
name: str
@@ -42,32 +40,6 @@ class RouteOut(BaseModel):
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
auto_generated_type: Optional[str] = None
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),
@@ -179,6 +151,31 @@ async def get_route(
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,
@@ -281,292 +278,3 @@ async def assign_activity_to_route(
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 only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
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,
auto_generated_type=body.type,
)
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
+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()
+4 -3
View File
@@ -19,7 +19,7 @@ from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import (
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
)
router = APIRouter()
@@ -122,12 +122,13 @@ async def delete_user(
# Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
segment_ids = select(Segment.id).where(Segment.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
await db.execute(delete(SegmentEffort).where(SegmentEffort.segment_id.in_(segment_ids)))
await db.execute(delete(Segment).where(Segment.user_id == user_id))
await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))