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))
+5 -7
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
async def init_db():
@@ -82,17 +82,14 @@ async def init_db():
except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {e}")
# route_segments auto_generated column added after initial creation
# goal_weight_kg column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
"ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
))
except Exception as e:
print(f"route_segments column migration skipped: {e}")
print(f"users.goal_weight_kg column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
@@ -225,6 +222,7 @@ app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
@app.get("/health")
+34 -10
View File
@@ -28,6 +28,7 @@ class User(Base):
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
goal_weight_kg = Column(Float, nullable=True)
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
@@ -172,22 +173,45 @@ class NamedRoute(Base):
user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base):
__tablename__ = "route_segments"
class Segment(Base):
"""A user-defined GPS segment (a stretch of road/trail) matched across activities."""
__tablename__ = "segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
auto_generated = Column(Boolean, default=False)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
sport_type = Column(String(64), nullable=True)
polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
start_lat = Column(Float, nullable=True)
start_lng = Column(Float, nullable=True)
end_lat = Column(Float, nullable=True)
end_lng = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
bounding_box = Column(JSON, nullable=True) # {min_lat,max_lat,min_lon,max_lon}
created_from_activity_id = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
route = relationship("NamedRoute", back_populates="segments")
efforts = relationship("SegmentEffort", back_populates="segment", cascade="all, delete-orphan")
class SegmentEffort(Base):
"""One activity's time over a segment."""
__tablename__ = "segment_efforts"
id = Column(Integer, primary_key=True)
segment_id = Column(Integer, ForeignKey("segments.id", ondelete="CASCADE"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id", ondelete="CASCADE"), nullable=False, index=True)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=True)
rank = Column(Integer, nullable=True) # 1/2/3 for podium, else null
__table_args__ = (
UniqueConstraint("segment_id", "activity_id", name="uq_segment_effort"),
)
segment = relationship("Segment", back_populates="efforts")
class PersonalRecord(Base):
+42 -173
View File
@@ -95,39 +95,56 @@ def routes_are_similar(
return dist < dtw_threshold_m
def find_segment_times(
data_points: list[dict],
start_dist_m: float,
end_dist_m: float,
def match_segment_in_activity(
seg_coords: list[tuple],
act_coords: list[tuple],
act_times: list,
tol_m: float = 30.0,
) -> Optional[float]:
"""
Given activity data points (with cumulative distance_m),
find the time to traverse from start_dist_m to end_dist_m.
Returns duration in seconds, or None if not found.
Determine whether an activity track traverses a segment's GPS geometry, and if so
how long it took. Works even when the activity's overall route differs — only the
overlapping stretch matters.
seg_coords: [(lat, lon), ...] segment geometry (start → end).
act_coords: [(lat, lon), ...] activity track, in time order.
act_times: parallel list of datetimes for act_coords.
Strategy: anchor on the activity point nearest the segment start, then the nearest
point (at/after it) to the segment end, then verify a few intermediate segment
points are each passed within tolerance between those anchors. Returns the time
between the start and end anchors, or None if the activity doesn't follow the segment.
"""
start_time = None
end_time = None
n = len(act_coords)
if n < 2 or len(seg_coords) < 2:
return None
for p in data_points:
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
continue
start_pt, end_pt = seg_coords[0], seg_coords[-1]
if start_time is None and dist >= start_dist_m:
start_time = ts
si, sd = None, tol_m
for i in range(n):
d = haversine_m(act_coords[i], start_pt)
if d < sd:
sd, si = d, i
if si is None:
return None
if start_time is not None and dist >= end_dist_m:
end_time = ts
break
ei, ed = None, tol_m
for i in range(si + 1, n):
d = haversine_m(act_coords[i], end_pt)
if d < ed:
ed, ei = d, i
if ei is None or ei <= si:
return None
if start_time and end_time:
from datetime import datetime
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
# Verify the activity actually follows the segment shape between the anchors.
for frac in (0.25, 0.5, 0.75):
sp = seg_coords[int(frac * (len(seg_coords) - 1))]
if not any(haversine_m(act_coords[i], sp) <= tol_m for i in range(si, ei + 1)):
return None
return None
dur = (act_times[ei] - act_times[si]).total_seconds()
return dur if dur > 0 else None
def find_best_split_time(
@@ -174,154 +191,6 @@ def find_best_split_time(
return best
def _bearing(p1: tuple, p2: tuple) -> float:
"""Compass bearing in degrees (0-360) from p1 to p2."""
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return math.degrees(math.atan2(x, y)) % 360
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
if not encoded_polyline:
return []
km_count = int(total_dist_m / 1000)
segments = []
for i in range(km_count):
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
remainder = total_dist_m - km_count * 1000
if remainder >= 200:
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
return segments
def generate_turn_segments(
encoded_polyline: str,
turn_angle_deg: float = 45.0,
) -> list[tuple[str, float, float]]:
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
coords = decode_polyline_to_coords(encoded_polyline)
if len(coords) < 3:
return []
cum_dists = [0.0]
for i in range(1, len(coords)):
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
total = cum_dists[-1]
HALF_WINDOW = 100.0 # metres either side of candidate turn point
turn_centers: list[float] = []
for i in range(1, len(coords) - 1):
# Find index ~HALF_WINDOW before and after
start_i = i
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
start_i -= 1
end_i = i
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
end_i += 1
if start_i == i or end_i == i:
continue
b1 = _bearing(coords[start_i], coords[i])
b2 = _bearing(coords[i], coords[end_i])
diff = abs(b2 - b1) % 360
if diff > 180:
diff = 360 - diff
if diff >= turn_angle_deg:
turn_centers.append(cum_dists[i])
if not turn_centers:
return []
# Cluster turns within 150 m of each other → one segment per cluster
clusters: list[list[float]] = [[turn_centers[0]]]
for d in turn_centers[1:]:
if d - clusters[-1][-1] < 150:
clusters[-1].append(d)
else:
clusters.append([d])
segments = []
for cluster in clusters:
center = sum(cluster) / len(cluster)
start = max(0.0, center - HALF_WINDOW)
end = min(total, center + HALF_WINDOW)
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
return segments
def generate_hill_segments(
data_points: list[dict],
gradient_pct: float = 5.0,
) -> list[tuple[str, float, float]]:
"""
Detect uphill sections using activity data points (with altitude_m + distance_m).
Returns list of (name, start_m, end_m).
"""
pts = [
(p["distance_m"], p["altitude_m"])
for p in data_points
if p.get("distance_m") is not None and p.get("altitude_m") is not None
]
if len(pts) < 10:
return []
pts.sort(key=lambda x: x[0])
dists = [p[0] for p in pts]
alts = [p[1] for p in pts]
# Smooth altitude with a sliding window to reduce GPS noise
SMOOTH = 10
smooth_alts = []
for i in range(len(alts)):
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
grad_threshold = gradient_pct / 100.0
MIN_HILL_M = 200.0
in_hill = False
hill_start_idx = 0
segments = []
for i in range(1, len(dists)):
d_dist = dists[i] - dists[i - 1]
if d_dist <= 0:
continue
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
if grad >= grad_threshold and not in_hill:
in_hill = True
hill_start_idx = i - 1
elif grad < grad_threshold and in_hill:
length = dists[i - 1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[i - 1],
))
in_hill = False
if in_hill:
length = dists[-1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[-1],
))
return segments
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
+140
View File
@@ -199,6 +199,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
compute_personal_records.delay(activity_id, user_id, parsed)
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
match_activity_segments.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@@ -412,6 +413,145 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
db.commit()
def _recompute_segment_ranks(db, segment_id: int):
"""Assign rank 1/2/3 to the three fastest efforts on a segment, null to the rest."""
from app.models.user import SegmentEffort
from sqlalchemy import select
efforts = db.execute(
select(SegmentEffort)
.where(SegmentEffort.segment_id == segment_id)
.order_by(SegmentEffort.duration_s)
).scalars().all()
for i, e in enumerate(efforts):
e.rank = (i + 1) if i < 3 else None
def _activity_track(db, activity_id):
"""Return (coords, times) for an activity's GPS track in time order."""
from app.models.user import ActivityDataPoint
from sqlalchemy import select
dps = db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == activity_id)
.order_by(ActivityDataPoint.timestamp)
).scalars().all()
coords, times = [], []
for p in dps:
if p.latitude is not None and p.longitude is not None and p.timestamp is not None:
coords.append((p.latitude, p.longitude))
times.append(p.timestamp)
return coords, times
def _upsert_effort(db, segment_id, activity, duration_s):
from app.models.user import SegmentEffort
from sqlalchemy import select
existing = db.execute(
select(SegmentEffort).where(
SegmentEffort.segment_id == segment_id,
SegmentEffort.activity_id == activity.id,
)
).scalar_one_or_none()
if existing:
existing.duration_s = duration_s
existing.achieved_at = activity.start_time
else:
db.add(SegmentEffort(
segment_id=segment_id,
activity_id=activity.id,
duration_s=duration_s,
achieved_at=activity.start_time,
))
@celery_app.task(name="match_segment")
def match_segment(segment_id: int):
"""Match one segment against every eligible activity and (re)build its leaderboard."""
from app.services.route_matcher import (
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
)
from app.core.database import SyncSessionLocal
from app.models.user import Segment, Activity
from sqlalchemy import select
with SyncSessionLocal() as db:
seg = db.execute(select(Segment).where(Segment.id == segment_id)).scalar_one_or_none()
if not seg or not seg.polyline:
return {"status": "no_segment"}
seg_coords = decode_polyline_to_coords(seg.polyline)
acts = db.execute(
select(Activity).where(
Activity.user_id == seg.user_id,
Activity.sport_type == seg.sport_type,
Activity.polyline != None,
)
).scalars().all()
matched = 0
for act in acts:
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
continue
coords, times = _activity_track(db, act.id)
if len(coords) < 2:
continue
dur = match_segment_in_activity(seg_coords, coords, times)
if dur is None:
continue
_upsert_effort(db, seg.id, act, dur)
matched += 1
db.commit()
_recompute_segment_ranks(db, seg.id)
db.commit()
return {"status": "ok", "matched": matched}
@celery_app.task(name="match_activity_segments")
def match_activity_segments(activity_id: int, user_id: int):
"""Match a newly-ingested activity against all of the user's existing segments."""
from app.services.route_matcher import (
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
)
from app.core.database import SyncSessionLocal
from app.models.user import Segment, Activity
from sqlalchemy import select
with SyncSessionLocal() as db:
act = db.execute(select(Activity).where(Activity.id == activity_id)).scalar_one_or_none()
if not act or not act.polyline:
return {"status": "no_polyline"}
coords, times = _activity_track(db, act.id)
if len(coords) < 2:
return {"status": "no_track"}
segs = db.execute(
select(Segment).where(
Segment.user_id == user_id,
Segment.sport_type == act.sport_type,
)
).scalars().all()
touched = []
for seg in segs:
if not seg.polyline:
continue
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
continue
dur = match_segment_in_activity(decode_polyline_to_coords(seg.polyline), coords, times)
if dur is None:
continue
_upsert_effort(db, seg.id, act, dur)
touched.append(seg.id)
db.commit()
for sid in touched:
_recompute_segment_ranks(db, sid)
db.commit()
return {"status": "ok", "matched_segments": len(touched)}
@celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int):
"""Extract wellness data from a Garmin Connect export ZIP."""