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()
|
||||
|
||||
|
||||
@router.get("/{activity_id}/lap-bests")
|
||||
async def get_lap_bests(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Best (fastest) time per lap number across all activities on the same route."""
|
||||
act = (await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.id == activity_id,
|
||||
Activity.user_id == current_user.id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
if not act.named_route_id:
|
||||
return {}
|
||||
|
||||
rows = (await db.execute(
|
||||
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
|
||||
.join(Activity, Activity.id == ActivityLap.activity_id)
|
||||
.where(
|
||||
Activity.named_route_id == act.named_route_id,
|
||||
Activity.user_id == current_user.id,
|
||||
ActivityLap.duration_s.isnot(None),
|
||||
)
|
||||
.group_by(ActivityLap.lap_number)
|
||||
)).all()
|
||||
return {str(lap_number): best for lap_number, best in rows}
|
||||
|
||||
|
||||
@router.patch("/{activity_id}/name")
|
||||
async def rename_activity(
|
||||
activity_id: int,
|
||||
|
||||
@@ -20,6 +20,7 @@ class ProfileUpdate(BaseModel):
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
biological_sex: Optional[str] = None
|
||||
goal_weight_kg: Optional[float] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
@@ -31,6 +32,7 @@ class ProfileOut(BaseModel):
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
biological_sex: Optional[str]
|
||||
goal_weight_kg: Optional[float]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
|
||||
@@ -78,6 +80,10 @@ async def update_profile(
|
||||
if body.biological_sex not in ('male', 'female', ''):
|
||||
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
|
||||
current_user.biological_sex = body.biological_sex or None
|
||||
if body.goal_weight_kg is not None:
|
||||
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
|
||||
raise HTTPException(400, "Goal weight must be 20–500 kg")
|
||||
current_user.goal_weight_kg = body.goal_weight_kg or None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
|
||||
from app.models.user import User, PersonalRecord, NamedRoute, HealthMetric, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
+31
-323
@@ -7,18 +7,11 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, NamedRoute, RouteSegment, Activity
|
||||
from app.models.user import User, NamedRoute, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SegmentCreate(BaseModel):
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class RouteCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
@@ -26,6 +19,11 @@ class RouteCreate(BaseModel):
|
||||
activity_id: int
|
||||
|
||||
|
||||
class RouteUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
sport_type: Optional[str] = None
|
||||
|
||||
|
||||
class RouteOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
@@ -42,32 +40,6 @@ class RouteOut(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SegmentOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str]
|
||||
auto_generated: Optional[bool] = False
|
||||
auto_generated_type: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AutoGenerateRequest(BaseModel):
|
||||
type: str # "1km" | "turns" | "hills"
|
||||
gradient_pct: float = 5.0
|
||||
turn_angle_deg: float = 45.0
|
||||
|
||||
|
||||
class SegmentTimeEntry(BaseModel):
|
||||
activity_id: int
|
||||
date: datetime
|
||||
name: str
|
||||
duration_s: float
|
||||
|
||||
|
||||
@router.get("/", response_model=List[RouteOut])
|
||||
async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -179,6 +151,31 @@ async def get_route(
|
||||
return route
|
||||
|
||||
|
||||
@router.patch("/{route_id}", response_model=RouteOut)
|
||||
async def update_route(
|
||||
route_id: int,
|
||||
body: RouteUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(NamedRoute).where(
|
||||
NamedRoute.id == route_id,
|
||||
NamedRoute.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
route = result.scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
if body.name is not None and body.name.strip():
|
||||
route.name = body.name.strip()
|
||||
if body.sport_type is not None:
|
||||
route.sport_type = body.sport_type
|
||||
await db.commit()
|
||||
await db.refresh(route)
|
||||
return route
|
||||
|
||||
|
||||
@router.get("/{route_id}/activities")
|
||||
async def route_activities(
|
||||
route_id: int,
|
||||
@@ -281,292 +278,3 @@ async def assign_activity_to_route(
|
||||
activity.named_route_id = route_id
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
|
||||
result = await db.execute(
|
||||
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
|
||||
)
|
||||
route = result.scalar_one_or_none()
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
return route
|
||||
|
||||
|
||||
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
||||
async def list_segments(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
result = await db.execute(
|
||||
select(RouteSegment)
|
||||
.where(RouteSegment.route_id == route_id)
|
||||
.order_by(RouteSegment.start_distance_m)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{route_id}/segments", response_model=SegmentOut)
|
||||
async def create_segment(
|
||||
route_id: int,
|
||||
body: SegmentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
segment = RouteSegment(
|
||||
route_id=route_id,
|
||||
name=body.name,
|
||||
start_distance_m=body.start_distance_m,
|
||||
end_distance_m=body.end_distance_m,
|
||||
description=body.description,
|
||||
auto_generated=False,
|
||||
)
|
||||
db.add(segment)
|
||||
await db.commit()
|
||||
await db.refresh(segment)
|
||||
return segment
|
||||
|
||||
|
||||
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
|
||||
async def delete_segment(
|
||||
route_id: int,
|
||||
segment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
result = await db.execute(
|
||||
select(RouteSegment).where(
|
||||
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
||||
)
|
||||
)
|
||||
seg = result.scalar_one_or_none()
|
||||
if not seg:
|
||||
raise HTTPException(status_code=404, detail="Segment not found")
|
||||
await db.delete(seg)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
|
||||
async def auto_generate_segments(
|
||||
route_id: int,
|
||||
body: AutoGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Auto-generate segments: 1km splits, turns, or hills."""
|
||||
from app.services.route_matcher import (
|
||||
generate_1km_segments, generate_turn_segments, generate_hill_segments,
|
||||
)
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
route = await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
if body.type not in ("1km", "turns", "hills"):
|
||||
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
|
||||
|
||||
# Clear only auto-generated segments of the same type so other auto types are preserved
|
||||
await db.execute(
|
||||
sql_delete(RouteSegment).where(
|
||||
RouteSegment.route_id == route_id,
|
||||
RouteSegment.auto_generated == True,
|
||||
RouteSegment.auto_generated_type == body.type,
|
||||
)
|
||||
)
|
||||
|
||||
raw_segments: list[tuple[str, float, float]] = []
|
||||
|
||||
if body.type == "1km":
|
||||
if not route.distance_m:
|
||||
raise HTTPException(status_code=400, detail="Route has no distance recorded")
|
||||
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
|
||||
|
||||
elif body.type == "turns":
|
||||
if not route.reference_polyline:
|
||||
raise HTTPException(status_code=400, detail="Route has no polyline")
|
||||
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
|
||||
|
||||
elif body.type == "hills":
|
||||
if not route.reference_polyline:
|
||||
raise HTTPException(status_code=400, detail="Route has no polyline")
|
||||
# Find most recent matched activity for elevation data
|
||||
act_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(1)
|
||||
)
|
||||
act = act_result.scalar_one_or_none()
|
||||
if not act:
|
||||
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
|
||||
from app.models.user import ActivityDataPoint
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == act.id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
)
|
||||
dps = dp_result.scalars().all()
|
||||
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
|
||||
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
|
||||
|
||||
new_segments = []
|
||||
for name, start_m, end_m in raw_segments:
|
||||
seg = RouteSegment(
|
||||
route_id=route_id,
|
||||
name=name,
|
||||
start_distance_m=start_m,
|
||||
end_distance_m=end_m,
|
||||
auto_generated=True,
|
||||
auto_generated_type=body.type,
|
||||
)
|
||||
db.add(seg)
|
||||
new_segments.append(seg)
|
||||
|
||||
await db.commit()
|
||||
for seg in new_segments:
|
||||
await db.refresh(seg)
|
||||
return new_segments
|
||||
|
||||
|
||||
class SegmentBestOut(BaseModel):
|
||||
segment_id: int
|
||||
name: str
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
auto_generated: bool
|
||||
best_s: Optional[float]
|
||||
best_activity_id: Optional[int]
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
|
||||
async def get_segment_bests(
|
||||
route_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return best time per segment across all matched activities for a route."""
|
||||
from app.services.route_matcher import find_segment_times
|
||||
from app.models.user import ActivityDataPoint
|
||||
from collections import defaultdict
|
||||
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
segs_result = await db.execute(
|
||||
select(RouteSegment)
|
||||
.where(RouteSegment.route_id == route_id)
|
||||
.order_by(RouteSegment.start_distance_m)
|
||||
)
|
||||
segments = segs_result.scalars().all()
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
acts_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(20)
|
||||
)
|
||||
activities = acts_result.scalars().all()
|
||||
if not activities:
|
||||
return [
|
||||
SegmentBestOut(
|
||||
segment_id=s.id, name=s.name,
|
||||
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
|
||||
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
|
||||
)
|
||||
for s in segments
|
||||
]
|
||||
|
||||
act_ids = [a.id for a in activities]
|
||||
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id.in_(act_ids))
|
||||
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
|
||||
)
|
||||
all_dps = dp_result.scalars().all()
|
||||
|
||||
# Group data points by activity_id
|
||||
dp_by_act = defaultdict(list)
|
||||
for dp in all_dps:
|
||||
if dp.distance_m is not None:
|
||||
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
|
||||
|
||||
bests = []
|
||||
for seg in segments:
|
||||
best_s = None
|
||||
best_act_id = None
|
||||
count = 0
|
||||
for act_id in act_ids:
|
||||
dp_list = dp_by_act.get(act_id, [])
|
||||
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
||||
if duration is not None:
|
||||
count += 1
|
||||
if best_s is None or duration < best_s:
|
||||
best_s = duration
|
||||
best_act_id = act_id
|
||||
bests.append(SegmentBestOut(
|
||||
segment_id=seg.id, name=seg.name,
|
||||
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
|
||||
auto_generated=bool(seg.auto_generated),
|
||||
best_s=best_s, best_activity_id=best_act_id, count=count,
|
||||
))
|
||||
return bests
|
||||
|
||||
|
||||
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
|
||||
async def get_segment_times(
|
||||
route_id: int,
|
||||
segment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the last 10 times this segment was traversed across matched activities."""
|
||||
from app.services.route_matcher import find_segment_times
|
||||
from app.models.user import ActivityDataPoint
|
||||
|
||||
await _get_owned_route(route_id, current_user.id, db)
|
||||
|
||||
seg_result = await db.execute(
|
||||
select(RouteSegment).where(
|
||||
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
|
||||
)
|
||||
)
|
||||
seg = seg_result.scalar_one_or_none()
|
||||
if not seg:
|
||||
raise HTTPException(status_code=404, detail="Segment not found")
|
||||
|
||||
acts_result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
|
||||
.order_by(desc(Activity.start_time))
|
||||
.limit(10)
|
||||
)
|
||||
activities = acts_result.scalars().all()
|
||||
|
||||
times = []
|
||||
for act in activities:
|
||||
dp_result = await db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == act.id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
)
|
||||
dps = dp_result.scalars().all()
|
||||
dp_list = [
|
||||
{"distance_m": p.distance_m, "timestamp": p.timestamp}
|
||||
for p in dps
|
||||
if p.distance_m is not None
|
||||
]
|
||||
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
|
||||
if duration:
|
||||
times.append(SegmentTimeEntry(
|
||||
activity_id=act.id,
|
||||
date=act.start_time,
|
||||
name=act.name,
|
||||
duration_s=duration,
|
||||
))
|
||||
return times
|
||||
|
||||
@@ -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.models.user import (
|
||||
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
|
||||
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -122,12 +122,13 @@ async def delete_user(
|
||||
# Ordered deletes: PersonalRecord and the activity/route child tables have no
|
||||
# cascade path from User, so remove them before the parents to avoid FK errors.
|
||||
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
|
||||
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
|
||||
segment_ids = select(Segment.id).where(Segment.user_id == user_id)
|
||||
|
||||
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
|
||||
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
|
||||
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
|
||||
await db.execute(delete(SegmentEffort).where(SegmentEffort.segment_id.in_(segment_ids)))
|
||||
await db.execute(delete(Segment).where(Segment.user_id == user_id))
|
||||
await db.execute(delete(Activity).where(Activity.user_id == user_id))
|
||||
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
|
||||
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
|
||||
|
||||
+5
-7
@@ -6,7 +6,7 @@ import asyncio
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
|
||||
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -82,17 +82,14 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"users.pocketid_allowed_group column migration skipped: {e}")
|
||||
|
||||
# route_segments auto_generated column added after initial creation
|
||||
# goal_weight_kg column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"route_segments column migration skipped: {e}")
|
||||
print(f"users.goal_weight_kg column migration skipped: {e}")
|
||||
|
||||
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
|
||||
try:
|
||||
@@ -225,6 +222,7 @@ app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
+34
-10
@@ -28,6 +28,7 @@ class User(Base):
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
height_cm = Column(Float, nullable=True)
|
||||
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
|
||||
goal_weight_kg = Column(Float, nullable=True)
|
||||
|
||||
# PocketID config (stored per-user so admin can set via UI)
|
||||
pocketid_issuer = Column(String(512), nullable=True)
|
||||
@@ -172,22 +173,45 @@ class NamedRoute(Base):
|
||||
|
||||
user = relationship("User", back_populates="named_routes")
|
||||
activities = relationship("Activity", back_populates="named_route")
|
||||
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RouteSegment(Base):
|
||||
__tablename__ = "route_segments"
|
||||
class Segment(Base):
|
||||
"""A user-defined GPS segment (a stretch of road/trail) matched across activities."""
|
||||
__tablename__ = "segments"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
start_distance_m = Column(Float, nullable=False)
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
auto_generated = Column(Boolean, default=False)
|
||||
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
|
||||
sport_type = Column(String(64), nullable=True)
|
||||
polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
|
||||
start_lat = Column(Float, nullable=True)
|
||||
start_lng = Column(Float, nullable=True)
|
||||
end_lat = Column(Float, nullable=True)
|
||||
end_lng = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True) # {min_lat,max_lat,min_lon,max_lon}
|
||||
created_from_activity_id = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
route = relationship("NamedRoute", back_populates="segments")
|
||||
efforts = relationship("SegmentEffort", back_populates="segment", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SegmentEffort(Base):
|
||||
"""One activity's time over a segment."""
|
||||
__tablename__ = "segment_efforts"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
segment_id = Column(Integer, ForeignKey("segments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
duration_s = Column(Float, nullable=False)
|
||||
achieved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
rank = Column(Integer, nullable=True) # 1/2/3 for podium, else null
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("segment_id", "activity_id", name="uq_segment_effort"),
|
||||
)
|
||||
|
||||
segment = relationship("Segment", back_populates="efforts")
|
||||
|
||||
|
||||
class PersonalRecord(Base):
|
||||
|
||||
@@ -95,39 +95,56 @@ def routes_are_similar(
|
||||
return dist < dtw_threshold_m
|
||||
|
||||
|
||||
def find_segment_times(
|
||||
data_points: list[dict],
|
||||
start_dist_m: float,
|
||||
end_dist_m: float,
|
||||
def match_segment_in_activity(
|
||||
seg_coords: list[tuple],
|
||||
act_coords: list[tuple],
|
||||
act_times: list,
|
||||
tol_m: float = 30.0,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Given activity data points (with cumulative distance_m),
|
||||
find the time to traverse from start_dist_m to end_dist_m.
|
||||
Returns duration in seconds, or None if not found.
|
||||
Determine whether an activity track traverses a segment's GPS geometry, and if so
|
||||
how long it took. Works even when the activity's overall route differs — only the
|
||||
overlapping stretch matters.
|
||||
|
||||
seg_coords: [(lat, lon), ...] segment geometry (start → end).
|
||||
act_coords: [(lat, lon), ...] activity track, in time order.
|
||||
act_times: parallel list of datetimes for act_coords.
|
||||
|
||||
Strategy: anchor on the activity point nearest the segment start, then the nearest
|
||||
point (at/after it) to the segment end, then verify a few intermediate segment
|
||||
points are each passed within tolerance between those anchors. Returns the time
|
||||
between the start and end anchors, or None if the activity doesn't follow the segment.
|
||||
"""
|
||||
start_time = None
|
||||
end_time = None
|
||||
n = len(act_coords)
|
||||
if n < 2 or len(seg_coords) < 2:
|
||||
return None
|
||||
|
||||
for p in data_points:
|
||||
dist = p.get("distance_m")
|
||||
ts = p.get("timestamp")
|
||||
if dist is None or ts is None:
|
||||
continue
|
||||
start_pt, end_pt = seg_coords[0], seg_coords[-1]
|
||||
|
||||
if start_time is None and dist >= start_dist_m:
|
||||
start_time = ts
|
||||
si, sd = None, tol_m
|
||||
for i in range(n):
|
||||
d = haversine_m(act_coords[i], start_pt)
|
||||
if d < sd:
|
||||
sd, si = d, i
|
||||
if si is None:
|
||||
return None
|
||||
|
||||
if start_time is not None and dist >= end_dist_m:
|
||||
end_time = ts
|
||||
break
|
||||
ei, ed = None, tol_m
|
||||
for i in range(si + 1, n):
|
||||
d = haversine_m(act_coords[i], end_pt)
|
||||
if d < ed:
|
||||
ed, ei = d, i
|
||||
if ei is None or ei <= si:
|
||||
return None
|
||||
|
||||
if start_time and end_time:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
||||
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
||||
return (t2 - t1).total_seconds()
|
||||
# Verify the activity actually follows the segment shape between the anchors.
|
||||
for frac in (0.25, 0.5, 0.75):
|
||||
sp = seg_coords[int(frac * (len(seg_coords) - 1))]
|
||||
if not any(haversine_m(act_coords[i], sp) <= tol_m for i in range(si, ei + 1)):
|
||||
return None
|
||||
|
||||
return None
|
||||
dur = (act_times[ei] - act_times[si]).total_seconds()
|
||||
return dur if dur > 0 else None
|
||||
|
||||
|
||||
def find_best_split_time(
|
||||
@@ -174,154 +191,6 @@ def find_best_split_time(
|
||||
return best
|
||||
|
||||
|
||||
def _bearing(p1: tuple, p2: tuple) -> float:
|
||||
"""Compass bearing in degrees (0-360) from p1 to p2."""
|
||||
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||
dlon = lon2 - lon1
|
||||
x = math.sin(dlon) * math.cos(lat2)
|
||||
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||
return math.degrees(math.atan2(x, y)) % 360
|
||||
|
||||
|
||||
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
|
||||
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
|
||||
if not encoded_polyline:
|
||||
return []
|
||||
km_count = int(total_dist_m / 1000)
|
||||
segments = []
|
||||
for i in range(km_count):
|
||||
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
|
||||
remainder = total_dist_m - km_count * 1000
|
||||
if remainder >= 200:
|
||||
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
|
||||
return segments
|
||||
|
||||
|
||||
def generate_turn_segments(
|
||||
encoded_polyline: str,
|
||||
turn_angle_deg: float = 45.0,
|
||||
) -> list[tuple[str, float, float]]:
|
||||
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
|
||||
coords = decode_polyline_to_coords(encoded_polyline)
|
||||
if len(coords) < 3:
|
||||
return []
|
||||
|
||||
cum_dists = [0.0]
|
||||
for i in range(1, len(coords)):
|
||||
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
|
||||
total = cum_dists[-1]
|
||||
|
||||
HALF_WINDOW = 100.0 # metres either side of candidate turn point
|
||||
|
||||
turn_centers: list[float] = []
|
||||
for i in range(1, len(coords) - 1):
|
||||
# Find index ~HALF_WINDOW before and after
|
||||
start_i = i
|
||||
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
|
||||
start_i -= 1
|
||||
end_i = i
|
||||
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
|
||||
end_i += 1
|
||||
if start_i == i or end_i == i:
|
||||
continue
|
||||
|
||||
b1 = _bearing(coords[start_i], coords[i])
|
||||
b2 = _bearing(coords[i], coords[end_i])
|
||||
diff = abs(b2 - b1) % 360
|
||||
if diff > 180:
|
||||
diff = 360 - diff
|
||||
if diff >= turn_angle_deg:
|
||||
turn_centers.append(cum_dists[i])
|
||||
|
||||
if not turn_centers:
|
||||
return []
|
||||
|
||||
# Cluster turns within 150 m of each other → one segment per cluster
|
||||
clusters: list[list[float]] = [[turn_centers[0]]]
|
||||
for d in turn_centers[1:]:
|
||||
if d - clusters[-1][-1] < 150:
|
||||
clusters[-1].append(d)
|
||||
else:
|
||||
clusters.append([d])
|
||||
|
||||
segments = []
|
||||
for cluster in clusters:
|
||||
center = sum(cluster) / len(cluster)
|
||||
start = max(0.0, center - HALF_WINDOW)
|
||||
end = min(total, center + HALF_WINDOW)
|
||||
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
|
||||
return segments
|
||||
|
||||
|
||||
def generate_hill_segments(
|
||||
data_points: list[dict],
|
||||
gradient_pct: float = 5.0,
|
||||
) -> list[tuple[str, float, float]]:
|
||||
"""
|
||||
Detect uphill sections using activity data points (with altitude_m + distance_m).
|
||||
Returns list of (name, start_m, end_m).
|
||||
"""
|
||||
pts = [
|
||||
(p["distance_m"], p["altitude_m"])
|
||||
for p in data_points
|
||||
if p.get("distance_m") is not None and p.get("altitude_m") is not None
|
||||
]
|
||||
if len(pts) < 10:
|
||||
return []
|
||||
pts.sort(key=lambda x: x[0])
|
||||
dists = [p[0] for p in pts]
|
||||
alts = [p[1] for p in pts]
|
||||
|
||||
# Smooth altitude with a sliding window to reduce GPS noise
|
||||
SMOOTH = 10
|
||||
smooth_alts = []
|
||||
for i in range(len(alts)):
|
||||
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
|
||||
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
|
||||
|
||||
grad_threshold = gradient_pct / 100.0
|
||||
MIN_HILL_M = 200.0
|
||||
|
||||
in_hill = False
|
||||
hill_start_idx = 0
|
||||
segments = []
|
||||
|
||||
for i in range(1, len(dists)):
|
||||
d_dist = dists[i] - dists[i - 1]
|
||||
if d_dist <= 0:
|
||||
continue
|
||||
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
|
||||
|
||||
if grad >= grad_threshold and not in_hill:
|
||||
in_hill = True
|
||||
hill_start_idx = i - 1
|
||||
elif grad < grad_threshold and in_hill:
|
||||
length = dists[i - 1] - dists[hill_start_idx]
|
||||
if length >= MIN_HILL_M:
|
||||
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
|
||||
start_km = dists[hill_start_idx] / 1000
|
||||
segments.append((
|
||||
f"Hill at {start_km:.1f} km (+{gain} m)",
|
||||
dists[hill_start_idx],
|
||||
dists[i - 1],
|
||||
))
|
||||
in_hill = False
|
||||
|
||||
if in_hill:
|
||||
length = dists[-1] - dists[hill_start_idx]
|
||||
if length >= MIN_HILL_M:
|
||||
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
|
||||
start_km = dists[hill_start_idx] / 1000
|
||||
segments.append((
|
||||
f"Hill at {start_km:.1f} km (+{gain} m)",
|
||||
dists[hill_start_idx],
|
||||
dists[-1],
|
||||
))
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
STANDARD_DISTANCES = [
|
||||
(400, "400m"),
|
||||
(800, "800m"),
|
||||
|
||||
@@ -199,6 +199,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||
detect_route.delay(activity_id, user_id)
|
||||
match_activity_segments.delay(activity_id, user_id)
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
|
||||
|
||||
@@ -412,6 +413,145 @@ def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||
db.commit()
|
||||
|
||||
|
||||
def _recompute_segment_ranks(db, segment_id: int):
|
||||
"""Assign rank 1/2/3 to the three fastest efforts on a segment, null to the rest."""
|
||||
from app.models.user import SegmentEffort
|
||||
from sqlalchemy import select
|
||||
|
||||
efforts = db.execute(
|
||||
select(SegmentEffort)
|
||||
.where(SegmentEffort.segment_id == segment_id)
|
||||
.order_by(SegmentEffort.duration_s)
|
||||
).scalars().all()
|
||||
for i, e in enumerate(efforts):
|
||||
e.rank = (i + 1) if i < 3 else None
|
||||
|
||||
|
||||
def _activity_track(db, activity_id):
|
||||
"""Return (coords, times) for an activity's GPS track in time order."""
|
||||
from app.models.user import ActivityDataPoint
|
||||
from sqlalchemy import select
|
||||
|
||||
dps = db.execute(
|
||||
select(ActivityDataPoint)
|
||||
.where(ActivityDataPoint.activity_id == activity_id)
|
||||
.order_by(ActivityDataPoint.timestamp)
|
||||
).scalars().all()
|
||||
coords, times = [], []
|
||||
for p in dps:
|
||||
if p.latitude is not None and p.longitude is not None and p.timestamp is not None:
|
||||
coords.append((p.latitude, p.longitude))
|
||||
times.append(p.timestamp)
|
||||
return coords, times
|
||||
|
||||
|
||||
def _upsert_effort(db, segment_id, activity, duration_s):
|
||||
from app.models.user import SegmentEffort
|
||||
from sqlalchemy import select
|
||||
|
||||
existing = db.execute(
|
||||
select(SegmentEffort).where(
|
||||
SegmentEffort.segment_id == segment_id,
|
||||
SegmentEffort.activity_id == activity.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
existing.duration_s = duration_s
|
||||
existing.achieved_at = activity.start_time
|
||||
else:
|
||||
db.add(SegmentEffort(
|
||||
segment_id=segment_id,
|
||||
activity_id=activity.id,
|
||||
duration_s=duration_s,
|
||||
achieved_at=activity.start_time,
|
||||
))
|
||||
|
||||
|
||||
@celery_app.task(name="match_segment")
|
||||
def match_segment(segment_id: int):
|
||||
"""Match one segment against every eligible activity and (re)build its leaderboard."""
|
||||
from app.services.route_matcher import (
|
||||
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
|
||||
)
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Segment, Activity
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
seg = db.execute(select(Segment).where(Segment.id == segment_id)).scalar_one_or_none()
|
||||
if not seg or not seg.polyline:
|
||||
return {"status": "no_segment"}
|
||||
seg_coords = decode_polyline_to_coords(seg.polyline)
|
||||
|
||||
acts = db.execute(
|
||||
select(Activity).where(
|
||||
Activity.user_id == seg.user_id,
|
||||
Activity.sport_type == seg.sport_type,
|
||||
Activity.polyline != None,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
matched = 0
|
||||
for act in acts:
|
||||
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
|
||||
continue
|
||||
coords, times = _activity_track(db, act.id)
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
dur = match_segment_in_activity(seg_coords, coords, times)
|
||||
if dur is None:
|
||||
continue
|
||||
_upsert_effort(db, seg.id, act, dur)
|
||||
matched += 1
|
||||
db.commit()
|
||||
_recompute_segment_ranks(db, seg.id)
|
||||
db.commit()
|
||||
return {"status": "ok", "matched": matched}
|
||||
|
||||
|
||||
@celery_app.task(name="match_activity_segments")
|
||||
def match_activity_segments(activity_id: int, user_id: int):
|
||||
"""Match a newly-ingested activity against all of the user's existing segments."""
|
||||
from app.services.route_matcher import (
|
||||
match_segment_in_activity, bounding_boxes_overlap, decode_polyline_to_coords,
|
||||
)
|
||||
from app.core.database import SyncSessionLocal
|
||||
from app.models.user import Segment, Activity
|
||||
from sqlalchemy import select
|
||||
|
||||
with SyncSessionLocal() as db:
|
||||
act = db.execute(select(Activity).where(Activity.id == activity_id)).scalar_one_or_none()
|
||||
if not act or not act.polyline:
|
||||
return {"status": "no_polyline"}
|
||||
coords, times = _activity_track(db, act.id)
|
||||
if len(coords) < 2:
|
||||
return {"status": "no_track"}
|
||||
|
||||
segs = db.execute(
|
||||
select(Segment).where(
|
||||
Segment.user_id == user_id,
|
||||
Segment.sport_type == act.sport_type,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
touched = []
|
||||
for seg in segs:
|
||||
if not seg.polyline:
|
||||
continue
|
||||
if seg.bounding_box and act.bounding_box and not bounding_boxes_overlap(seg.bounding_box, act.bounding_box):
|
||||
continue
|
||||
dur = match_segment_in_activity(decode_polyline_to_coords(seg.polyline), coords, times)
|
||||
if dur is None:
|
||||
continue
|
||||
_upsert_effort(db, seg.id, act, dur)
|
||||
touched.append(seg.id)
|
||||
db.commit()
|
||||
for sid in touched:
|
||||
_recompute_segment_ranks(db, sid)
|
||||
db.commit()
|
||||
return {"status": "ok", "matched_segments": len(touched)}
|
||||
|
||||
|
||||
@celery_app.task(name="process_garmin_health_zip")
|
||||
def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"""Extract wellness data from a Garmin Connect export ZIP."""
|
||||
|
||||
Reference in New Issue
Block a user