All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+31 -43
View File
@@ -7,13 +7,23 @@ from typing import Optional
import httpx
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
from app.core.security import verify_password, create_access_token, get_current_user
from app.core.config import settings
from app.models.user import User
router = APIRouter()
async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
admin = result.scalar_one_or_none()
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
return issuer, client_id, client_secret
class Token(BaseModel):
access_token: str
token_type: str
@@ -37,24 +47,15 @@ async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(User.username == form_data.username)
)
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)})
return Token(
access_token=token,
token_type="bearer",
user_id=user.id,
username=user.username,
is_admin=user.is_admin,
)
return Token(access_token=token, token_type="bearer",
user_id=user.id, username=user.username, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
@@ -63,51 +64,44 @@ async def get_me(current_user: User = Depends(get_current_user)):
@router.get("/pocketid/available")
async def pocketid_available():
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
async def pocketid_available(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url")
async def pocketid_login_url():
"""Return the OIDC authorization URL for PocketID."""
if not settings.pocketid_issuer:
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": settings.pocketid_client_id,
"client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
}
from urllib.parse import urlencode
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
return {"url": url}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
"""Exchange OIDC code for tokens and create/login user."""
if not settings.pocketid_issuer:
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
# Exchange code for tokens
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.pocketid_issuer}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": settings.pocketid_client_id,
"client_secret": settings.pocketid_client_secret,
},
f"{issuer}/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": client_secret},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json()
userinfo_resp = await client.get(
f"{settings.pocketid_issuer}/userinfo",
f"{issuer}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
@@ -118,17 +112,11 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
if not user:
user = User(
username=preferred_username,
email=email,
pocketid_sub=sub,
)
user = User(username=preferred_username, email=email, pocketid_sub=sub)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
# Redirect to frontend with token
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
+220
View File
@@ -0,0 +1,220 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
class Config:
from_attributes = True
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
await db.commit()
await db.refresh(current_user)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None:
current_user.pocketid_client_secret = body.client_secret or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = 365,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
+36 -8
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
@@ -23,7 +23,7 @@ class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int # use this activity as the reference route
activity_id: int
class RouteOut(BaseModel):
@@ -34,6 +34,7 @@ class RouteOut(BaseModel):
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
@@ -64,13 +65,44 @@ async def list_routes(
return result.scalars().all()
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Load the reference activity
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
@@ -89,11 +121,10 @@ async def create_route(
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
# Link this activity to the route
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
@@ -124,7 +155,6 @@ async def route_activities(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""All activities on this named route, ordered fastest first."""
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
@@ -153,7 +183,6 @@ async def assign_activity_to_route(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually assign an activity to a named route."""
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
@@ -164,7 +193,6 @@ async def assign_activity_to_route(
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
+1 -1
View File
@@ -36,4 +36,4 @@ class Settings(BaseSettings):
case_sensitive = False
settings = Settings()
settings = Settings()
+1 -1
View File
@@ -44,4 +44,4 @@ async def get_db():
await session.rollback()
raise
finally:
await session.close()
await session.close()
+3 -2
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload
from app.api import auth, activities, routes, health, records, upload, profile
async def init_db():
@@ -97,8 +97,9 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@app.get("/health")
async def healthcheck():
return {"status": "ok"}
return {"status": "ok"}
+32 -6
View File
@@ -22,9 +22,39 @@ class User(Base):
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
# Health profile
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
pocketid_client_id = Column(String(256), nullable=True)
pocketid_client_secret = Column(String(256), nullable=True)
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
class WeightLog(Base):
"""Manual weight entries separate from health_metrics for easy tracking."""
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
weight_kg = Column(Float, nullable=False)
body_fat_pct = Column(Float, nullable=True)
note = Column(String(256), nullable=True)
__table_args__ = (
Index("ix_weight_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="weight_logs")
class Activity(Base):
@@ -68,11 +98,6 @@ class Activity(Base):
class ActivityDataPoint(Base):
"""
TimescaleDB hypertable - one row per second of activity data.
Composite primary key (activity_id, timestamp) satisfies TimescaleDB's
requirement that the partition column be part of the primary key.
"""
__tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
@@ -118,6 +143,7 @@ class NamedRoute(Base):
reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="named_routes")
@@ -198,4 +224,4 @@ class HealthMetric(Base):
Index("ix_health_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="health_metrics")
user = relationship("User", back_populates="health_metrics")
+108 -142
View File
@@ -1,21 +1,24 @@
"""
Parses Garmin .fit files and GPX files into normalized activity data.
Handles full Strava and Garmin data export archives.
FIT and GPX file parser using:
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
- gpxpy for .gpx files
The official SDK correctly handles scale/offset, component expansion,
semicircle-to-degree conversion, and HR message merging.
"""
import os
import zipfile
import json
import math
from pathlib import Path
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional
import fitparse
import gpxpy
import polyline as polyline_lib
FIT_EPOCH_S = 631065600
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Returns distance in metres between two GPS points."""
"""Distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
@@ -24,106 +27,100 @@ def haversine_distance(lat1, lon1, lat2, lon2) -> float:
return 2 * R * math.asin(math.sqrt(a))
def semicircles_to_degrees(sc: int) -> float:
return sc * (180 / 2**31)
def _safe_float(val) -> Optional[float]:
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def _bounding_box(coords: list) -> Optional[dict]:
if not coords:
return None
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons)}
def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit file and return normalized activity dict."""
fit = fitparse.FitFile(filepath)
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
data_points = []
laps = []
session = {}
records = []
laps = []
for record in fit.get_messages():
name = record.name
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
if name == "session":
for f in record:
session[f.name] = f.value
stream = Stream.from_file(filepath)
decoder = Decoder(stream)
decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
elif name == "lap":
lap = {}
for f in record:
lap[f.name] = f.value
laps.append(lap)
elif name == "record":
point = {}
for f in record:
point[f.name] = f.value
if point:
# Convert semicircles to degrees
if "position_lat" in point and point["position_lat"] is not None:
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
if "position_long" in point and point["position_long"] is not None:
point["position_long"] = semicircles_to_degrees(point["position_long"])
data_points.append(point)
# Build normalized output
# Map sport type
sport = str(session.get("sport", "generic")).lower()
sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running",
"e_biking": "cycling",
}
sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time")
if start_time and start_time.tzinfo is None:
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
# Build GPS track for polyline
# Build GPS track
coords = [
(p["position_lat"], p["position_long"])
for p in data_points
if p.get("position_lat") is not None and p.get("position_long") is not None
(r["position_lat"], r["position_long"])
for r in records
if r.get("position_lat") is not None and r.get("position_long") is not None
]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Calculate cumulative distance if not in FIT
cumulative_dist = 0.0
prev_lat, prev_lon = None, None
# Normalize data points
normalized_points = []
for p in data_points:
ts = p.get("timestamp")
if ts and ts.tzinfo is None:
for r in records:
ts = r.get("timestamp")
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
lat = p.get("position_lat")
lon = p.get("position_long")
dist = p.get("distance")
if dist is None and lat and lon and prev_lat and prev_lon:
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
dist = cumulative_dist
elif dist is not None:
cumulative_dist = float(dist)
if lat and lon:
prev_lat, prev_lon = lat, lon
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": lat,
"longitude": lon,
"altitude_m": p.get("altitude"),
"heart_rate": p.get("heart_rate"),
"cadence": p.get("cadence"),
"speed_ms": p.get("speed"),
"power": p.get("power"),
"temperature_c": p.get("temperature"),
"distance_m": dist,
"latitude": r.get("position_lat"),
"longitude": r.get("position_long"),
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
"heart_rate": r.get("heart_rate"),
"cadence": r.get("cadence") or r.get("fractional_cadence"),
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
"power": r.get("power"),
"temperature_c": r.get("temperature"),
"distance_m": r.get("distance"),
})
# Parse laps
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if ls and ls.tzinfo is None:
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({
"lap_number": i + 1,
@@ -132,13 +129,17 @@ def parse_fit_file(filepath: str) -> dict:
"distance_m": _safe_float(lap.get("total_distance")),
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
"avg_cadence": _safe_float(lap.get("avg_cadence")),
"avg_speed_ms": _safe_float(lap.get("avg_speed")),
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
"avg_power": _safe_float(lap.get("avg_power")),
})
# Build activity name
name = session.get("sport", "Activity").title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
return {
"name": session.get("sport", "Activity").title() + " " + (
start_time.strftime("%Y-%m-%d") if start_time else ""),
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")),
@@ -150,12 +151,12 @@ def parse_fit_file(filepath: str) -> dict:
"avg_cadence": _safe_float(session.get("avg_cadence")),
"avg_power": _safe_float(session.get("avg_power")),
"normalized_power": _safe_float(session.get("normalized_power")),
"avg_speed_ms": _safe_float(session.get("avg_speed")),
"max_speed_ms": _safe_float(session.get("max_speed")),
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
"calories": _safe_float(session.get("total_calories")),
"training_stress_score": _safe_float(session.get("training_stress_score")),
"vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
@@ -165,13 +166,12 @@ def parse_fit_file(filepath: str) -> dict:
def parse_gpx_file(filepath: str) -> dict:
"""Parse a GPX file into normalized activity dict."""
"""Parse a GPX file."""
with open(filepath) as f:
gpx = gpxpy.parse(f)
data_points = []
track = gpx.tracks[0] if gpx.tracks else None
if not track:
raise ValueError("No tracks found in GPX file")
@@ -204,7 +204,6 @@ def parse_gpx_file(filepath: str) -> dict:
"distance_m": None,
})
# Calculate distance and elevation
coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
@@ -220,6 +219,7 @@ def parse_gpx_file(filepath: str) -> dict:
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
@@ -235,7 +235,7 @@ def parse_gpx_file(filepath: str) -> dict:
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = "running" # GPX doesn't always include sport; default to running
sport = "running"
if track.type:
sport = track.type.lower()
@@ -266,76 +266,42 @@ def parse_gpx_file(filepath: str) -> dict:
}
def parse_strava_export(export_dir: str) -> list[dict]:
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
"""
Parse a full Strava data export directory.
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
Calculate % time in each HR zone using the user's configured max HR.
Zones follow the standard 5-zone model as % of max HR:
Z1 Recovery: < 60%
Z2 Base: 60 - 70%
Z3 Tempo: 70 - 80%
Z4 Threshold: 80 - 90%
Z5 Max: > 90%
user_max_hr should be the user's actual physiological max HR, NOT the
highest HR recorded in this activity. Using activity max shifts all zones
upward and makes easy runs look harder than they are.
"""
activities = []
activities_dir = Path(export_dir) / "activities"
if not activities_dir.exists():
return activities
for fname in sorted(activities_dir.iterdir()):
if fname.suffix in (".fit", ".gpx"):
try:
if fname.suffix == ".fit":
act = parse_fit_file(str(fname))
else:
act = parse_gpx_file(str(fname))
act["source_type"] = "strava_" + fname.suffix[1:]
activities.append(act)
except Exception as e:
print(f"Error parsing {fname}: {e}")
return activities
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
"""Calculate percentage of time spent in each HR zone."""
if not max_hr:
if not user_max_hr or user_max_hr < 100:
return {}
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr:
if not hr or hr < 20:
continue
pct = hr / max_hr
pct = hr / user_max_hr
total += 1
if pct < zone_bounds[1]:
zones["z1"] += 1
elif pct < zone_bounds[2]:
zones["z2"] += 1
elif pct < zone_bounds[3]:
zones["z3"] += 1
elif pct < zone_bounds[4]:
zones["z4"] += 1
for i, key in enumerate(zone_keys):
if zone_bounds[i] <= pct < zone_bounds[i+1]:
zones[key] += 1
break
else:
zones["z5"] += 1
zones["z5"] += 1 # anything above 90% goes to z5
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
def _safe_float(val) -> Optional[float]:
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def _bounding_box(coords: list[tuple]) -> Optional[dict]:
if not coords:
return None
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {
"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons),
}
+1 -1
View File
@@ -306,4 +306,4 @@ def parse_wellness_fit(file_path: str) -> dict:
"sleep_awake_s": sleep_awake_s,
}
return {"days": result, "error": None}
return {"days": result, "error": None}
+1 -1
View File
@@ -4,4 +4,4 @@ can be started with: celery -A app.workers.celery_app worker
"""
from app.workers.tasks import celery_app
__all__ = ["celery_app"]
__all__ = ["celery_app"]
+147 -134
View File
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
if existing:
return {"activity_id": existing.id, "status": "duplicate"}
# Get user's configured max HR for accurate zone calculation
# Falls back to: user-set value → 220-age → activity max → 190
from app.models.user import User as UserModel
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
user_max_hr = None
if user_obj:
user_max_hr = user_obj.max_heart_rate
if not user_max_hr and user_obj.birth_year:
from datetime import date as _date
age = _date.today().year - user_obj.birth_year
user_max_hr = 220 - age
if not user_max_hr:
# Last resort: use activity max but warn this may shift zones
user_max_hr = parsed.get("max_heart_rate") or 190
hr_zones = calculate_hr_zones(
parsed.get("data_points", []),
parsed.get("max_heart_rate") or 190
user_max_hr
)
start_time = datetime.fromisoformat(parsed["start_time"])
@@ -169,6 +184,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed)
# Auto route detection for running and cycling
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@@ -176,161 +194,156 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
def parse_wellness_fit(file_path: str, user_id: int):
"""
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
These files contain resting HR, HRV, sleep, stress, SpO2 etc.
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
"""
import fitparse
from app.services.wellness_parser import parse_wellness_fit as _parse
from app.core.database import SyncSessionLocal
from app.models.user import HealthMetric
from datetime import datetime, timezone
from sqlalchemy import text
from datetime import datetime, timezone, date
try:
fit = fitparse.FitFile(file_path)
except Exception as e:
return {"status": "error", "error": str(e)}
result = _parse(file_path)
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
# Collect all monitoring/daily summary records keyed by date
daily = {} # date -> dict of fields
def get_or_create_day(d: date) -> dict:
if d not in daily:
daily[d] = {}
return daily[d]
for record in fit.get_messages():
name = record.name
fields = {f.name: f.value for f in record if f.value is not None}
if name == "monitoring_info":
ts = fields.get("timestamp") or fields.get("local_timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
day.setdefault("resting_hr", fields.get("resting_heart_rate"))
elif name == "monitoring":
ts = fields.get("timestamp") or fields.get("local_timestamp")
if not ts:
continue
d = ts.date() if hasattr(ts, "date") else None
if not d:
continue
day = get_or_create_day(d)
# Accumulate steps (they're stored as increments)
if "steps" in fields:
day["steps"] = day.get("steps", 0) + int(fields["steps"])
if "heart_rate" in fields:
hrs = day.setdefault("heart_rates", [])
hrs.append(int(fields["heart_rate"]))
if "stress_level_value" in fields:
stresses = day.setdefault("stress_values", [])
stresses.append(int(fields["stress_level_value"]))
elif name == "hrv_status_summary":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
day.setdefault("hrv_nightly_avg", fields.get("weekly_average"))
day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high"))
day.setdefault("hrv_status", str(fields.get("hrv_status", "")))
elif name == "sleep_level":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
levels = day.setdefault("sleep_levels", [])
levels.append(fields.get("sleep_level"))
elif name == "stress":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
if "stress_level_value" in fields:
stresses = day.setdefault("stress_values", [])
stresses.append(int(fields["stress_level_value"]))
elif name == "spo2_data":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
readings = day.setdefault("spo2_readings", [])
if "spo2_percent" in fields:
readings.append(fields["spo2_percent"])
if not daily:
days = result.get("days", {})
if not days:
return {"status": "no_data", "file": file_path}
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
with SyncSessionLocal() as db:
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_levels = data.pop("sleep_levels", [])
resting_hr = data.get("resting_hr")
avg_hr = (sum(hrs) / len(hrs)) if hrs else None
avg_stress = (sum(stresses) / len(stresses)) if stresses else None
spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None
# Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None
sleep_duration_s = (
(sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0)
) or None
for day_date, data in days.items():
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
# ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data
db.execute(text("""
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress,
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps,
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
steps, floors_climbed, active_calories, total_calories,
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress,
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps,
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
:steps, :floors, :active_cal, :total_cal,
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": resting_hr, "avg_hr": avg_hr,
"avg_stress": avg_stress, "spo2_avg": spo2_avg,
"resting_hr": data.get("resting_hr"),
"avg_hr": data.get("avg_hr_day"),
"max_hr": data.get("max_hr_day"),
"avg_stress": data.get("avg_stress"),
"spo2_avg": data.get("spo2_avg"),
"hrv_avg": data.get("hrv_nightly_avg"),
"hrv_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s,
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s,
"sleep_awake": sleep_awake_s,
"floors": data.get("floors_climbed"),
"active_cal": data.get("active_calories"),
"total_cal": data.get("total_calories"),
"sleep_dur": data.get("sleep_duration_s"),
"sleep_deep": data.get("sleep_deep_s"),
"sleep_light": data.get("sleep_light_s"),
"sleep_rem": data.get("sleep_rem_s"),
"sleep_awake": data.get("sleep_awake_s"),
})
db.commit()
return {"status": "ok", "days_processed": len(daily), "file": file_path}
return {"status": "ok", "days_processed": len(days), "file": file_path}
@celery_app.task(name="detect_route")
def detect_route(activity_id: int, user_id: int):
"""
After importing an activity, check if it matches any existing named routes.
If two+ unassigned activities match each other, auto-create a named route.
"""
from app.services.route_matcher import routes_are_similar
from app.core.database import SyncSessionLocal
from app.models.user import Activity, NamedRoute
from sqlalchemy import select
with SyncSessionLocal() as db:
# Get the new activity
new_act = db.execute(
select(Activity).where(Activity.id == activity_id)
).scalar_one_or_none()
if not new_act or not new_act.polyline:
return {"status": "no_polyline"}
# Already assigned to a route?
if new_act.named_route_id:
return {"status": "already_assigned"}
# Check against existing named routes first
routes = db.execute(
select(NamedRoute).where(
NamedRoute.user_id == user_id,
NamedRoute.sport_type == new_act.sport_type,
)
).scalars().all()
for route in routes:
if route.reference_polyline and routes_are_similar(
new_act.polyline, route.reference_polyline,
new_act.bounding_box, route.bounding_box,
):
new_act.named_route_id = route.id
db.commit()
return {"status": "matched_existing", "route_id": route.id}
# No existing route matched - check unassigned activities for a match
candidates = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == new_act.sport_type,
Activity.named_route_id == None,
Activity.id != activity_id,
Activity.polyline != None,
# Within 20% distance
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
)
).scalars().all()
for candidate in candidates:
if routes_are_similar(
new_act.polyline, candidate.polyline,
new_act.bounding_box, candidate.bounding_box,
):
# Auto-create a route from the older activity
older = candidate if candidate.start_time < new_act.start_time else new_act
newer = new_act if candidate.start_time < new_act.start_time else candidate
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
new_route = NamedRoute(
user_id=user_id,
name=route_name,
sport_type=older.sport_type,
reference_polyline=older.polyline,
bounding_box=older.bounding_box,
distance_m=older.distance_m,
auto_detected=True,
)
db.add(new_route)
db.flush()
older.named_route_id = new_route.id
newer.named_route_id = new_route.id
db.commit()
return {"status": "auto_created", "route_id": new_route.id}
return {"status": "no_match"}
@celery_app.task(name="compute_personal_records")
@@ -435,4 +448,4 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
"spo2": data.get("avgSpo2"),
})
db.commit()
db.commit()