Batch 1: dashboard, maps, segments rewrite, health, sync UX
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:59:06 +01:00
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
+31
View File
@@ -195,6 +195,37 @@ async def get_laps(
return result.scalars().all() 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") @router.patch("/{activity_id}/name")
async def rename_activity( async def rename_activity(
activity_id: int, activity_id: int,
+6
View File
@@ -20,6 +20,7 @@ class ProfileUpdate(BaseModel):
birth_year: Optional[int] = None birth_year: Optional[int] = None
height_cm: Optional[float] = None height_cm: Optional[float] = None
biological_sex: Optional[str] = None biological_sex: Optional[str] = None
goal_weight_kg: Optional[float] = None
class ProfileOut(BaseModel): class ProfileOut(BaseModel):
@@ -31,6 +32,7 @@ class ProfileOut(BaseModel):
birth_year: Optional[int] birth_year: Optional[int]
height_cm: Optional[float] height_cm: Optional[float]
biological_sex: Optional[str] biological_sex: Optional[str]
goal_weight_kg: Optional[float]
estimated_max_hr: Optional[int] estimated_max_hr: Optional[int]
is_admin: bool is_admin: bool
@@ -78,6 +80,10 @@ async def update_profile(
if body.biological_sex not in ('male', 'female', ''): if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'") raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None current_user.biological_sex = body.biological_sex or None
if body.goal_weight_kg is not None:
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
raise HTTPException(400, "Goal weight must be 20500 kg")
current_user.goal_weight_kg = body.goal_weight_kg or None
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
+1 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user 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() router = APIRouter()
+31 -323
View File
@@ -7,18 +7,11 @@ from datetime import datetime, timedelta, timezone
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user 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() router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel): class RouteCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
@@ -26,6 +19,11 @@ class RouteCreate(BaseModel):
activity_id: int activity_id: int
class RouteUpdate(BaseModel):
name: Optional[str] = None
sport_type: Optional[str] = None
class RouteOut(BaseModel): class RouteOut(BaseModel):
id: int id: int
name: str name: str
@@ -42,32 +40,6 @@ class RouteOut(BaseModel):
from_attributes = True 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]) @router.get("/", response_model=List[RouteOut])
async def list_routes( async def list_routes(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -179,6 +151,31 @@ async def get_route(
return 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") @router.get("/{route_id}/activities")
async def route_activities( async def route_activities(
route_id: int, route_id: int,
@@ -281,292 +278,3 @@ async def assign_activity_to_route(
activity.named_route_id = route_id activity.named_route_id = route_id
await db.commit() await db.commit()
return {"status": "ok"} return {"status": "ok"}
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
result = await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
auto_generated=False,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
async def delete_segment(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
await db.delete(seg)
await db.commit()
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
async def auto_generate_segments(
route_id: int,
body: AutoGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Auto-generate segments: 1km splits, turns, or hills."""
from app.services.route_matcher import (
generate_1km_segments, generate_turn_segments, generate_hill_segments,
)
from sqlalchemy import delete as sql_delete
route = await _get_owned_route(route_id, current_user.id, db)
if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
raw_segments: list[tuple[str, float, float]] = []
if body.type == "1km":
if not route.distance_m:
raise HTTPException(status_code=400, detail="Route has no distance recorded")
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
elif body.type == "turns":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
elif body.type == "hills":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
# Find most recent matched activity for elevation data
act_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(1)
)
act = act_result.scalar_one_or_none()
if not act:
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
from app.models.user import ActivityDataPoint
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
new_segments = []
for name, start_m, end_m in raw_segments:
seg = RouteSegment(
route_id=route_id,
name=name,
start_distance_m=start_m,
end_distance_m=end_m,
auto_generated=True,
auto_generated_type=body.type,
)
db.add(seg)
new_segments.append(seg)
await db.commit()
for seg in new_segments:
await db.refresh(seg)
return new_segments
class SegmentBestOut(BaseModel):
segment_id: int
name: str
start_distance_m: float
end_distance_m: float
auto_generated: bool
best_s: Optional[float]
best_activity_id: Optional[int]
count: int
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
async def get_segment_bests(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return best time per segment across all matched activities for a route."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
from collections import defaultdict
await _get_owned_route(route_id, current_user.id, db)
segs_result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
segments = segs_result.scalars().all()
if not segments:
return []
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(20)
)
activities = acts_result.scalars().all()
if not activities:
return [
SegmentBestOut(
segment_id=s.id, name=s.name,
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
)
for s in segments
]
act_ids = [a.id for a in activities]
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id.in_(act_ids))
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
)
all_dps = dp_result.scalars().all()
# Group data points by activity_id
dp_by_act = defaultdict(list)
for dp in all_dps:
if dp.distance_m is not None:
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
bests = []
for seg in segments:
best_s = None
best_act_id = None
count = 0
for act_id in act_ids:
dp_list = dp_by_act.get(act_id, [])
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration is not None:
count += 1
if best_s is None or duration < best_s:
best_s = duration
best_act_id = act_id
bests.append(SegmentBestOut(
segment_id=seg.id, name=seg.name,
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
auto_generated=bool(seg.auto_generated),
best_s=best_s, best_activity_id=best_act_id, count=count,
))
return bests
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
async def get_segment_times(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the last 10 times this segment was traversed across matched activities."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
await _get_owned_route(route_id, current_user.id, db)
seg_result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = seg_result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(10)
)
activities = acts_result.scalars().all()
times = []
for act in activities:
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [
{"distance_m": p.distance_m, "timestamp": p.timestamp}
for p in dps
if p.distance_m is not None
]
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration:
times.append(SegmentTimeEntry(
activity_id=act.id,
date=act.start_time,
name=act.name,
duration_s=duration,
))
return times
+214
View File
@@ -0,0 +1,214 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
import polyline as polyline_lib
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Segment, SegmentEffort, Activity, ActivityDataPoint
from app.services.route_matcher import haversine_m
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
activity_id: int
start_distance_m: float
end_distance_m: float
class EffortOut(BaseModel):
activity_id: int
activity_name: str
date: Optional[datetime]
duration_s: float
rank: Optional[int]
class SegmentOut(BaseModel):
id: int
name: str
sport_type: Optional[str]
polyline: Optional[str]
distance_m: Optional[float]
created_from_activity_id: Optional[int]
effort_count: int = 0
best_s: Optional[float] = None
class SegmentDetailOut(SegmentOut):
leaderboard: List[EffortOut] = []
class ActivitySegmentOut(BaseModel):
segment_id: int
name: str
polyline: Optional[str]
distance_m: Optional[float]
duration_s: float
rank: Optional[int] # this activity's place on the leaderboard
best_s: Optional[float] # current gold time
effort_count: int
def _bbox(coords):
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats), "min_lon": min(lons), "max_lon": max(lons)}
async def _own_segment(segment_id: int, user_id: int, db: AsyncSession) -> Segment:
seg = (await db.execute(
select(Segment).where(Segment.id == segment_id, Segment.user_id == user_id)
)).scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
return seg
@router.get("/", response_model=List[SegmentOut])
async def list_segments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segs = (await db.execute(
select(Segment).where(Segment.user_id == current_user.id).order_by(Segment.created_at.desc())
)).scalars().all()
out = []
for s in segs:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == s.id)
)).one()
out.append(SegmentOut(
id=s.id, name=s.name, sport_type=s.sport_type, polyline=s.polyline,
distance_m=s.distance_m, created_from_activity_id=s.created_from_activity_id,
effort_count=agg[0] or 0, best_s=agg[1],
))
return out
@router.post("/", response_model=SegmentOut)
async def create_segment(
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity = (await db.execute(
select(Activity).where(Activity.id == body.activity_id, Activity.user_id == current_user.id)
)).scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
lo, hi = sorted((body.start_distance_m, body.end_distance_m))
dps = (await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == activity.id)
.order_by(ActivityDataPoint.timestamp)
)).scalars().all()
coords = [
(p.latitude, p.longitude)
for p in dps
if p.distance_m is not None and p.latitude is not None and p.longitude is not None
and lo <= p.distance_m <= hi
]
if len(coords) < 2:
raise HTTPException(status_code=400, detail="Selected range has too few GPS points")
seg = Segment(
user_id=current_user.id,
name=body.name.strip() or "Segment",
sport_type=activity.sport_type,
polyline=polyline_lib.encode(coords),
start_lat=coords[0][0], start_lng=coords[0][1],
end_lat=coords[-1][0], end_lng=coords[-1][1],
distance_m=max(0.0, hi - lo),
bounding_box=_bbox(coords),
created_from_activity_id=activity.id,
)
db.add(seg)
await db.commit()
await db.refresh(seg)
# Match across all activities in the background.
from app.workers.tasks import match_segment
match_segment.delay(seg.id)
return SegmentOut(
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
effort_count=0, best_s=None,
)
@router.get("/by-activity/{activity_id}", response_model=List[ActivitySegmentOut])
async def segments_for_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Segments that this activity has an effort on, with the activity's place + the gold time."""
rows = (await db.execute(
select(Segment, SegmentEffort)
.join(SegmentEffort, SegmentEffort.segment_id == Segment.id)
.where(Segment.user_id == current_user.id, SegmentEffort.activity_id == activity_id)
.order_by(Segment.created_at.desc())
)).all()
out = []
for seg, effort in rows:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == seg.id)
)).one()
out.append(ActivitySegmentOut(
segment_id=seg.id, name=seg.name, polyline=seg.polyline, distance_m=seg.distance_m,
duration_s=effort.duration_s, rank=effort.rank,
best_s=agg[1], effort_count=agg[0] or 0,
))
return out
@router.get("/{segment_id}", response_model=SegmentDetailOut)
async def get_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
rows = (await db.execute(
select(SegmentEffort, Activity)
.join(Activity, Activity.id == SegmentEffort.activity_id)
.where(SegmentEffort.segment_id == seg.id)
.order_by(SegmentEffort.duration_s)
)).all()
leaderboard = [
EffortOut(
activity_id=e.activity_id, activity_name=a.name,
date=e.achieved_at or a.start_time, duration_s=e.duration_s, rank=e.rank,
)
for e, a in rows
]
return SegmentDetailOut(
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
effort_count=len(leaderboard), best_s=leaderboard[0].duration_s if leaderboard else None,
leaderboard=leaderboard,
)
@router.delete("/{segment_id}", status_code=204)
async def delete_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
await db.delete(seg)
await db.commit()
+4 -3
View File
@@ -19,7 +19,7 @@ from app.core.security import get_current_user
from app.core.config import settings from app.core.config import settings
from app.models.user import ( from app.models.user import (
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute, User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig, Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
) )
router = APIRouter() router = APIRouter()
@@ -122,12 +122,13 @@ async def delete_user(
# Ordered deletes: PersonalRecord and the activity/route child tables have no # 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. # 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) 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(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids))) 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(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(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.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)) await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
+5 -7
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings 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(): async def init_db():
@@ -82,17 +82,14 @@ async def init_db():
except Exception as e: except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {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: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text( await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" "ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
)) ))
except Exception as e: 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 # Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try: 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(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"]) 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(users.router, prefix="/api/users", tags=["users"])
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
@app.get("/health") @app.get("/health")
+34 -10
View File
@@ -28,6 +28,7 @@ class User(Base):
birth_year = Column(Integer, nullable=True) birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True) height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female' 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 config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True) pocketid_issuer = Column(String(512), nullable=True)
@@ -172,22 +173,45 @@ class NamedRoute(Base):
user = relationship("User", back_populates="named_routes") user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route") activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base): class Segment(Base):
__tablename__ = "route_segments" """A user-defined GPS segment (a stretch of road/trail) matched across activities."""
__tablename__ = "segments"
id = Column(Integer, primary_key=True) 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) name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False) sport_type = Column(String(64), nullable=True)
end_distance_m = Column(Float, nullable=False) polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
description = Column(Text, nullable=True) start_lat = Column(Float, nullable=True)
auto_generated = Column(Boolean, default=False) start_lng = Column(Float, nullable=True)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills' 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): class PersonalRecord(Base):
+42 -173
View File
@@ -95,39 +95,56 @@ def routes_are_similar(
return dist < dtw_threshold_m return dist < dtw_threshold_m
def find_segment_times( def match_segment_in_activity(
data_points: list[dict], seg_coords: list[tuple],
start_dist_m: float, act_coords: list[tuple],
end_dist_m: float, act_times: list,
tol_m: float = 30.0,
) -> Optional[float]: ) -> Optional[float]:
""" """
Given activity data points (with cumulative distance_m), Determine whether an activity track traverses a segment's GPS geometry, and if so
find the time to traverse from start_dist_m to end_dist_m. how long it took. Works even when the activity's overall route differs — only the
Returns duration in seconds, or None if not found. 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 n = len(act_coords)
end_time = None if n < 2 or len(seg_coords) < 2:
return None
for p in data_points: start_pt, end_pt = seg_coords[0], seg_coords[-1]
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
continue
if start_time is None and dist >= start_dist_m: si, sd = None, tol_m
start_time = ts 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: ei, ed = None, tol_m
end_time = ts for i in range(si + 1, n):
break 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: # Verify the activity actually follows the segment shape between the anchors.
from datetime import datetime for frac in (0.25, 0.5, 0.75):
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time sp = seg_coords[int(frac * (len(seg_coords) - 1))]
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time if not any(haversine_m(act_coords[i], sp) <= tol_m for i in range(si, ei + 1)):
return (t2 - t1).total_seconds() 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( def find_best_split_time(
@@ -174,154 +191,6 @@ def find_best_split_time(
return best 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 = [ STANDARD_DISTANCES = [
(400, "400m"), (400, "400m"),
(800, "800m"), (800, "800m"),
+140
View File
@@ -199,6 +199,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
compute_personal_records.delay(activity_id, user_id, parsed) compute_personal_records.delay(activity_id, user_id, parsed)
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"): if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id) detect_route.delay(activity_id, user_id)
match_activity_segments.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"} 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() 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") @celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int): def process_garmin_health_zip(zip_path: str, user_id: int):
"""Extract wellness data from a Garmin Connect export ZIP.""" """Extract wellness data from a Garmin Connect export ZIP."""
-2
View File
@@ -8,7 +8,6 @@ import ActivitiesPage from './pages/ActivitiesPage'
import ActivityDetailPage from './pages/ActivityDetailPage' import ActivityDetailPage from './pages/ActivityDetailPage'
import HealthPage from './pages/HealthPage' import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage' import RoutesPage from './pages/RoutesPage'
import SegmentsPage from './pages/SegmentsPage'
import RecordsPage from './pages/RecordsPage' import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage' import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage' import ProfilePage from './pages/ProfilePage'
@@ -36,7 +35,6 @@ export default function App() {
<Route path="activities/:id" element={<ActivityDetailPage />} /> <Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} /> <Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} /> <Route path="routes" element={<RoutesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} /> <Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
@@ -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) { function decodePolyline(encoded) {
const coords = [] const coords = []
let index = 0, lat = 0, lng = 0 let index = 0, lat = 0, lng = 0
@@ -39,43 +52,82 @@ function decodePolyline(encoded) {
return coords return coords
} }
function drawRoute(map, polyline, sportType, trackRef) { const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) { if (trackRef.current) {
trackRef.current.remove() trackRef.current.remove()
trackRef.current = null 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 5th95th 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) const coords = decodePolyline(polyline)
if (!coords.length) return if (!coords.length) return
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
trackRef.current = L.polyline(coords, { L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
color: sportColor(sportType), L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
weight: 3, group.addTo(map)
opacity: 0.9, trackRef.current = group
}).addTo(map) map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
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)
} }
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 mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
const trackRef = useRef(null) const trackRef = useRef(null)
const tileLayerRef = useRef(null) const tileLayerRef = useRef(null)
const polylineRef = useRef(polyline) const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
const sportTypeRef = useRef(sportType) const clickRef = useRef(onMapClick)
useEffect(() => { polylineRef.current = polyline }, [polyline]) drawArgsRef.current = { polyline, dataPoints, sportType, colorMode }
useEffect(() => { sportTypeRef.current = sportType }, [sportType]) useEffect(() => { clickRef.current = onMapClick }, [onMapClick])
useEffect(() => { useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return if (!mapRef.current || mapInstanceRef.current) return
@@ -83,13 +135,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
mapInstanceRef.current = L.map(mapRef.current, { mapInstanceRef.current = L.map(mapRef.current, {
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
preferCanvas: true,
}) })
const tile = TILE_LAYERS.dark const tile = TILE_LAYERS.street
tileLayerRef.current = L.tileLayer(tile.url, { tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
attribution: tile.attribution, .addTo(mapInstanceRef.current)
maxZoom: 19,
}).addTo(mapInstanceRef.current) mapInstanceRef.current.on('click', (e) => {
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
})
return () => { return () => {
mapInstanceRef.current?.remove() mapInstanceRef.current?.remove()
@@ -99,19 +154,16 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return 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() if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, { tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
attribution: tile.attribution, .addTo(mapInstanceRef.current)
maxZoom: 19,
}).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
}, [mapType]) }, [mapType])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef) drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef)
}, [polyline, sportType]) }, [polyline, sportType, colorMode, dataPoints])
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
+37 -19
View File
@@ -2,8 +2,9 @@ import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCade
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking']) 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 showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
const hasBests = lapBests && Object.keys(lapBests).length > 0
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -12,6 +13,8 @@ export default function LapTable({ laps, sportType }) {
<th className="text-left pb-2 font-medium">Lap</th> <th className="text-left pb-2 font-medium">Lap</th>
<th className="text-right pb-2 font-medium">Distance</th> <th className="text-right pb-2 font-medium">Distance</th>
<th className="text-right pb-2 font-medium">Time</th> <th className="text-right pb-2 font-medium">Time</th>
{hasBests && <th className="text-right pb-2 font-medium">Best</th>}
{hasBests && <th className="text-right pb-2 font-medium">Δ</th>}
<th className="text-right pb-2 font-medium">Pace</th> <th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th> <th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th> <th className="text-right pb-2 font-medium">Cadence</th>
@@ -19,25 +22,40 @@ export default function LapTable({ laps, sportType }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{laps.map((lap) => ( {laps.map((lap) => {
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"> const best = hasBests ? lapBests[String(lap.lap_number)] : null
<td className="py-2 text-gray-400">{lap.lap_number}</td> const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td> const isBest = delta != null && delta <= 0.5
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td> return (
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td> <tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-right"> <td className="py-2 text-gray-400">{lap.lap_number}</td>
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span> <td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
</td> <td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
<td className="py-2 text-right text-gray-400"> {hasBests && (
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} <td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
</td> )}
{showPower && ( {hasBests && (
<td className="py-2 text-right text-gray-400"> <td className={`py-2 text-right font-mono ${
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
}`}>
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</td>
)}
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
<td className="py-2 text-right">
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td> </td>
)} <td className="py-2 text-right text-gray-400">
</tr> {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
))} </td>
{showPower && (
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
)}
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1,10 +1,26 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { formatPace, formatCadence } from '../../utils/format' 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 <circle cx={cx} cy={cy} r={2} fill={cadenceColor(payload.cadence * 2)} />
}
function downsample(points, maxPoints = 500) { function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points if (points.length <= maxPoints) return points
const step = Math.ceil(points.length / maxPoints) const step = Math.ceil(points.length / maxPoints)
@@ -133,15 +149,23 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />} content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
isAnimationActive={false} isAnimationActive={false}
/> />
<Line {metric.key === 'cadence' && sportType === 'running' ? (
type="monotone" <>
dataKey={metric.key} {/* 165 spm guide → 82.5 in stored (halved) units */}
stroke={metric.color} <ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
strokeWidth={1.5} <Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
dot={false} </>
isAnimationActive={false} ) : (
connectNulls={false} <Line
/> type="monotone"
dataKey={metric.key}
stroke={metric.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
)}
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -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 <p className="text-xs text-gray-600 py-2">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p>
return (
<div className="space-y-0.5 py-1">
{data.leaderboard.map((e, i) => (
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
<span className="w-5 text-right">{MEDALS[e.rank] || i + 1}</span>
<span className="font-mono text-gray-200 w-14 text-right">{formatDuration(e.duration_s)}</span>
<a href={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">{e.activity_name}</a>
</div>
))}
</div>
)
}
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 (
<div className="space-y-1">
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 text-xs text-gray-600 uppercase tracking-wide">
<span className="flex-1">Segment</span>
<span className="w-14 text-right">This run</span>
<span className="w-14 text-right">Best</span>
<span className="w-10 text-right">Place</span>
</div>
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
return (
<div key={seg.segment_id} className="border-b border-gray-800/40">
<div className="flex items-center gap-3 py-1.5 text-sm">
<button onClick={() => setOpen(open === seg.segment_id ? null : seg.segment_id)}
className="flex-1 text-left text-gray-300 text-xs truncate hover:text-white">
{seg.name}
<span className="text-gray-600 ml-2">{formatDistance(seg.distance_m)}</span>
</button>
<span className={`font-mono text-xs w-14 text-right ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</span>
<span className="w-10 text-right text-xs">
{isPodium
? <span title="New podium time on this activity">{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</span>
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</div>
{open === seg.segment_id && <Leaderboard segmentId={seg.segment_id} />}
</div>
)
})}
</div>
)
}
+22 -1
View File
@@ -1,12 +1,13 @@
import { useEffect } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth' import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
const nav = [ const nav = [
{ to: '/', label: 'Dashboard', icon: '📊', exact: true }, { to: '/', label: 'Dashboard', icon: '📊', exact: true },
{ to: '/activities', label: 'Activities', icon: '🏃' }, { to: '/activities', label: 'Activities', icon: '🏃' },
{ to: '/health', label: 'Health', icon: '❤️' }, { to: '/health', label: 'Health', icon: '❤️' },
{ to: '/routes', label: 'Routes', icon: '🗺️' }, { to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/segments', label: 'Segments', icon: '📏' },
{ to: '/records', label: 'Records', icon: '🏆' }, { to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' }, { to: '/profile', label: 'Profile', icon: '⚙️' },
@@ -16,6 +17,12 @@ const nav = [
export default function Layout() { export default function Layout() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
useEffect(() => {
startPolling()
return () => stopPolling()
}, [])
const handleLogout = () => { const handleLogout = () => {
logout() logout()
@@ -48,6 +55,20 @@ export default function Layout() {
))} ))}
</nav> </nav>
{inProgress && (
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-blue-400">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Garmin sync
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(status)}%` }} />
</div>
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div>
)}
<div className="px-4 py-4 border-t border-gray-800"> <div className="px-4 py-4 border-t border-gray-800">
<button onClick={handleLogout} <button onClick={handleLogout}
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"> className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
+87
View File
@@ -0,0 +1,87 @@
import { create } from 'zustand'
import api from '../utils/api'
// A status string is "terminal" when the sync has finished (success, partial, or error).
const isTerminal = (s) =>
s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') ||
s.startsWith('Credentials') || s.startsWith('Connected')
// Map a Garmin sync status string to an approximate completion percentage.
export function 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 && +m[2] > 0) return 15 + Math.round((+m[1] / +m[2]) * 30)
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m && +m[2] > 0) return 45 + Math.round((+m[1] / +m[2]) * 45)
return 50
}
return 3
}
export function syncPhase(status) {
if (!status) return -1
if (status.startsWith('Connecting') || status.startsWith('Starting')) return 0
if (status.startsWith('Syncing activities')) return 1
if (status.startsWith('Syncing wellness')) return 2
return -1
}
let pollTimer = null
export const useSyncStore = create((set, get) => ({
status: '',
inProgress: false,
connected: false,
lastSyncAt: null,
email: '',
poll: async () => {
try {
const { data } = await api.get('/garmin-sync/config')
const status = data?.last_sync_status ?? ''
const inProgress = !!status && !isTerminal(status)
set({
status, inProgress,
connected: !!data?.connected,
lastSyncAt: data?.last_sync_at ?? null,
email: data?.email ?? '',
})
return inProgress
} catch {
return get().inProgress
}
},
// Adaptive polling: fast while a sync runs, slow when idle. Runs for the
// lifetime of the app (started by Layout) so the floating bar stays accurate
// no matter which page you're on.
startPolling: () => {
if (pollTimer) return
const tick = async () => {
const inProgress = await get().poll()
pollTimer = setTimeout(tick, inProgress ? 3000 : 20000)
}
tick()
},
stopPolling: () => {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
},
trigger: async () => {
set({ inProgress: true, status: 'Starting sync…' })
try {
await api.post('/garmin-sync/trigger')
} catch {
set({ inProgress: false })
return
}
get().stopPolling()
get().startPolling()
},
}))
+101 -55
View File
@@ -1,25 +1,27 @@
import { useParams, Link } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap' import ActivityMap from '../components/activity/ActivityMap'
import MetricTimeline from '../components/activity/MetricTimeline' import MetricTimeline from '../components/activity/MetricTimeline'
import HRZoneBar from '../components/activity/HRZoneBar' import HRZoneBar from '../components/activity/HRZoneBar'
import LapTable from '../components/activity/LapTable' import LapTable from '../components/activity/LapTable'
import SegmentsPanel from '../components/activity/SegmentsPanel'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import { import {
formatDuration, formatDistance, formatPace, formatElevation, formatDuration, formatDistance, formatPace, formatElevation,
formatHeartRate, formatDateTime, formatCadence, sportIcon, formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format' } from '../utils/format'
function segmentTime(points, startM, endM) { // Find the cumulative distance along the track nearest a clicked lat/lng.
let t0 = null function nearestDistance(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) { for (const p of points) {
if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime() if (p.latitude == null || p.longitude == null || p.distance_m == null) continue
if (t0 !== null && p.distance_m >= endM) const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
return (new Date(p.timestamp).getTime() - t0) / 1000 if (d < bestD) { bestD = d; best = p.distance_m }
} }
return null return best
} }
const METRICS = [ const METRICS = [
@@ -36,7 +38,12 @@ export default function ActivityDetailPage() {
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null) const [hoveredDistance, setHoveredDistance] = useState(null)
const [mapHeight, setMapHeight] = useState(420) const [mapHeight, setMapHeight] = useState(420)
const [mapType, setMapType] = useState('dark') const [mapType, setMapType] = useState('street')
const [colorMode, setColorMode] = useState('speed')
const [segCreate, setSegCreate] = useState(false)
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
const [segName, setSegName] = useState('')
const qc = useQueryClient()
const { data: activity, isLoading } = useQuery({ const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id], queryKey: ['activity', id],
@@ -55,17 +62,36 @@ export default function ActivityDetailPage() {
enabled: !!activity, enabled: !!activity,
}) })
const { data: segments } = useQuery({ const { data: actSegments } = useQuery({
queryKey: ['segments', activity?.named_route_id], queryKey: ['activity-segments', id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data), queryFn: () => api.get(`/segments/by-activity/${id}`).then(r => r.data),
enabled: !!activity,
})
const { data: lapBests } = useQuery({
queryKey: ['lap-bests', id],
queryFn: () => api.get(`/activities/${id}/lap-bests`).then(r => r.data),
enabled: !!activity?.named_route_id, enabled: !!activity?.named_route_id,
}) })
const { data: segmentBests } = useQuery({ const handleMapClick = ({ lat, lng }) => {
queryKey: ['segment-bests', activity?.named_route_id], if (!segCreate || !dataPoints) return
queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data), const dist = nearestDistance(dataPoints, lat, lng)
enabled: !!activity?.named_route_id, if (dist == null) return
}) setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
}
const createSegment = async () => {
const [a, b] = segPoints
await api.post('/segments', {
name: segName.trim() || 'Segment',
activity_id: Number(id),
start_distance_m: a.distance_m,
end_distance_m: b.distance_m,
})
setSegCreate(false); setSegPoints([]); setSegName('')
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
}
const toggleMetric = (key) => { const toggleMetric = (key) => {
setActiveMetrics(prev => setActiveMetrics(prev =>
@@ -140,9 +166,31 @@ export default function ActivityDetailPage() {
{t} {t}
</button> </button>
))} ))}
{dataPoints?.length > 0 && (
<button
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ml-2 ${
segCreate ? 'bg-green-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
+ Segment
</button>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span> <span className="text-xs text-gray-500">Route:</span>
{[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => (
<button
key={mode}
onClick={() => setColorMode(mode)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
colorMode === mode ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{label}
</button>
))}
<span className="text-xs text-gray-500 ml-2">Height:</span>
{[280, 420, 560].map(h => ( {[280, 420, 560].map(h => (
<button <button
key={h} key={h}
@@ -156,6 +204,34 @@ export default function ActivityDetailPage() {
))} ))}
</div> </div>
</div> </div>
{segCreate && (
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
<span className="text-green-400">
Click two points on the route to mark the segment start and end.
</span>
<span className="text-gray-400">
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
</span>
{segPoints.length === 2 && (
<>
<input
value={segName}
onChange={e => 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"
/>
<button onClick={createSegment} disabled={!segName.trim()}
className="bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">
Create
</button>
</>
)}
{segPoints.length > 0 && (
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
)}
</div>
)}
<div style={{ height: mapHeight }}> <div style={{ height: mapHeight }}>
<ActivityMap <ActivityMap
polyline={activity.polyline} polyline={activity.polyline}
@@ -163,6 +239,8 @@ export default function ActivityDetailPage() {
hoveredDistance={hoveredDistance} hoveredDistance={hoveredDistance}
sportType={activity.sport_type} sportType={activity.sport_type}
mapType={mapType} mapType={mapType}
colorMode={colorMode}
onMapClick={segCreate ? handleMapClick : undefined}
/> />
</div> </div>
</div> </div>
@@ -202,50 +280,18 @@ export default function ActivityDetailPage() {
</div> </div>
{/* Laps + Segments side by side */} {/* Laps + Segments side by side */}
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && ( {((laps && laps.length > 0) || (actSegments && actSegments.length > 0)) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{laps && laps.length > 0 && ( {laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} /> <LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div> </div>
)} )}
{segments && segments.length > 0 && dataPoints && ( {actSegments && actSegments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3"> <h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<h3 className="text-sm font-medium text-gray-300">Segments</h3> <SegmentsPanel segments={actSegments} />
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
</div>
<div className="space-y-0.5">
{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 (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
</span>
<span className={`font-mono text-xs w-14 text-right ${
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</span>
</div>
)
})}
</div>
</div> </div>
)} )}
</div> </div>
+43 -1
View File
@@ -4,11 +4,21 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContaine
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import { import {
formatDuration, formatDistance, formatPace, formatHeartRate, formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep, formatDate, sportIcon, formatSleep,
} from '../utils/format' } from '../utils/format'
function Stat({ label, value }) {
return (
<div className="bg-gray-900 px-4 py-3 flex flex-col justify-center">
<p className="text-xs text-gray-500">{label}</p>
<p className="text-lg font-semibold text-white">{value}</p>
</div>
)
}
function bbLevelColor(level) { function bbLevelColor(level) {
if (level == null) return '#6b7280' if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6' if (level >= 75) return '#3b82f6'
@@ -154,6 +164,7 @@ export default function DashboardPage() {
}) })
const latest = healthSummary?.latest const latest = healthSummary?.latest
const featured = recentActivities?.[0]
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -203,6 +214,37 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Featured most-recent activity */}
{featured && (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{sportIcon(featured.sport_type)}</span>
<div className="min-w-0">
<Link to={`/activities/${featured.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">
{featured.name}
</Link>
<p className="text-xs text-gray-500">{formatDate(featured.start_time)}</p>
</div>
</div>
<Link to={`/activities/${featured.id}`} className="text-xs text-blue-400 hover:underline flex-shrink-0">Open </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2 h-64 bg-gray-950">
{featured.polyline
? <ActivityMap polyline={featured.polyline} sportType={featured.sport_type} colorMode="solid" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50">
<Stat label="Distance" value={formatDistance(featured.distance_m)} />
<Stat label="Elevation ↑" value={formatElevation(featured.elevation_gain_m)} />
<Stat label="Moving time" value={formatDuration(featured.duration_s)} />
<Stat label="Calories" value={featured.calories ? `${Math.round(featured.calories)} kcal` : '--'} />
</div>
</div>
</div>
)}
{/* Recent activities */} {/* Recent activities */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
+140 -12
View File
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query' import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { import {
AreaChart, Area, BarChart, Bar, ReferenceLine, AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts' } from 'recharts'
import { format, subDays } from 'date-fns' import { format, subDays } from 'date-fns'
@@ -280,6 +280,20 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} /> <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))} ))}
</Bar> </Bar>
{(activities || []).map(a => {
const start = new Date(a.start_time).getTime()
const end = a.duration_s ? start + a.duration_s * 1000 : start
return (
<ReferenceArea
key={`area-${a.id}`}
x1={start}
x2={end}
fill="rgba(255,255,255,0.12)"
stroke="rgba(255,255,255,0.25)"
strokeWidth={1}
/>
)
})}
{(activities || []).map(a => ( {(activities || []).map(a => (
<ReferenceLine <ReferenceLine
key={a.id} key={a.id}
@@ -425,7 +439,7 @@ function NavArrow({ onClick, disabled, children }) {
) )
} }
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) { function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return ( if (!day) return (
<div className="text-center py-10 text-gray-600"> <div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p> <p className="text-3xl mb-2">📊</p>
@@ -562,11 +576,14 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
<p className="text-xs text-gray-500 mb-0.5">Weight</p> <p className="text-xs text-gray-500 mb-0.5">Weight</p>
<div className="flex items-baseline gap-1.5 flex-wrap"> <div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-semibold text-emerald-400"> <span className="text-xl font-semibold text-emerald-400">
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'} {snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
</span> </span>
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>} {snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>} {snapshotWeight?.fat && !snapshotWeight.carried && <span className="text-xs text-gray-500">{snapshotWeight.fat.toFixed(1)}% fat</span>}
</div> </div>
{snapshotWeight?.carried && (
<p className="text-xs text-gray-600 mt-0.5">as of {format(new Date(snapshotWeight.date), 'd MMM')}</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -716,6 +733,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
if (!hasData) return ( if (!hasData) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div> <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
) )
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 ( return (
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
<BarChart <BarChart
@@ -737,6 +758,12 @@ function SleepChart({ data, selectedDate, onDayClick }) {
{selectedDate && ( {selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" /> <ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)} )}
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: '8h', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 }} />
{avgSleep != null && (
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: `avg ${avgSleep}h`, position: 'insideBottomRight', fill: '#a855f7', fontSize: 9 }} />
)}
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" /> <Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" /> <Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
@@ -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 = (
<div className="flex gap-1">
{[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => (
<button key={u} onClick={() => choose(u)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 bg-gray-800 hover:text-white'
}`}>
{label}
</button>
))}
</div>
)
if (!series.length) {
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No weight data</div>
</>
)
}
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 (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}>
<defs>
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis domain={[yMin, yMax]} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={36} tickFormatter={v => Math.round(v)} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [fmtVal(v), 'Weight']} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
{goalU != null && (
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
label={{ value: `Goal ${fmtVal(goalU)}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
)}
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────────────────
export default function HealthPage() { export default function HealthPage() {
@@ -809,6 +929,15 @@ export default function HealthPage() {
return found ? found.vo2max : null return found ? found.vo2max : null
}, [allDaysSorted]) }, [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({ const { data: intradayData } = useQuery({
queryKey: ['health-intraday', selectedDay?.date], queryKey: ['health-intraday', selectedDay?.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
@@ -844,6 +973,7 @@ export default function HealthPage() {
<DailySnapshot <DailySnapshot
day={selectedDay} day={selectedDay}
snapshotWeight={snapshotWeight}
avg30={summary?.avg_30d} avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values} intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery} bodyBattery={intradayData?.body_battery}
@@ -921,13 +1051,10 @@ export default function HealthPage() {
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3> <WeightChart
<MetricChart data={metrics}
data={metrics.filter(d => d.weight_kg != null)} goalKg={profile?.goal_weight_kg}
dataKey="weight_kg" color="#34d399" selectedDate={selDateForCharts} onDayClick={handleDayClick} />
formatter={v => `${v.toFixed(1)} kg`}
selectedDate={selDateForCharts} onDayClick={handleDayClick}
connectNulls showDots />
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -989,6 +1116,7 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" <MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)} formatter={v => v.toFixed(1)}
domain={[30, 70]}
connectNulls showDots connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
+20 -87
View File
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api' import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth' import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
function Section({ title, children }) { function Section({ title, children }) {
return ( return (
@@ -77,25 +78,13 @@ export default function ProfilePage() {
enabled: !!user?.is_admin, 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({ const { data: healthSummary } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data), 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 // 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 [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false) const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false) const maxHrChangedRef = useRef(false)
@@ -105,6 +94,7 @@ export default function ProfilePage() {
birth_year: profile.birth_year || '', birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '', height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '', biological_sex: profile.biological_sex || '',
goal_weight_kg: profile.goal_weight_kg || '',
}) })
}, [profile]) }, [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 [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false) const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('') const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false) const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync } = useSyncStore()
const syncPollRef = useRef(null)
const gcFormLoaded = useRef(false) const gcFormLoaded = useRef(false)
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
useEffect(() => { useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) { if (garminConfig?.connected && !gcFormLoaded.current) {
gcFormLoaded.current = true 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' }) 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 // PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' }) const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
const [pidSaved, setPidSaved] = useState(false) const [pidSaved, setPidSaved] = useState(false)
@@ -285,22 +225,18 @@ export default function ProfilePage() {
</Field> </Field>
</div> </div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && ( <div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
<div className="flex gap-6 pt-3 border-t border-gray-800"> <Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
{avgRestingHr && ( <Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
<div> onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
<p className="text-xs text-gray-500 mb-0.5">Resting HR (7-day avg, from Garmin)</p> </Field>
<span className="text-lg font-semibold text-rose-400">{avgRestingHr} bpm</span> {healthSummary?.latest?.weight_kg && (
</div> <div>
)} <p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
{healthSummary?.latest?.weight_kg && ( <span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
<div> </div>
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p> )}
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span> </div>
</div>
)}
</div>
)}
<SaveButton <SaveButton
onClick={() => { onClick={() => {
@@ -449,7 +385,7 @@ export default function ProfilePage() {
{garminConfig?.connected && ( {garminConfig?.connected && (
<> <>
<button <button
onClick={triggerGarminSync} onClick={triggerSync}
disabled={gcSyncing} disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"> className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'} {gcSyncing ? 'Syncing…' : '↻ Sync now'}
@@ -464,12 +400,9 @@ export default function ProfilePage() {
</div> </div>
{gcSyncing && (() => { {gcSyncing && (() => {
const status = garminConfig?.last_sync_status || '' const status = syncStatus || ''
const pct = syncProgressPct(status) const pct = syncProgressPct(status)
const phase = status.startsWith('Connecting') ? 0 const phase = syncPhase(status)
: status.startsWith('Syncing activities') ? 1
: status.startsWith('Syncing wellness') ? 2
: status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1
return ( return (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
+2 -117
View File
@@ -14,7 +14,7 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k', 'Half marathon', 'Marathon', '50k', '100k',
] ]
const TABS = ['Distance PRs', 'Route Records', 'Segment Records'] const TABS = ['Distance PRs', 'Route Records']
function DistancePRs() { function DistancePRs() {
const [sport, setSport] = useState('running') const [sport, setSport] = useState('running')
@@ -122,7 +122,7 @@ function DistancePRs() {
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')} /> tickFormatter={d => format(new Date(d), 'MMM yy')} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={40} tickFormatter={formatDuration} reversed /> width={40} tickFormatter={formatDuration} />
<Tooltip <Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }} contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
@@ -212,120 +212,6 @@ function RouteRecords() {
) )
} }
function SegmentRecords() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: bests, isLoading } = useQuery({
queryKey: ['segment-bests', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
enabled: !!selectedRouteId,
})
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
if (!routes?.length) return (
<p className="text-sm text-gray-600">
No named routes yet.{' '}
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
</p>
)
return (
<div className="space-y-4">
{/* Route tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">
No segments for this route.{' '}
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
</p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Segment</th>
<th className="text-right px-4 py-3 font-medium">Length</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Runs</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{bests.map(b => (
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3 text-gray-200">
{b.name}
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">
{formatDistance(b.end_distance_m - b.start_distance_m)}
</td>
<td className="px-4 py-3 text-right font-mono font-semibold">
{b.best_s != null
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
<td className="px-4 py-3 text-right">
{b.best_activity_id && (
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
{theoreticalBest != null && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
)}
</div>
)
}
export default function RecordsPage() { export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs') const [tab, setTab] = useState('Distance PRs')
@@ -351,7 +237,6 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />} {tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />} {tab === 'Route Records' && <RouteRecords />}
{tab === 'Segment Records' && <SegmentRecords />}
</div> </div>
) )
} }
+179 -223
View File
@@ -2,92 +2,9 @@ import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api' import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' 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 (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
{group.map(seg => {
const best = bestMap[seg.id]
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
{best?.best_s != null ? (
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
) : (
<span className="text-gray-700 text-xs w-14 text-right">--</span>
)}
<button
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
title="Delete segment"
></button>
</div>
)
})}
</div>
)
}
return (
<div className="border-t border-gray-800 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{renderGroup(kmSplits, '1km Splits')}
{renderGroup(hillsTurns, 'Hills & Turns')}
{theoreticalBest != null && (
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
}
// Decode Google encoded polyline to [[lat,lng], ...] // Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) { function decodePolyline(encoded) {
if (!encoded) return [] if (!encoded) return []
@@ -134,47 +51,37 @@ function RouteMap({ polyline, className = '', sportType = '' }) {
function routeSportStyle(sportType) { function routeSportStyle(sportType) {
const t = (sportType || '').toLowerCase() const t = (sportType || '').toLowerCase()
if (t.includes('cycl') || t.includes('bike') || t.includes('ride')) 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')) 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-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', color: '#6b7280' } return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
} }
export default function RoutesPage() { const MEDALS = ['🥇', '🥈', '🥉']
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false) function RouteDetail({ selected, setSelected }) {
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) const qc = useQueryClient()
const [merging, setMerging] = useState(false) const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('') const [mergeTarget, setMergeTarget] = useState('')
const qc = useQueryClient() const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(selected.name)
const { data: routes } = useQuery({ const { data: routes } = useQuery({
queryKey: ['routes'], queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data), 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({ 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), queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
enabled: !!selected,
}) })
const { data: recentActivities } = useQuery({ const renameRoute = useMutation({
queryKey: ['recent-activities-for-route'], mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
queryFn: () => api.get('/routes/recent-activities').then(r => r.data), onSuccess: updated => {
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] }) qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false) setSelected(updated)
setNewRoute({ name: '', activity_id: '' }) setEditingName(false)
setSelected(route)
}, },
}) })
@@ -198,7 +105,161 @@ export default function RoutesPage() {
}) })
const fastest = routeActivities?.[0] 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 (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-4 items-start min-w-0">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
</div>
<div className="min-w-0">
{editingName ? (
<div className="flex items-center gap-2">
<input
value={nameInput}
onChange={e => 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"
/>
<button onClick={() => renameRoute.mutate(nameInput.trim())} disabled={!nameInput.trim() || renameRoute.isPending}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-2 py-1 rounded-lg">Save</button>
<button onClick={() => { setEditingName(false); setNameInput(selected.name) }}
className="text-xs text-gray-400 hover:text-white px-1">Cancel</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white truncate">{selected.name}</h2>
<button onClick={() => { setNameInput(selected.name); setEditingName(true) }}
className="text-gray-500 hover:text-white text-sm" title="Rename route"></button>
</div>
)}
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Podium */}
{routeActivities?.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{routeActivities.slice(0, 3).map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="bg-gray-800/50 hover:bg-gray-800 rounded-lg p-3 text-center transition-colors">
<p className="text-xl">{MEDALS[i]}</p>
<p className="font-mono text-lg font-bold text-white">{formatDuration(act.duration_s)}</p>
<p className="text-xs text-gray-500">{formatDate(act.start_time)}</p>
{i > 0 && crTime != null && (
<p className="text-xs text-red-400">+{formatDuration(act.duration_s - crTime)}</p>
)}
</Link>
))}
</div>
)}
{/* All completions */}
<h3 className="text-sm font-medium text-gray-400">All completions ({routeActivities?.length ?? 0})</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => {
const delta = crTime != null ? act.duration_s - crTime : null
return (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
</span>
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
: <span className="w-16" />}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
)
})}
</div>
</div>
)
}
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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -256,7 +317,7 @@ export default function RoutesPage() {
</div> </div>
)} )}
{/* Route tile grid */} {/* Route tile grid — selected route's detail expands inline under its row */}
{routes?.length === 0 && !showCreate ? ( {routes?.length === 0 && !showCreate ? (
<div className="text-center py-12 text-gray-600"> <div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p> <p className="text-3xl mb-2">🗺</p>
@@ -268,9 +329,9 @@ export default function RoutesPage() {
{sortedRoutes.map(route => { {sortedRoutes.map(route => {
const style = routeSportStyle(route.sport_type) const style = routeSportStyle(route.sport_type)
const isSelected = selected?.id === route.id const isSelected = selected?.id === route.id
return ( return [
<button key={route.id} <button key={route.id}
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }} onClick={() => setSelected(isSelected ? null : route)}
className={`text-left rounded-xl border p-2 transition-all ${ className={`text-left rounded-xl border p-2 transition-all ${
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600` isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
}`}> }`}>
@@ -279,121 +340,16 @@ export default function RoutesPage() {
<div className="flex items-center justify-between mt-0.5 gap-1"> <div className="flex items-center justify-between mt-0.5 gap-1">
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span> <span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
{route.activity_count > 0 && ( {route.activity_count > 0 && (
<span className={`text-xs font-medium ${style.accent}`}> <span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
{route.activity_count}×
</span>
)} )}
</div> </div>
{route.auto_detected && ( {route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
<span className="text-xs text-gray-600">auto</span> </button>,
)} isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
</button> ]
)
})} })}
</div> </div>
)} )}
{/* Route detail — shown below the tile grid when a route is selected */}
{selected && (
<div className="space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex gap-4 items-start">
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
<div>
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
<button onClick={() => setMerging(false)}
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Course record */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
{/* Activity list */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All completions ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}
</div> </div>
) )
} }
-365
View File
@@ -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 (
<div className="border border-gray-800 rounded-lg overflow-hidden">
{/* Main row */}
<div className="flex items-center gap-3 p-3">
{/* Segment mini-map */}
<div className="flex-shrink-0">
<RouteMiniMap
polyline={routePolyline}
sportType={sportType}
width={72}
height={56}
segmentStartM={seg.start_distance_m}
segmentEndM={seg.end_distance_m}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
{seg.auto_generated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
{seg.auto_generated_type || 'auto'}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{formatSegmentDist(seg.start_distance_m)} {formatSegmentDist(seg.end_distance_m)}
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
</p>
{/* Times preview row */}
{!timesLoading && (
<div className="flex items-center gap-3 mt-1">
{bestTime && (
<span className="text-xs font-mono text-yellow-400">
Best {formatDuration(bestTime)}
</span>
)}
{lastTime && lastTime !== bestTime && (
<span className="text-xs font-mono text-gray-400">
Last {formatDuration(lastTime)}
</span>
)}
{times?.length > 0 && (
<span className="text-xs text-gray-600">
{times.length} run{times.length !== 1 ? 's' : ''}
</span>
)}
{times?.length === 0 && (
<span className="text-xs text-gray-600">No times yet</span>
)}
</div>
)}
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
>
{expanded ? 'Hide' : 'All'}
</button>
)}
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
title="Delete segment"
>
</button>
</div>
</div>
{/* Expanded times list */}
{expanded && times?.length > 0 && (
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
{times.map((t, i) => (
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
{formatDuration(t.duration_s)}
</span>
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
{t.name}
</Link>
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
</div>
))}
</div>
)}
</div>
)
}
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 (
<button
onClick={() => setOpen(true)}
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
>
+ Add segment manually
</button>
)
}
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 (
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
<p className="text-xs text-gray-400 font-medium">New segment</p>
<input
type="text" placeholder="Name (e.g. The big hill)"
value={name} onChange={e => 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
/>
<div className="flex gap-2">
<input
type="number" placeholder="Start (km)" step="0.01" min="0"
value={startKm} onChange={e => 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
/>
<input
type="number" placeholder="End (km)" step="0.01" min="0"
value={endKm} onChange={e => 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
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mut.isPending}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
{mut.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => setOpen(false)}
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
Cancel
</button>
</div>
</form>
)
}
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 (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Segments</h1>
</div>
{/* Route tile grid */}
{!routes?.length ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
<div className="flex items-center justify-between mt-0.5">
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
{r.activity_count > 0 && (
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
)}
</div>
</button>
))}
</div>
)}
{selectedRoute && (
<div className="space-y-4">
{/* Route info */}
<div className="flex items-center gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
<p className="text-xs text-gray-500">
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
</p>
</div>
</div>
{/* Auto-generate controls */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={() => handleAutoGen('1km')}
disabled={autoGenLoading === '1km'}
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
</button>
<button
onClick={() => handleAutoGen('turns')}
disabled={autoGenLoading === 'turns'}
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
disabled={autoGenLoading === 'hills'}
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
</button>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<input
type="number" min="1" max="30" step="1"
value={hillGradient}
onChange={e => 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"
/>
<span className="text-xs text-gray-500">%</span>
</div>
</div>
</div>
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
</div>
{/* Segments list */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
{segments?.length > 0 && (
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
)}
</div>
{segsLoading && <p className="text-sm text-gray-600">Loading</p>}
{!segsLoading && !segments?.length && (
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
)}
{segments?.map(seg => (
<SegmentRow
key={seg.id}
seg={seg}
routeId={selectedRouteId}
routePolyline={selectedRoute.reference_polyline}
sportType={selectedRoute.sport_type}
/>
))}
<NewSegmentForm routeId={selectedRouteId} />
</div>
</div>
)}
</div>
)
}