Batch 1: dashboard, maps, segments rewrite, health, sync UX
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar
Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows
Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 20–500 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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -95,40 +95,57 @@ 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:
|
||||||
|
|
||||||
for p in data_points:
|
|
||||||
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:
|
|
||||||
start_time = ts
|
|
||||||
|
|
||||||
if start_time is not None and dist >= end_dist_m:
|
|
||||||
end_time = ts
|
|
||||||
break
|
|
||||||
|
|
||||||
if start_time and end_time:
|
|
||||||
from datetime import datetime
|
|
||||||
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
|
||||||
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
|
||||||
return (t2 - t1).total_seconds()
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
start_pt, end_pt = seg_coords[0], seg_coords[-1]
|
||||||
|
|
||||||
|
si, sd = None, tol_m
|
||||||
|
for i in range(n):
|
||||||
|
d = haversine_m(act_coords[i], start_pt)
|
||||||
|
if d < sd:
|
||||||
|
sd, si = d, i
|
||||||
|
if si is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ei, ed = None, tol_m
|
||||||
|
for i in range(si + 1, n):
|
||||||
|
d = haversine_m(act_coords[i], end_pt)
|
||||||
|
if d < ed:
|
||||||
|
ed, ei = d, i
|
||||||
|
if ei is None or ei <= si:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify the activity actually follows the segment shape between the anchors.
|
||||||
|
for frac in (0.25, 0.5, 0.75):
|
||||||
|
sp = seg_coords[int(frac * (len(seg_coords) - 1))]
|
||||||
|
if not any(haversine_m(act_coords[i], sp) <= tol_m for i in range(si, ei + 1)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
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(
|
||||||
data_points: list[dict],
|
data_points: list[dict],
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
if (trackRef.current) {
|
|
||||||
trackRef.current.remove()
|
|
||||||
trackRef.current = null
|
|
||||||
}
|
|
||||||
if (!polyline) return
|
|
||||||
|
|
||||||
const coords = decodePolyline(polyline)
|
|
||||||
if (!coords.length) return
|
|
||||||
|
|
||||||
trackRef.current = L.polyline(coords, {
|
|
||||||
color: sportColor(sportType),
|
|
||||||
weight: 3,
|
|
||||||
opacity: 0.9,
|
|
||||||
}).addTo(map)
|
|
||||||
|
|
||||||
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
|
||||||
|
|
||||||
const dot = (color) => L.divIcon({
|
const dot = (color) => L.divIcon({
|
||||||
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
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)
|
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
|
||||||
|
if (trackRef.current) {
|
||||||
|
trackRef.current.remove()
|
||||||
|
trackRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
// Prefer the data-point track when colouring by speed; fall back to the encoded polyline.
|
||||||
|
const speedPts = (colorMode === 'speed' && dataPoints)
|
||||||
|
? dataPoints.filter(p => p.latitude != null && p.longitude != null)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const group = L.layerGroup()
|
||||||
|
|
||||||
|
if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) {
|
||||||
|
const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0)
|
||||||
|
speeds.sort((a, b) => a - b)
|
||||||
|
// Clamp the range to the 5th–95th percentile so a couple of GPS spikes don't wash out the ramp.
|
||||||
|
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
|
||||||
|
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
|
||||||
|
|
||||||
|
// Group consecutive points into runs of the same colour bucket → one polyline per run.
|
||||||
|
let runStart = 0
|
||||||
|
let runIdx = speedColorIndex(speedPts[0].speed_ms ?? lo, lo, hi)
|
||||||
|
const flush = (end) => {
|
||||||
|
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
|
||||||
|
if (coords.length >= 2) {
|
||||||
|
L.polyline(coords, { color: SPEED_STOPS[runIdx], weight: 3, opacity: 0.95 }).addTo(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 1; i < speedPts.length; i++) {
|
||||||
|
const idx = speedColorIndex(speedPts[i].speed_ms ?? lo, lo, hi)
|
||||||
|
if (idx !== runIdx) {
|
||||||
|
flush(i) // include current point so runs join up
|
||||||
|
runStart = i
|
||||||
|
runIdx = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(speedPts.length - 1)
|
||||||
|
|
||||||
|
const coords = speedPts.map(p => [p.latitude, p.longitude])
|
||||||
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
|
||||||
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
|
||||||
|
group.addTo(map)
|
||||||
|
trackRef.current = group
|
||||||
|
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid single-colour route from the encoded polyline.
|
||||||
|
if (!polyline) return
|
||||||
|
const coords = decodePolyline(polyline)
|
||||||
|
if (!coords.length) return
|
||||||
|
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
|
||||||
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
|
||||||
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
|
||||||
|
group.addTo(map)
|
||||||
|
trackRef.current = group
|
||||||
|
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = '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
|
||||||
|
|||||||
@@ -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,11 +22,25 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laps.map((lap) => (
|
{laps.map((lap) => {
|
||||||
|
const best = hasBests ? lapBests[String(lap.lap_number)] : null
|
||||||
|
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
|
||||||
|
const isBest = delta != null && delta <= 0.5
|
||||||
|
return (
|
||||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||||
|
{hasBests && (
|
||||||
|
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||||
|
)}
|
||||||
|
{hasBests && (
|
||||||
|
<td className={`py-2 text-right font-mono ${
|
||||||
|
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 text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
@@ -37,7 +54,8 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</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,6 +149,13 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||||
|
<>
|
||||||
|
{/* 165 spm guide → 82.5 in stored (halved) units */}
|
||||||
|
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
|
||||||
|
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={metric.key}
|
dataKey={metric.key}
|
||||||
@@ -142,6 +165,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
connectNulls={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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{healthSummary?.latest?.weight_kg && (
|
{healthSummary?.latest?.weight_kg && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p>
|
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
||||||
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user