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
+34 -10
View File
@@ -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):