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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:59:06 +01:00
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
+140
View File
@@ -199,6 +199,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
compute_personal_records.delay(activity_id, user_id, parsed)
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."""