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 }) {
| Lap |
Distance |
Time |
+ {hasBests && Best | }
+ {hasBests && Δ | }
Pace |
Avg HR |
Cadence |
@@ -19,25 +22,40 @@ export default function LapTable({ laps, sportType }) {
- {laps.map((lap) => (
-
- | {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) : '--'}
- |
- {showPower && (
-
- {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)} |
+ {hasBests && (
+ {best != null ? formatDuration(best) : '--'} |
+ )}
+ {hasBests && (
+
+ {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) : '--'}
+ |
+ {showPower && (
+
+ {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) => (
+
+ ))}
+
+ )
+}
+
+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 (
+
+ )
+}
+
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 && (
<>