Segments and Av HR update
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 22s

This commit is contained in:
2026-06-07 17:12:27 +01:00
parent 4a4cbdcc92
commit bf1920eb9d
8 changed files with 299 additions and 98 deletions
+22 -4
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
class Config:
from_attributes = True
@@ -48,6 +49,7 @@ class SegmentOut(BaseModel):
end_distance_m: float
description: Optional[str]
auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config:
from_attributes = True
@@ -71,12 +73,26 @@ async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute)
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
@@ -352,11 +368,12 @@ async def auto_generate_segments(
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
# 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,
)
)
@@ -403,6 +420,7 @@ async def auto_generate_segments(
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)
+26
View File
@@ -69,9 +69,35 @@ async def init_db():
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)"
))
except Exception as e:
print(f"route_segments column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
await conn.execute(text("""
UPDATE health_metrics SET
avg_hr_day = sub.avg_hr,
max_hr_day = sub.max_hr
FROM (
SELECT id,
AVG((elem->>1)::float) AS avg_hr,
MAX((elem->>1)::float) AS max_hr
FROM health_metrics,
json_array_elements(intraday_hr) AS elem
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
AND intraday_hr IS NOT NULL
AND (elem->>1)::float > 0
GROUP BY id
) sub
WHERE health_metrics.id = sub.id
"""))
except Exception as e:
print(f"avg_hr_day backfill skipped: {e}")
# Replace the all-columns unique constraint on personal_records with a partial
# index (only current records must be unique per user/sport/distance).
# The old constraint also covered is_current_record=False rows, causing
+1
View File
@@ -182,6 +182,7 @@ class RouteSegment(Base):
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'
route = relationship("NamedRoute", back_populates="segments")
+5 -1
View File
@@ -258,13 +258,17 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
if bb:
row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
intraday = None
if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday:
row["intraday_hr"] = intraday
hr_vals = [v for _, v in intraday if v > 0]
if hr_vals:
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
row["max_hr_day"] = float(max(hr_vals))
# High-resolution body battery derived from BB checkpoints + intraday HR
if bb and intraday: