Add segments, YTD stats, route matching fixes, body battery layout, pace fix
- Segments page: new /segments route with auto-generate (1km splits, turn detection, hill detection), manual segment creation, per-segment performance times across matched activities; fixed auth on existing segment endpoints - YTD distance: new /activities/stats/ytd endpoint; Dashboard replaces 'Total distance' with 'Running this year' + 'Cycling this year'; Activities page shows YTD stats row - Weekly chart click: clicking a Dashboard bar navigates to Activities filtered to that week; Activities reads from/to query params with dismissable chip - Route matching: add ±2.5% distance gate + 3% relative DTW threshold (was flat 80m); tighten candidate pre-filter from 80/120% to 95/105% - Body battery layout: HR chart and body battery now side-by-side at same height on large screens instead of stacked full-width - Pace display fix: MetricTimeline clamps GPS speed outliers before computing Y-axis domain; tick formatter guards against v<=0 or v>25 m/s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/stats/ytd")
|
||||
async def ytd_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return year-to-date distance totals grouped by sport type."""
|
||||
from datetime import date, timezone
|
||||
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
|
||||
result = await db.execute(
|
||||
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
|
||||
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
|
||||
.group_by(Activity.sport_type)
|
||||
)
|
||||
rows = result.all()
|
||||
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
|
||||
return {
|
||||
"running_km": round(totals.get("running", 0), 2),
|
||||
"cycling_km": round(totals.get("cycling", 0), 2),
|
||||
"hiking_km": round(totals.get("hiking", 0), 2),
|
||||
"walking_km": round(totals.get("walking", 0), 2),
|
||||
"total_km": round(sum(totals.values()), 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ActivitySummary])
|
||||
async def list_activities(
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
@@ -47,11 +47,25 @@ class SegmentOut(BaseModel):
|
||||
start_distance_m: float
|
||||
end_distance_m: float
|
||||
description: Optional[str]
|
||||
auto_generated: Optional[bool] = False
|
||||
|
||||
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),
|
||||
@@ -253,12 +267,23 @@ async def assign_activity_to_route(
|
||||
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)
|
||||
@@ -274,14 +299,169 @@ async def create_segment(
|
||||
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 existing auto-generated segments of this type
|
||||
await db.execute(
|
||||
sql_delete(RouteSegment).where(
|
||||
RouteSegment.route_id == route_id,
|
||||
RouteSegment.auto_generated == True,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
db.add(seg)
|
||||
new_segments.append(seg)
|
||||
|
||||
await db.commit()
|
||||
for seg in new_segments:
|
||||
await db.refresh(seg)
|
||||
return new_segments
|
||||
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user