Segments and Av HR update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user