From bc437cce92f2768436585f09260567b32b59b3d7 Mon Sep 17 00:00:00 2001 From: owain Date: Mon, 8 Jun 2026 19:59:06 +0100 Subject: [PATCH] Batch 1: dashboard, maps, segments rewrite, health, sync UX 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 --- backend/app/api/activities.py | 31 ++ backend/app/api/profile.py | 6 + backend/app/api/records.py | 2 +- backend/app/api/routes.py | 354 ++------------- backend/app/api/segments.py | 214 ++++++++++ backend/app/api/users.py | 7 +- backend/app/main.py | 12 +- backend/app/models/user.py | 44 +- backend/app/services/route_matcher.py | 215 ++-------- backend/app/workers/tasks.py | 140 ++++++ frontend/src/App.jsx | 2 - .../src/components/activity/ActivityMap.jsx | 124 ++++-- frontend/src/components/activity/LapTable.jsx | 56 ++- .../components/activity/MetricTimeline.jsx | 44 +- .../src/components/activity/SegmentsPanel.jsx | 78 ++++ frontend/src/components/ui/Layout.jsx | 23 +- frontend/src/hooks/useSync.js | 87 ++++ frontend/src/pages/ActivityDetailPage.jsx | 156 ++++--- frontend/src/pages/DashboardPage.jsx | 44 +- frontend/src/pages/HealthPage.jsx | 152 ++++++- frontend/src/pages/ProfilePage.jsx | 107 +---- frontend/src/pages/RecordsPage.jsx | 119 +----- frontend/src/pages/RoutesPage.jsx | 402 ++++++++---------- frontend/src/pages/SegmentsPage.jsx | 365 ---------------- 24 files changed, 1339 insertions(+), 1445 deletions(-) create mode 100644 backend/app/api/segments.py create mode 100644 frontend/src/components/activity/SegmentsPanel.jsx create mode 100644 frontend/src/hooks/useSync.js delete mode 100644 frontend/src/pages/SegmentsPage.jsx diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 45559ae..6dcea5e 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -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, diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index c1e9977..de9520b 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -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 20–500 kg") + current_user.goal_weight_kg = body.goal_weight_kg or None await db.commit() await db.refresh(current_user) diff --git a/backend/app/api/records.py b/backend/app/api/records.py index b3472ca..e229a64 100644 --- a/backend/app/api/records.py +++ b/backend/app/api/records.py @@ -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() diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 78f38ee..227bd67 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -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 diff --git a/backend/app/api/segments.py b/backend/app/api/segments.py new file mode 100644 index 0000000..d1d99e2 --- /dev/null +++ b/backend/app/api/segments.py @@ -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() diff --git a/backend/app/api/users.py b/backend/app/api/users.py index a619767..61f067a 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -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)) diff --git a/backend/app/main.py b/backend/app/main.py index cf38e87..9006d5d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f0bc014..bb644fc 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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): diff --git a/backend/app/services/route_matcher.py b/backend/app/services/route_matcher.py index 92dd307..796df39 100644 --- a/backend/app/services/route_matcher.py +++ b/backend/app/services/route_matcher.py @@ -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"), diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index cec5b37..72e23ce 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -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.""" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ae2b1e0..a740644 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,6 @@ import ActivitiesPage from './pages/ActivitiesPage' import ActivityDetailPage from './pages/ActivityDetailPage' import HealthPage from './pages/HealthPage' import RoutesPage from './pages/RoutesPage' -import SegmentsPage from './pages/SegmentsPage' import RecordsPage from './pages/RecordsPage' import UploadPage from './pages/UploadPage' import ProfilePage from './pages/ProfilePage' @@ -36,7 +35,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index 56a8d7f..d209f52 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -24,6 +24,19 @@ const TILE_LAYERS = { }, } +// Tile options tuned for smoother panning/zooming: keep a larger off-screen +// buffer of tiles and don't defer loads until the map is idle. +const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false } + +// Slow → fast colour ramp for speed-coloured routes. +const SPEED_STOPS = ['#3b82f6', '#22c55e', '#eab308', '#f97316', '#ef4444'] + +function speedColorIndex(speed, min, max) { + if (!(max > min)) return 1 + const t = (speed - min) / (max - min) + return Math.min(SPEED_STOPS.length - 1, Math.max(0, Math.floor(t * SPEED_STOPS.length))) +} + function decodePolyline(encoded) { const coords = [] let index = 0, lat = 0, lng = 0 @@ -39,43 +52,82 @@ function decodePolyline(encoded) { return coords } -function drawRoute(map, polyline, sportType, trackRef) { +const dot = (color) => L.divIcon({ + html: `
`, + iconSize: [12, 12], iconAnchor: [6, 6], className: '', +}) + +function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) { if (trackRef.current) { trackRef.current.remove() trackRef.current = null } - if (!polyline) return + // Prefer the data-point track when colouring by speed; fall back to the encoded polyline. + const speedPts = (colorMode === 'speed' && dataPoints) + ? dataPoints.filter(p => p.latitude != null && p.longitude != null) + : [] + + const group = L.layerGroup() + + if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) { + const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0) + speeds.sort((a, b) => a - b) + // Clamp the range to the 5th–95th percentile so a couple of GPS spikes don't wash out the ramp. + const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0 + const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1 + + // Group consecutive points into runs of the same colour bucket → one polyline per run. + let runStart = 0 + let runIdx = speedColorIndex(speedPts[0].speed_ms ?? lo, lo, hi) + const flush = (end) => { + const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude]) + if (coords.length >= 2) { + L.polyline(coords, { color: SPEED_STOPS[runIdx], weight: 3, opacity: 0.95 }).addTo(group) + } + } + for (let i = 1; i < speedPts.length; i++) { + const idx = speedColorIndex(speedPts[i].speed_ms ?? lo, lo, hi) + if (idx !== runIdx) { + flush(i) // include current point so runs join up + runStart = i + runIdx = idx + } + } + flush(speedPts.length - 1) + + const coords = speedPts.map(p => [p.latitude, p.longitude]) + L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group) + L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group) + group.addTo(map) + trackRef.current = group + map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] }) + return + } + + // Solid single-colour route from the encoded polyline. + if (!polyline) return const coords = decodePolyline(polyline) if (!coords.length) return - - trackRef.current = L.polyline(coords, { - color: sportColor(sportType), - weight: 3, - opacity: 0.9, - }).addTo(map) - - map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) - - const dot = (color) => L.divIcon({ - html: `
`, - iconSize: [12, 12], iconAnchor: [6, 6], className: '', - }) - L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map) - L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map) + L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group) + L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group) + L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group) + group.addTo(map) + trackRef.current = group + map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] }) } -export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) { +export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) const trackRef = useRef(null) const tileLayerRef = useRef(null) - const polylineRef = useRef(polyline) - const sportTypeRef = useRef(sportType) + const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode }) + const clickRef = useRef(onMapClick) - useEffect(() => { polylineRef.current = polyline }, [polyline]) - useEffect(() => { sportTypeRef.current = sportType }, [sportType]) + drawArgsRef.current = { polyline, dataPoints, sportType, colorMode } + useEffect(() => { clickRef.current = onMapClick }, [onMapClick]) useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return @@ -83,13 +135,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true, + preferCanvas: true, }) - const tile = TILE_LAYERS.dark - tileLayerRef.current = L.tileLayer(tile.url, { - attribution: tile.attribution, - maxZoom: 19, - }).addTo(mapInstanceRef.current) + const tile = TILE_LAYERS.street + tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS }) + .addTo(mapInstanceRef.current) + + mapInstanceRef.current.on('click', (e) => { + if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng }) + }) return () => { mapInstanceRef.current?.remove() @@ -99,19 +154,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo useEffect(() => { if (!mapInstanceRef.current) return - const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark + const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street if (tileLayerRef.current) tileLayerRef.current.remove() - tileLayerRef.current = L.tileLayer(tile.url, { - attribution: tile.attribution, - maxZoom: 19, - }).addTo(mapInstanceRef.current) - drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef) + tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS }) + .addTo(mapInstanceRef.current) }, [mapType]) useEffect(() => { if (!mapInstanceRef.current) return - drawRoute(mapInstanceRef.current, polyline, sportType, trackRef) - }, [polyline, sportType]) + drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef) + }, [polyline, sportType, colorMode, dataPoints]) useEffect(() => { if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return @@ -130,4 +182,4 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo }, [hoveredDistance, dataPoints]) return
-} \ No newline at end of file +} diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx index 7b7fb67..cc0afdf 100644 --- a/frontend/src/components/activity/LapTable.jsx +++ b/frontend/src/components/activity/LapTable.jsx @@ -2,8 +2,9 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade const RUNNING_TYPES = new Set(['running', 'hiking', 'walking']) -export default function LapTable({ laps, sportType }) { +export default function LapTable({ laps, sportType, lapBests }) { const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) + const hasBests = lapBests && Object.keys(lapBests).length > 0 return (
@@ -12,6 +13,8 @@ export default function LapTable({ laps, sportType }) { + {hasBests && } + {hasBests && } @@ -19,25 +22,40 @@ export default function LapTable({ laps, sportType }) { - {laps.map((lap) => ( - - - - - - - - {showPower && ( - + + + + {hasBests && ( + + )} + {hasBests && ( + + )} + + - )} - - ))} + + {showPower && ( + + )} + + ) + })}
Lap Distance TimeBestΔPace Avg HR Cadence
{lap.lap_number}{formatDistance(lap.distance_m)}{formatDuration(lap.duration_s)}{formatPace(lap.avg_speed_ms, sportType)} - {formatHeartRate(lap.avg_heart_rate)} - - {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} - - {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} + {laps.map((lap) => { + const best = hasBests ? lapBests[String(lap.lap_number)] : null + const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null + const isBest = delta != null && delta <= 0.5 + return ( +
{lap.lap_number}{formatDistance(lap.distance_m)}{formatDuration(lap.duration_s)}{best != null ? formatDuration(best) : '--'} + {delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`} + {formatPace(lap.avg_speed_ms, sportType)} + {formatHeartRate(lap.avg_heart_rate)}
+ {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} + + {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} +
diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx index 36d2a29..3418f10 100644 --- a/frontend/src/components/activity/MetricTimeline.jsx +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -1,10 +1,26 @@ import { useMemo } from 'react' import { - ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' import { formatPace, formatCadence } from '../../utils/format' +// Running cadence colour bands (steps per minute). Cadence is stored halved for +// running, so spm = stored × 2. +function cadenceColor(spm) { + if (spm < 153) return '#ef4444' // red — slow + if (spm < 164) return '#f97316' // orange — moderate + if (spm < 174) return '#22c55e' // green — good recreational + if (spm < 184) return '#3b82f6' // blue — experienced + return '#a855f7' // purple — elite +} + +const renderCadenceDot = (props) => { + const { cx, cy, payload } = props + if (cx == null || cy == null || payload?.cadence == null) return null + return +} + function downsample(points, maxPoints = 500) { if (points.length <= maxPoints) return points const step = Math.ceil(points.length / maxPoints) @@ -133,15 +149,23 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH content={} isAnimationActive={false} /> - + {metric.key === 'cadence' && sportType === 'running' ? ( + <> + {/* 165 spm guide → 82.5 in stored (halved) units */} + + + + ) : ( + + )}
diff --git a/frontend/src/components/activity/SegmentsPanel.jsx b/frontend/src/components/activity/SegmentsPanel.jsx new file mode 100644 index 0000000..f2d4e3d --- /dev/null +++ b/frontend/src/components/activity/SegmentsPanel.jsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import api from '../../utils/api' +import { formatDuration, formatDistance } from '../../utils/format' + +const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' } + +function Leaderboard({ segmentId }) { + const { data } = useQuery({ + queryKey: ['segment', segmentId], + queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data), + }) + if (!data) return

Loading…

+ if (!data.leaderboard?.length) return

No efforts yet — still matching.

+ return ( +
+ {data.leaderboard.map((e, i) => ( +
+ {MEDALS[e.rank] || i + 1} + {formatDuration(e.duration_s)} + {e.activity_name} +
+ ))} +
+ ) +} + +export default function SegmentsPanel({ segments }) { + const qc = useQueryClient() + const [open, setOpen] = useState(null) + + const remove = async (id) => { + if (!confirm('Delete this segment?')) return + await api.delete(`/segments/${id}`) + qc.invalidateQueries() + } + + return ( +
+
+ Segment + This run + Best + Place +
+ {segments.map(seg => { + const isPodium = seg.rank && seg.rank <= 3 + const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null + return ( +
+
+ + + {formatDuration(seg.duration_s)} + + + {seg.best_s != null ? formatDuration(seg.best_s) : '--'} + + + {isPodium + ? {MEDALS[seg.rank]} + : delta != null + ? +{formatDuration(delta)} + : --} + + +
+ {open === seg.segment_id && } +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index d5b85af..bf761fa 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -1,12 +1,13 @@ +import { useEffect } from 'react' import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { useAuthStore } from '../../hooks/useAuth' +import { useSyncStore, syncProgressPct } from '../../hooks/useSync' const nav = [ { to: '/', label: 'Dashboard', icon: '📊', exact: true }, { to: '/activities', label: 'Activities', icon: '🏃' }, { to: '/health', label: 'Health', icon: '❤️' }, { to: '/routes', label: 'Routes', icon: '🗺️' }, - { to: '/segments', label: 'Segments', icon: '📏' }, { to: '/records', label: 'Records', icon: '🏆' }, { to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/profile', label: 'Profile', icon: '⚙️' }, @@ -16,6 +17,12 @@ const nav = [ export default function Layout() { const { user, logout } = useAuthStore() const navigate = useNavigate() + const { inProgress, status, startPolling, stopPolling } = useSyncStore() + + useEffect(() => { + startPolling() + return () => stopPolling() + }, []) const handleLogout = () => { logout() @@ -48,6 +55,20 @@ export default function Layout() { ))} + {inProgress && ( +
+
+ + Garmin sync +
+
+
+
+

{status || 'Starting sync…'}

+
+ )} +
))} + {dataPoints?.length > 0 && ( + + )}
- Height: + Route: + {[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => ( + + ))} + Height: {[280, 420, 560].map(h => (
+ {segCreate && ( +
+ + Click two points on the route to mark the segment start and end. + + + Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'} + {' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'} + + {segPoints.length === 2 && ( + <> + setSegName(e.target.value)} + placeholder="Segment name" + className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-white focus:outline-none focus:ring-2 focus:ring-green-500" + /> + + + )} + {segPoints.length > 0 && ( + + )} +
+ )}
@@ -202,50 +280,18 @@ export default function ActivityDetailPage() { {/* Laps + Segments side by side */} - {((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && ( + {((laps && laps.length > 0) || (actSegments && actSegments.length > 0)) && (
{laps && laps.length > 0 && (

Laps

- +
)} - {segments && segments.length > 0 && dataPoints && ( + {actSegments && actSegments.length > 0 && (
-
-

Segments

- Manage → -
-
- Segment - This run - Best - Δ -
-
- {segments.map(seg => { - const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m) - const best = segmentBests?.find(b => b.segment_id === seg.id) - const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5 - const delta = t != null && best?.best_s != null ? t - best.best_s : null - return ( -
- {seg.name} - - {t != null ? formatDuration(t) : --} - - - {best?.best_s != null ? formatDuration(best.best_s) : '--'} - - - {isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`} - -
- ) - })} -
+

Segments

+
)}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 407e9fe..752fc94 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -4,11 +4,21 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContaine import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import api from '../utils/api' import StatCard from '../components/ui/StatCard' +import ActivityMap from '../components/activity/ActivityMap' import { - formatDuration, formatDistance, formatPace, formatHeartRate, + formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation, formatDate, sportIcon, formatSleep, } from '../utils/format' +function Stat({ label, value }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + function bbLevelColor(level) { if (level == null) return '#6b7280' if (level >= 75) return '#3b82f6' @@ -154,6 +164,7 @@ export default function DashboardPage() { }) const latest = healthSummary?.latest + const featured = recentActivities?.[0] return (
@@ -203,6 +214,37 @@ export default function DashboardPage() {
+ {/* Featured most-recent activity */} + {featured && ( +
+
+
+ {sportIcon(featured.sport_type)} +
+ + {featured.name} + +

{formatDate(featured.start_time)}

+
+
+ Open → +
+
+
+ {featured.polyline + ? + :
No GPS track
} +
+
+ + + + +
+
+
+ )} + {/* Recent activities */}
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 9196c14..e2508e1 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { - AreaChart, Area, BarChart, Bar, ReferenceLine, + AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from 'recharts' import { format, subDays } from 'date-fns' @@ -280,6 +280,20 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) ))} + {(activities || []).map(a => { + const start = new Date(a.start_time).getTime() + const end = a.duration_s ? start + a.duration_s * 1000 : start + return ( + + ) + })} {(activities || []).map(a => (

📊

@@ -562,11 +576,14 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag

Weight

- {day.weight_kg ? day.weight_kg.toFixed(1) : '--'} + {snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'} - {day.weight_kg && kg} - {day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat} + {snapshotWeight && kg} + {snapshotWeight?.fat && !snapshotWeight.carried && {snapshotWeight.fat.toFixed(1)}% fat}
+ {snapshotWeight?.carried && ( +

as of {format(new Date(snapshotWeight.date), 'd MMM')}

+ )}
@@ -716,6 +733,10 @@ function SleepChart({ data, selectedDate, onDayClick }) { if (!hasData) return (
No sleep data
) + const totals = chartData + .map(d => (d.deep || 0) + (d.rem || 0) + (d.light || 0) + (d.awake || 0)) + .filter(t => t > 0) + const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null return ( )} + + {avgSleep != null && ( + + )} @@ -746,6 +773,99 @@ function SleepChart({ data, selectedDate, onDayClick }) { ) } +// ── Weight (with goal line + kg ⇄ st/lb toggle) ────────────────────────────── + +const KG_TO_LB = 2.2046226218 + +function fmtStLb(lb) { + let st = Math.floor(lb / 14) + let r = Math.round(lb - st * 14) + if (r === 14) { st += 1; r = 0 } + return `${st} st ${r} lb` +} + +function WeightChart({ data, goalKg, selectedDate, onDayClick }) { + const [unit, setUnit] = useState(() => localStorage.getItem('weightUnit') || 'kg') + const choose = (u) => { setUnit(u); localStorage.setItem('weightUnit', u) } + const imperial = unit === 'lb' + const toU = (kg) => (imperial ? kg * KG_TO_LB : kg) + + const withWeight = data.filter(d => d.weight_kg != null) + const series = withWeight.map(d => ({ date: d.date, w: +toU(d.weight_kg).toFixed(2) })) + + const title = imperial ? 'Weight (st & lb)' : 'Weight (kg)' + const toggle = ( +
+ {[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => ( + + ))} +
+ ) + + if (!series.length) { + return ( + <> +
+

{title}

{toggle} +
+
No weight data
+ + ) + } + + const maxKg = Math.max(...withWeight.map(d => d.weight_kg)) + const minW = Math.min(...series.map(s => s.w)) + const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null + const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent + const yMin = Math.max(0, Math.floor(minW - (imperial ? 6 : 3))) + const fmtVal = (v) => (imperial ? fmtStLb(v) : `${v.toFixed(1)} kg`) + + return ( + <> +
+

{title}

{toggle} +
+ + { + const p = evt?.activePayload?.[0]?.payload + if (p?.date && onDayClick) onDayClick(p.date) + }}> + + + + + + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + Math.round(v)} /> + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [fmtVal(v), 'Weight']} /> + {selectedDate && ( + + )} + {goalU != null && ( + + )} + + + + + ) +} + // ── Page ───────────────────────────────────────────────────────────────────── export default function HealthPage() { @@ -809,6 +929,15 @@ export default function HealthPage() { return found ? found.vo2max : null }, [allDaysSorted]) + // Weight for the snapshot: the selected day's, or the most recent earlier reading. + const snapshotWeight = useMemo(() => { + if (!selectedDay) return null + if (selectedDay.weight_kg != null) + return { kg: selectedDay.weight_kg, fat: selectedDay.body_fat_pct, carried: false } + const earlier = allDaysSorted.find(d => d.weight_kg != null && d.date <= selectedDay.date) + return earlier ? { kg: earlier.weight_kg, fat: earlier.body_fat_pct, carried: true, date: earlier.date } : null + }, [selectedDay, allDaysSorted]) + const { data: intradayData } = useQuery({ queryKey: ['health-intraday', selectedDay?.date], queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), @@ -844,6 +973,7 @@ export default function HealthPage() {
-

Weight

- d.weight_kg != null)} - dataKey="weight_kg" color="#34d399" - formatter={v => `${v.toFixed(1)} kg`} - selectedDate={selDateForCharts} onDayClick={handleDayClick} - connectNulls showDots /> +
@@ -989,6 +1116,7 @@ export default function HealthPage() {

VO2 Max

v.toFixed(1)} + domain={[30, 70]} connectNulls showDots selectedDate={selDateForCharts} onDayClick={handleDayClick} />
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index ab86500..7d6b619 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { useAuthStore } from '../hooks/useAuth' +import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync' function Section({ title, children }) { return ( @@ -77,25 +78,13 @@ export default function ProfilePage() { enabled: !!user?.is_admin, }) - const { data: recentMetrics } = useQuery({ - queryKey: ['health-metrics-recent'], - queryFn: () => api.get('/health-metrics/', { params: { limit: 7 } }).then(r => r.data), - }) - const { data: healthSummary } = useQuery({ queryKey: ['health-summary'], queryFn: () => api.get('/health-metrics/summary').then(r => r.data), }) - const avgRestingHr = useMemo(() => { - if (!recentMetrics?.length) return null - const vals = recentMetrics.filter(m => m.resting_hr != null).map(m => m.resting_hr) - if (!vals.length) return null - return Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) - }, [recentMetrics]) - // HR / measurements form - const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' }) + const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '', goal_weight_kg: '' }) const [hrSaved, setHrSaved] = useState(false) const [hrZoneRecalc, setHrZoneRecalc] = useState(false) const maxHrChangedRef = useRef(false) @@ -105,6 +94,7 @@ export default function ProfilePage() { birth_year: profile.birth_year || '', height_cm: profile.height_cm || '', biological_sex: profile.biological_sex || '', + goal_weight_kg: profile.goal_weight_kg || '', }) }, [profile]) @@ -140,10 +130,8 @@ export default function ProfilePage() { const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) const [gcSaved, setGcSaved] = useState(false) const [gcError, setGcError] = useState('') - const [gcSyncing, setGcSyncing] = useState(false) - const syncPollRef = useRef(null) + const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync } = useSyncStore() const gcFormLoaded = useRef(false) - useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, []) useEffect(() => { if (garminConfig?.connected && !gcFormLoaded.current) { gcFormLoaded.current = true @@ -177,54 +165,6 @@ export default function ProfilePage() { setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) }, }) - const triggerGarminSync = async () => { - setGcSyncing(true) - try { - await api.post('/garmin-sync/trigger') - // Poll every 3s: wait until we've seen an in-progress status, then wait for terminal - let seenInProgress = false - syncPollRef.current = setInterval(async () => { - const result = await refetchGarmin() - const status = result.data?.last_sync_status ?? '' - const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error') - if (!terminal) seenInProgress = true - if (seenInProgress && terminal) { - clearInterval(syncPollRef.current) - syncPollRef.current = null - setGcSyncing(false) - } - }, 3000) - // Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running - setTimeout(() => { - if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null } - }, 4 * 60 * 60 * 1000) - } catch { - setGcSyncing(false) - } - } - - const syncProgressPct = status => { - if (!status) return 3 - if (status.startsWith('Connecting')) return 10 - if (status.startsWith('Syncing activities')) { - const m = status.match(/(\d+)\/(\d+)/) - if (m) { - const done = parseInt(m[1], 10), total = parseInt(m[2], 10) - if (total > 0) return 15 + Math.round(done / total * 30) - } - return 20 - } - if (status.startsWith('Syncing wellness')) { - const m = status.match(/(\d+)\/(\d+)/) - if (m) { - const done = parseInt(m[1], 10), total = parseInt(m[2], 10) - if (total > 0) return 45 + Math.round(done / total * 45) - } - return 50 - } - return 3 - } - // PocketID config const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' }) const [pidSaved, setPidSaved] = useState(false) @@ -285,22 +225,18 @@ export default function ProfilePage() { - {(avgRestingHr || healthSummary?.latest?.weight_kg) && ( -
- {avgRestingHr && ( -
-

Resting HR (7-day avg, from Garmin)

- {avgRestingHr} bpm -
- )} - {healthSummary?.latest?.weight_kg && ( -
-

Weight (from Garmin)

- {healthSummary.latest.weight_kg.toFixed(1)} kg -
- )} -
- )} +
+ + setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} /> + + {healthSummary?.latest?.weight_kg && ( +
+

Current weight (from Garmin)

+ {healthSummary.latest.weight_kg.toFixed(1)} kg +
+ )} +
{ @@ -449,7 +385,7 @@ export default function ProfilePage() { {garminConfig?.connected && ( <> - ))} - - - {selectedRouteId && ( - isLoading ? ( -

Loading…

- ) : !bests?.length ? ( -

- No segments for this route.{' '} - Create some on the Segments page. -

- ) : ( -
- - - - - - - - - - - {bests.map(b => ( - - - - - - - - ))} - -
SegmentLengthBest timeRuns -
- {b.name} - {b.auto_generated && (auto)} - - {formatDistance(b.end_distance_m - b.start_distance_m)} - - {b.best_s != null - ? {formatDuration(b.best_s)} - : --} - {b.count} - {b.best_activity_id && ( - - View → - - )} -
- {theoreticalBest != null && ( -
- Theoretical best (1km splits only) - {formatDuration(theoreticalBest)} -
- )} -
- ) - )} - - ) -} - export default function RecordsPage() { const [tab, setTab] = useState('Distance PRs') @@ -351,7 +237,6 @@ export default function RecordsPage() { {tab === 'Distance PRs' && } {tab === 'Route Records' && } - {tab === 'Segment Records' && } ) } diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index 0173643..d48b774 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -2,92 +2,9 @@ import { useState } from 'react' import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' +import ActivityMap from '../components/activity/ActivityMap' import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' -function formatSegDist(m) { - if (m == null) return '--' - return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` -} - -function SegmentsPanel({ routeId, sportType }) { - const qc = useQueryClient() - - const { data: segments } = useQuery({ - queryKey: ['segments', routeId], - queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data), - }) - - const { data: bests } = useQuery({ - queryKey: ['segment-bests', routeId], - queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data), - }) - - const deleteSeg = useMutation({ - mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['segments', routeId] }) - qc.invalidateQueries({ queryKey: ['segment-bests', routeId] }) - }, - }) - - if (!segments?.length) return null - - const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b])) - - const kmSplits = segments.filter(s => s.name.startsWith('km ')) - const hillsTurns = segments.filter(s => !s.name.startsWith('km ')) - - const kmBests = (bests || []).filter(b => b.name?.startsWith('km ')) - const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null) - ? kmBests.reduce((sum, b) => sum + b.best_s, 0) - : null - - const renderGroup = (group, title) => { - if (!group.length) return null - return ( -
-

{title}

- {group.map(seg => { - const best = bestMap[seg.id] - return ( -
- {seg.name} - {formatSegDist(seg.end_distance_m - seg.start_distance_m)} - {best?.best_s != null ? ( - {formatDuration(best.best_s)} - ) : ( - -- - )} - -
- ) - })} -
- ) - } - - return ( -
-
-

Segments

- Manage → -
- {renderGroup(kmSplits, '1km Splits')} - {renderGroup(hillsTurns, 'Hills & Turns')} - {theoreticalBest != null && ( -
- Theoretical best (1km splits only) - {formatDuration(theoreticalBest)} -
- )} -
- ) -} - // Decode Google encoded polyline to [[lat,lng], ...] function decodePolyline(encoded) { if (!encoded) return [] @@ -134,47 +51,37 @@ function RouteMap({ polyline, className = '', sportType = '' }) { function routeSportStyle(sportType) { const t = (sportType || '').toLowerCase() if (t.includes('cycl') || t.includes('bike') || t.includes('ride')) - return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' } + return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' } if (t.includes('run') || t.includes('jog') || t.includes('walk')) - return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' } - return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' } + return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' } + return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' } } -export default function RoutesPage() { - const [selected, setSelected] = useState(null) - const [showCreate, setShowCreate] = useState(false) - const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) +const MEDALS = ['🥇', '🥈', '🥉'] + +function RouteDetail({ selected, setSelected }) { + const qc = useQueryClient() const [merging, setMerging] = useState(false) const [mergeTarget, setMergeTarget] = useState('') - const qc = useQueryClient() + const [editingName, setEditingName] = useState(false) + const [nameInput, setNameInput] = useState(selected.name) const { data: routes } = useQuery({ queryKey: ['routes'], queryFn: () => api.get('/routes/').then(r => r.data), }) - // Sort by most completions first - const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0)) - const { data: routeActivities } = useQuery({ - queryKey: ['route-activities', selected?.id], + queryKey: ['route-activities', selected.id], queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), - enabled: !!selected, }) - const { data: recentActivities } = useQuery({ - queryKey: ['recent-activities-for-route'], - queryFn: () => api.get('/routes/recent-activities').then(r => r.data), - enabled: showCreate, - }) - - const createRoute = useMutation({ - mutationFn: data => api.post('/routes/', data).then(r => r.data), - onSuccess: route => { + const renameRoute = useMutation({ + mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data), + onSuccess: updated => { qc.invalidateQueries({ queryKey: ['routes'] }) - setShowCreate(false) - setNewRoute({ name: '', activity_id: '' }) - setSelected(route) + setSelected(updated) + setEditingName(false) }, }) @@ -198,7 +105,161 @@ export default function RoutesPage() { }) const fastest = routeActivities?.[0] - const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? [] + const crTime = fastest?.duration_s + const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type) + + return ( +
+
+
+
+ {selected.reference_polyline + ? + : } +
+
+ {editingName ? ( +
+ setNameInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }} + autoFocus + className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ ) : ( +
+

{selected.name}

+ +
+ )} +
+ {selected.sport_type && {selected.sport_type}} + {formatDistance(selected.distance_m)} + {selected.auto_detected && ( + Auto-detected + )} +
+
+
+
+ + +
+
+ + {/* Merge panel */} + {merging && ( +
+

Merge another route into this one

+

All activities from the selected route will be moved here, then the other route will be deleted.

+
+ + +
+ {otherRoutes.length === 0 && ( +

No other {selected.sport_type} routes to merge with.

+ )} +
+ )} + + {/* Podium */} + {routeActivities?.length > 0 && ( +
+ {routeActivities.slice(0, 3).map((act, i) => ( + +

{MEDALS[i]}

+

{formatDuration(act.duration_s)}

+

{formatDate(act.start_time)}

+ {i > 0 && crTime != null && ( +

+{formatDuration(act.duration_s - crTime)}

+ )} + + ))} +
+ )} + + {/* All completions */} +

All completions ({routeActivities?.length ?? 0})

+
+ {routeActivities?.map((act, i) => { + const delta = crTime != null ? act.duration_s - crTime : null + return ( + + {i + 1} + {formatDate(act.start_time)} + {formatDuration(act.duration_s)} + + {i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''} + + {formatPace(act.avg_speed_ms, selected.sport_type)} + {act.avg_heart_rate + ? {Math.round(act.avg_heart_rate)} bpm + : } + + + ) + })} +
+
+ ) +} + +export default function RoutesPage() { + const [selected, setSelected] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) + const qc = useQueryClient() + + const { data: routes } = useQuery({ + queryKey: ['routes'], + queryFn: () => api.get('/routes/').then(r => r.data), + }) + + // Sort by most completions first + const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0)) + + const { data: recentActivities } = useQuery({ + queryKey: ['recent-activities-for-route'], + queryFn: () => api.get('/routes/recent-activities').then(r => r.data), + enabled: showCreate, + }) + + const createRoute = useMutation({ + mutationFn: data => api.post('/routes/', data).then(r => r.data), + onSuccess: route => { + qc.invalidateQueries({ queryKey: ['routes'] }) + setShowCreate(false) + setNewRoute({ name: '', activity_id: '' }) + setSelected(route) + }, + }) return (
@@ -256,7 +317,7 @@ export default function RoutesPage() {
)} - {/* Route tile grid */} + {/* Route tile grid — selected route's detail expands inline under its row */} {routes?.length === 0 && !showCreate ? (

🗺️

@@ -268,9 +329,9 @@ export default function RoutesPage() { {sortedRoutes.map(route => { const style = routeSportStyle(route.sport_type) const isSelected = selected?.id === route.id - return ( + return [ - ) + {route.auto_detected && auto} + , + isSelected && , + ] })}
)} - - {/* Route detail — shown below the tile grid when a route is selected */} - {selected && ( -
-
-
-
- -
-

{selected.name}

-
- {selected.sport_type && {selected.sport_type}} - {formatDistance(selected.distance_m)} - {selected.auto_detected && ( - Auto-detected - )} -
-
-
-
- - -
-
- - {/* Merge panel */} - {merging && ( -
-

Merge another route into this one

-

All activities from the selected route will be moved here, then the other route will be deleted.

-
- - - -
- {otherRoutes.length === 0 && ( -

No other {selected.sport_type} routes to merge with.

- )} -
- )} - - {/* Course record */} - {fastest && ( -
-

Course record 🏆

-
- {formatDuration(fastest.duration_s)} - - {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)} - -
-
- )} - - {/* Activity list */} -

- All completions ({routeActivities?.length ?? 0}) -

-
- {routeActivities?.map((act, i) => ( - - {i + 1} - {formatDate(act.start_time)} - {formatDuration(act.duration_s)} - {formatPace(act.avg_speed_ms, selected.sport_type)} - {act.avg_heart_rate && ( - {Math.round(act.avg_heart_rate)} bpm - )} - {i === 0 && ( - CR - )} - - - ))} -
- - -
-
- )} ) } diff --git a/frontend/src/pages/SegmentsPage.jsx b/frontend/src/pages/SegmentsPage.jsx deleted file mode 100644 index aa54fcf..0000000 --- a/frontend/src/pages/SegmentsPage.jsx +++ /dev/null @@ -1,365 +0,0 @@ -import { useState } from 'react' -import { Link } from 'react-router-dom' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { format } from 'date-fns' -import api from '../utils/api' -import { formatDuration, formatDistance } from '../utils/format' -import RouteMiniMap from '../components/ui/RouteMiniMap' - -function formatSegmentDist(m) { - if (m == null) return '--' - return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` -} - -function SegmentRow({ seg, routeId, routePolyline, sportType }) { - const [expanded, setExpanded] = useState(false) - const queryClient = useQueryClient() - - const { data: times, isLoading: timesLoading } = useQuery({ - queryKey: ['segment-times', routeId, seg.id], - queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data), - }) - - const deleteMut = useMutation({ - mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }), - }) - - const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null - const lastTime = times?.[0]?.duration_s ?? null - - return ( -
- {/* Main row */} -
- {/* Segment mini-map */} -
- -
- -
-
- {seg.name} - {seg.auto_generated && ( - - {seg.auto_generated_type || 'auto'} - - )} -
-

- {formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)} - ({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)}) -

- {/* Times preview row */} - {!timesLoading && ( -
- {bestTime && ( - - Best {formatDuration(bestTime)} - - )} - {lastTime && lastTime !== bestTime && ( - - Last {formatDuration(lastTime)} - - )} - {times?.length > 0 && ( - - {times.length} run{times.length !== 1 ? 's' : ''} - - )} - {times?.length === 0 && ( - No times yet - )} -
- )} - {timesLoading &&

Loading times…

} -
- -
- {times?.length > 0 && ( - - )} - -
-
- - {/* Expanded times list */} - {expanded && times?.length > 0 && ( -
- {times.map((t, i) => ( -
- - {formatDuration(t.duration_s)} - - - {t.name} - - {format(new Date(t.date), 'd MMM yyyy')} -
- ))} -
- )} -
- ) -} - -function NewSegmentForm({ routeId, onCreated }) { - const queryClient = useQueryClient() - const [name, setName] = useState('') - const [startKm, setStartKm] = useState('') - const [endKm, setEndKm] = useState('') - const [open, setOpen] = useState(false) - - const mut = useMutation({ - mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['segments', routeId] }) - setName(''); setStartKm(''); setEndKm(''); setOpen(false) - if (onCreated) onCreated() - }, - }) - - if (!open) { - return ( - - ) - } - - const handleSubmit = (e) => { - e.preventDefault() - const start = parseFloat(startKm) * 1000 - const end = parseFloat(endKm) * 1000 - if (!name || isNaN(start) || isNaN(end) || end <= start) return - mut.mutate({ name, start_distance_m: start, end_distance_m: end }) - } - - return ( -
-

New segment

- setName(e.target.value)} - className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" - required - /> -
- setStartKm(e.target.value)} - className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" - required - /> - setEndKm(e.target.value)} - className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" - required - /> -
-
- - -
-
- ) -} - -export default function SegmentsPage() { - const [selectedRouteId, setSelectedRouteId] = useState(null) - const [autoGenLoading, setAutoGenLoading] = useState(null) - const [hillGradient, setHillGradient] = useState(5) - const queryClient = useQueryClient() - - const { data: routes } = useQuery({ - queryKey: ['routes'], - queryFn: () => api.get('/routes/').then(r => r.data), - }) - - const selectedRoute = routes?.find(r => r.id === selectedRouteId) - - const { data: segments, isLoading: segsLoading } = useQuery({ - queryKey: ['segments', selectedRouteId], - queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data), - enabled: !!selectedRouteId, - }) - - const autoGenMut = useMutation({ - mutationFn: ({ type, opts }) => - api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] }) - setAutoGenLoading(null) - }, - onError: (err) => { - alert(err?.response?.data?.detail || 'Auto-generate failed') - setAutoGenLoading(null) - }, - }) - - const handleAutoGen = (type, opts = {}) => { - setAutoGenLoading(type) - autoGenMut.mutate({ type, opts }) - } - - return ( -
-
-

Segments

-
- - {/* Route tile grid */} - {!routes?.length ? ( -
-

No named routes yet. Create one on the Routes page.

-
- ) : ( -
- {routes.map(r => ( - - ))} -
- )} - - {selectedRoute && ( -
- {/* Route info */} -
-
-

{selectedRoute.name}

-

- {selectedRoute.sport_type && {selectedRoute.sport_type}} - {selectedRoute.distance_m && · {formatDistance(selectedRoute.distance_m)}} - {selectedRoute.activity_count > 0 && · {selectedRoute.activity_count} runs} - {selectedRoute.auto_detected && (auto-detected)} -

-
-
- - {/* Auto-generate controls */} -
-

Auto-generate segments

-
- - -
- -
- - setHillGradient(parseInt(e.target.value) || 5)} - className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500" - /> - % -
-
-
-

Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.

-
- - {/* Segments list */} -
-
-

Segments

- {segments?.length > 0 && ( - {segments.length} segment{segments.length !== 1 ? 's' : ''} - )} -
- - {segsLoading &&

Loading…

} - - {!segsLoading && !segments?.length && ( -

No segments yet. Use auto-generate above or add one manually.

- )} - - {segments?.map(seg => ( - - ))} - - -
-
- )} -
- ) -}