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:
@@ -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