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
@@ -0,0 +1,213 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
router = APIRouter()
class ActivitySummary(BaseModel):
id: int
name: str
sport_type: str
start_time: datetime
distance_m: Optional[float]
duration_s: Optional[float]
elevation_gain_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
calories: Optional[float]
polyline: Optional[str]
bounding_box: Optional[dict]
hr_zones: Optional[dict]
named_route_id: Optional[int]
class Config:
from_attributes = True
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
elevation_loss_m: Optional[float]
max_heart_rate: Optional[float]
avg_power: Optional[float]
normalized_power: Optional[float]
max_speed_ms: Optional[float]
avg_temperature_c: Optional[float]
training_stress_score: Optional[float]
vo2max_estimate: Optional[float]
class DataPointOut(BaseModel):
timestamp: Optional[datetime]
latitude: Optional[float]
longitude: Optional[float]
altitude_m: Optional[float]
heart_rate: Optional[float]
cadence: Optional[float]
speed_ms: Optional[float]
power: Optional[float]
temperature_c: Optional[float]
distance_m: Optional[float]
class Config:
from_attributes = True
class LapOut(BaseModel):
lap_number: int
start_time: Optional[datetime]
duration_s: Optional[float]
distance_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
avg_power: Optional[float]
class Config:
from_attributes = True
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
sport_type: Optional[str] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Activity).where(Activity.user_id == current_user.id)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
q = q.order_by(desc(Activity.start_time))
q = q.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(q)
return result.scalars().all()
@router.get("/{activity_id}", response_model=ActivityDetail)
async def get_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
async def get_data_points(
activity_id: int,
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify ownership
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
q = select(ActivityDataPoint).where(
ActivityDataPoint.activity_id == activity_id
).order_by(ActivityDataPoint.timestamp)
result = await db.execute(q)
points = result.scalars().all()
if downsample > 1:
points = points[::downsample]
return points
@router.get("/{activity_id}/laps", response_model=List[LapOut])
async def get_laps(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
result = await db.execute(
select(ActivityLap)
.where(ActivityLap.activity_id == activity_id)
.order_by(ActivityLap.lap_number)
)
return result.scalars().all()
@router.patch("/{activity_id}/name")
async def rename_activity(
activity_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.name = body.get("name", activity.name)
await db.commit()
return {"id": activity_id, "name": activity.name}
@router.delete("/{activity_id}", status_code=204)
async def delete_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(activity)
await db.commit()
+122
View File
@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
import httpx
from app.core.database import get_db
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
user_id: int
username: str
is_admin: bool
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
class Config:
from_attributes = True
@router.post("/token", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
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)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/pocketid/available")
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(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": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
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"{issuer}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
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)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
+156
View File
@@ -0,0 +1,156 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, HealthMetric
router = APIRouter()
class HealthMetricOut(BaseModel):
id: int
date: datetime
resting_hr: Optional[float]
max_hr_day: Optional[float]
avg_hr_day: Optional[float]
hrv_nightly_avg: Optional[float]
hrv_status: Optional[str]
hrv_5min_high: Optional[float]
hrv_5min_low: Optional[float]
sleep_duration_s: Optional[float]
sleep_deep_s: Optional[float]
sleep_light_s: Optional[float]
sleep_rem_s: Optional[float]
sleep_awake_s: Optional[float]
sleep_score: Optional[float]
sleep_start: Optional[datetime]
sleep_end: Optional[datetime]
weight_kg: Optional[float]
bmi: Optional[float]
body_fat_pct: Optional[float]
muscle_mass_kg: Optional[float]
vo2max: Optional[float]
fitness_age: Optional[int]
training_load: Optional[float]
recovery_time_h: Optional[float]
avg_stress: Optional[float]
steps: Optional[int]
floors_climbed: Optional[int]
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
class Config:
from_attributes = True
@router.get("/", response_model=List[HealthMetricOut])
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
q = q.where(HealthMetric.date >= from_date)
if to_date:
q = q.where(HealthMetric.date <= to_date)
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@router.get("/summary")
async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
.order_by(desc(HealthMetric.date))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
func.avg(HealthMetric.avg_stress).label("avg_stress"),
func.avg(HealthMetric.steps).label("avg_steps"),
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
)
)
avgs = avg_result.one()
return {
"latest": HealthMetricOut.model_validate(latest) if latest else None,
"avg_30d": {
"resting_hr": avgs.avg_resting_hr,
"hrv": avgs.avg_hrv,
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
"sleep_score": avgs.avg_sleep_score,
"stress": avgs.avg_stress,
"steps": avgs.avg_steps,
"weight_kg": avgs.avg_weight,
},
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually add or update a health metric for a given date."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
date_str = body.get("date")
if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date.date(),
)
)
metric = existing.scalar_one_or_none()
if metric:
for key, val in body.items():
if hasattr(metric, key) and key not in ("id", "user_id"):
setattr(metric, key, val)
else:
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
k: v for k, v in body.items()
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
})
db.add(metric)
await db.commit()
return {"status": "ok"}
+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()
@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException, Query
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 app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
router = APIRouter()
# ─── Personal Records ────────────────────────────────────────────────────────
class PROut(BaseModel):
id: int
sport_type: str
distance_m: float
distance_label: str
duration_s: float
achieved_at: datetime
activity_id: int
class Config:
from_attributes = True
@router.get("/", response_model=List[PROut])
async def list_records(
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.is_current_record == True,
)
if sport_type:
q = q.where(PersonalRecord.sport_type == sport_type)
q = q.order_by(PersonalRecord.distance_m)
result = await db.execute(q)
return result.scalars().all()
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
sport_type: str = "running",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Show progression of a PR over time."""
result = await db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.sport_type == sport_type,
PersonalRecord.distance_label == distance_label,
).order_by(PersonalRecord.achieved_at)
)
return result.scalars().all()
+232
View File
@@ -0,0 +1,232 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
class Config:
from_attributes = True
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
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),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
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"}
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
+134
View File
@@ -0,0 +1,134 @@
import os
import shutil
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import User
from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename
with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f)
return dest
@router.post("/activity")
async def upload_activity(
file: UploadFile = File(...),
background_tasks: BackgroundTasks = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a single .fit or .gpx activity file."""
suffix = Path(file.filename).suffix.lower()
if suffix not in {".fit", ".gpx"}:
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
dest = save_upload(file, dest_dir)
# Queue processing
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
return {"task_id": task.id, "status": "queued", "filename": file.filename}
@router.post("/garmin-export")
async def upload_garmin_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Upload a full Garmin Connect data export ZIP.
Processes all FIT files for activities + wellness data.
"""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
# Extract and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit"):
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
"health_task": health_task.id,
}
@router.post("/strava-export")
async def upload_strava_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit") or lower.endswith(".gpx"):
file_path = extract_dir / name
ext = Path(name).suffix[1:]
task = process_activity_file.delay(str(file_path), current_user.id, ext)
task_ids.append(task.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
}
@router.get("/task/{task_id}")
async def check_task_status(
task_id: str,
current_user: User = Depends(get_current_user),
):
"""Check the status of an upload processing task."""
from app.workers.celery_app import celery_app
result = celery_app.AsyncResult(task_id)
return {
"task_id": task_id,
"status": result.status,
"result": result.result if result.ready() else None,
}
@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = Field(..., env="DATABASE_URL")
# Redis
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
# Auth
secret_key: str = Field(..., env="SECRET_KEY")
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Admin account - optional so the worker (which doesn't seed users) can start
# without it. The backend service checks this at seed time.
admin_username: str = Field("admin", env="ADMIN_USERNAME")
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
# PocketID OIDC (optional)
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
# Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment
environment: str = Field("production", env="ENVIRONMENT")
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()
@@ -0,0 +1,47 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
# Async engine for FastAPI
engine = create_async_engine(
settings.database_url,
echo=settings.environment == "development",
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Sync engine for Celery workers (Celery + asyncio don't mix well)
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
sync_engine = create_engine(
sync_url,
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
+105
View File
@@ -0,0 +1,105 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import text
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, profile
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup.
Multiple uvicorn workers may race here on first start. We tolerate
duplicate table errors since they just mean another worker got there first.
"""
for attempt in range(15):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
print("Tables already created by another worker - skipping")
break
if attempt == 14:
raise
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
await asyncio.sleep(2)
# Try TimescaleDB hypertable (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"SELECT create_hypertable('activity_data_points', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
))
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
return
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
except Exception as e:
msg = str(e).lower()
if "duplicate" in msg or "unique" in msg:
print("Admin user already exists - skipping seed")
else:
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="MileVault",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.environment == "development" else [],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
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"}
+227
View File
@@ -0,0 +1,227 @@
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
def now_utc():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(256), unique=True, nullable=True)
hashed_password = Column(String(256), nullable=True)
is_admin = Column(Boolean, default=False)
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):
__tablename__ = "activities"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
max_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
normalized_power = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
max_speed_ms = Column(Float, nullable=True)
avg_temperature_c = Column(Float, nullable=True)
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
vo2max_estimate = Column(Float, nullable=True)
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
hr_zones = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="activities")
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
named_route = relationship("NamedRoute", back_populates="activities")
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
class ActivityDataPoint(Base):
__tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
altitude_m = Column(Float, nullable=True)
heart_rate = Column(Float, nullable=True)
cadence = Column(Float, nullable=True)
speed_ms = Column(Float, nullable=True)
power = Column(Float, nullable=True)
temperature_c = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="data_points")
class ActivityLap(Base):
__tablename__ = "activity_laps"
id = Column(Integer, primary_key=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
lap_number = Column(Integer, nullable=False)
start_time = Column(DateTime(timezone=True), nullable=True)
duration_s = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="laps")
class NamedRoute(Base):
__tablename__ = "named_routes"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
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")
activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base):
__tablename__ = "route_segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.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)
route = relationship("NamedRoute", back_populates="segments")
class PersonalRecord(Base):
__tablename__ = "personal_records"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
sport_type = Column(String(64), nullable=False)
distance_m = Column(Float, nullable=False)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
__table_args__ = (
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
name="uq_pr_current"),
)
class HealthMetric(Base):
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
sleep_rem_s = Column(Float, nullable=True)
sleep_awake_s = Column(Float, nullable=True)
sleep_score = Column(Float, nullable=True)
sleep_start = Column(DateTime(timezone=True), nullable=True)
sleep_end = Column(DateTime(timezone=True), nullable=True)
weight_kg = Column(Float, nullable=True)
bmi = Column(Float, nullable=True)
body_fat_pct = Column(Float, nullable=True)
muscle_mass_kg = Column(Float, nullable=True)
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
avg_stress = Column(Float, nullable=True)
steps = Column(Integer, nullable=True)
floors_climbed = Column(Integer, nullable=True)
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
Index("ix_health_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="health_metrics")
@@ -0,0 +1,307 @@
"""
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 math
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional
import gpxpy
import polyline as polyline_lib
FIT_EPOCH_S = 631065600
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return 2 * R * math.asin(math.sqrt(a))
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 activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
session = {}
records = []
laps = []
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)
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,
)
# 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 isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
# Build GPS track
coords = [
(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)
# Normalize data points
normalized_points = []
for r in records:
ts = r.get("timestamp")
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"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"),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": _safe_float(lap.get("total_elapsed_time")),
"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") 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": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")),
"duration_s": _safe_float(session.get("total_elapsed_time")),
"elevation_gain_m": _safe_float(session.get("total_ascent")),
"elevation_loss_m": _safe_float(session.get("total_descent")),
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
"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") 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("total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"data_points": normalized_points,
"laps": normalized_laps,
}
def parse_gpx_file(filepath: str) -> 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")
for segment in track.segments:
for pt in segment.points:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
for child in ext:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
try:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude,
"longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
"speed_ms": extensions.get("speed"),
"power": extensions.get("power"),
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
"distance_m": None,
})
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
bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
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)):
diff = alts[i] - alts[i-1]
if diff > 0:
uphill += diff
else:
downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
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"
if track.type:
sport = track.type.lower()
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport,
"start_time": start_time_str,
"distance_m": total_dist,
"duration_s": duration,
"elevation_gain_m": uphill,
"elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None,
"avg_power": None,
"normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"max_speed_ms": None,
"avg_temperature_c": None,
"calories": None,
"training_stress_score": None,
"vo2max_estimate": None,
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "gpx",
"data_points": data_points,
"laps": [],
}
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
"""
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.
"""
if not user_max_hr or user_max_hr < 100:
return {}
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 or hr < 20:
continue
pct = hr / user_max_hr
total += 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 # anything above 90% goes to z5
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
@@ -0,0 +1,190 @@
"""
Route matching: identifies when multiple activities were on the same route.
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
"""
import math
from typing import Optional
import polyline as polyline_lib
import numpy as np
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
return polyline_lib.decode(encoded)
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
return (
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
)
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
if len(coords) <= n:
return coords
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
return [coords[i] for i in indices]
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
"""
Compute DTW distance between two GPS tracks.
Each point is (lat, lon). Returns average distance in metres per matched pair.
"""
n, m = len(track1), len(track2)
dtw = np.full((n + 1, m + 1), np.inf)
dtw[0][0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = haversine_m(track1[i-1], track2[j-1])
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
return dtw[n][m] / max(n, m)
def haversine_m(p1: tuple, p2: tuple) -> float:
R = 6371000
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
return 2 * R * math.asin(math.sqrt(a))
def routes_are_similar(
poly1: str,
poly2: str,
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
"""
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
try:
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
except Exception:
return False
if not coords1 or not coords2:
return False
dist = dtw_distance(coords1, coords2)
return dist < dtw_threshold_m
def find_segment_times(
data_points: list[dict],
start_dist_m: float,
end_dist_m: float,
) -> Optional[float]:
"""
Given activity data points (with cumulative distance_m),
find the time to traverse from start_dist_m to end_dist_m.
Returns duration in seconds, or None if not found.
"""
start_time = None
end_time = None
for p in data_points:
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
continue
if start_time is None and dist >= start_dist_m:
start_time = ts
if start_time is not None and dist >= end_dist_m:
end_time = ts
break
if start_time and end_time:
from datetime import datetime
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
return None
def find_best_split_time(
data_points: list[dict],
target_distance_m: float,
) -> Optional[float]:
"""
Find the best (fastest) time over any target_distance_m window within an activity.
E.g. fastest 1km split in a 10km run.
Returns duration in seconds.
"""
points_with_dist = [
p for p in data_points
if p.get("distance_m") is not None and p.get("timestamp") is not None
]
if not points_with_dist:
return None
best = None
j = 0
for i, start_p in enumerate(points_with_dist):
start_dist = start_p["distance_m"]
start_ts = start_p["timestamp"]
# Advance j until distance covered >= target
while j < len(points_with_dist):
end_p = points_with_dist[j]
covered = end_p["distance_m"] - start_dist
if covered >= target_distance_m:
from datetime import datetime
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
duration = (t2 - t1).total_seconds()
if best is None or duration < best:
best = duration
break
j += 1
if j >= len(points_with_dist):
break
return best
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
(1000, "1k"),
(1609.34, "1 mile"),
(3000, "3k"),
(5000, "5k"),
(10000, "10k"),
(21097.5, "Half marathon"),
(42195, "Marathon"),
(50000, "50k"),
(100000, "100k"),
]
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
"""Compute best split times for all standard distances that fit within the activity."""
results = {}
for dist_m, label in STANDARD_DISTANCES:
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
best = find_best_split_time(data_points, dist_m)
if best:
results[label] = best
return results
@@ -0,0 +1,309 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The official SDK (garmin-fit-sdk) correctly handles:
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
- Garmin proprietary messages stored by numeric mesg_num
- Unknown fields stored by field definition number
- Scale/offset application, component expansion, HR merging
Fenix 6X proprietary message numbers identified by binary analysis:
55 - activity accumulation snapshots (cumulative steps, HR per interval)
103 - daily totals summary (total steps, floors, calories)
211 - resting HR + HRV summary
227 - per-minute stress level + heart rate (most valuable for health dashboard)
"""
from datetime import datetime, timezone, timedelta, date
from typing import Optional
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
def fit_ts(seconds) -> Optional[datetime]:
"""Convert FIT timestamp to UTC datetime."""
if seconds is None:
return None
try:
s = int(seconds)
if s == 0 or s == 0xFFFFFFFF:
return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return None
def _is_datetime(v) -> bool:
return isinstance(v, datetime)
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
Returns {"days": {date: metrics_dict}, "error": str|None}
"""
try:
from garmin_fit_sdk import Decoder, Stream
except ImportError:
# Fall back to fitparse-based parser if SDK not installed yet
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
return _fb(file_path)
daily = {} # date -> aggregation dict
def ensure_day(d: date) -> dict:
if d not in daily:
daily[d] = {
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
"sleep_levels": [],
"steps": None,
"floors_climbed": None,
"active_calories": None,
"total_calories": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
}
return daily[d]
def get_date(msg: dict, *keys) -> Optional[date]:
"""Extract a date from a message, trying multiple field names."""
for key in keys:
v = msg.get(key)
if v is None:
continue
if _is_datetime(v):
return v.date()
if isinstance(v, (int, float)):
dt = fit_ts(v)
if dt:
return dt.date()
return None
def listener(mesg_num: int, msg: dict):
"""Called for every message after full decoding."""
# ── Standard: monitoring (148) ────────────────────────────────────
if mesg_num == 148:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress))
# ── Standard: monitoring_info (147) ───────────────────────────────
elif mesg_num == 147:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
rhr = msg.get("resting_heart_rate")
if rhr and 20 < rhr < 120:
ensure_day(d)["resting_hr"] = int(rhr)
# ── Standard: hrv_status_summary (275) ────────────────────────────
elif mesg_num == 275:
d = get_date(msg, "timestamp")
if not d:
return
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status")
if status:
entry["hrv_status"] = str(status)
# ── Standard: stress_level (132) ──────────────────────────────────
elif mesg_num == 132:
d = get_date(msg, "stress_level_time", "timestamp")
if not d:
return
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress))
# ── Standard: spo2_data (258) ─────────────────────────────────────
elif mesg_num == 258:
d = get_date(msg, "timestamp")
if not d:
return
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2))
# ── Standard: sleep_level (269) ───────────────────────────────────
elif mesg_num == 269:
d = get_date(msg, "timestamp")
if not d:
return
level = msg.get("sleep_level")
if level is not None:
# Convert string level names to numeric codes if SDK decoded them
if isinstance(level, str):
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
level = level_map.get(level.lower())
if level is not None:
ensure_day(d)["sleep_levels"].append(int(level))
# ── Proprietary 227: per-minute stress + HR ───────────────────────
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
elif mesg_num == 227:
# SDK stores unknown fields as "unknown_N" or by def_num
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
ts_raw if _is_datetime(ts_raw) else None
)
if not ts:
return
entry = ensure_day(ts.date())
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
entry["stress_values"].append(int(stress_raw))
# ── Proprietary 103: daily totals summary ─────────────────────────
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
elif mesg_num == 103:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(3)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = int(steps)
floors = msg.get(4)
if floors and isinstance(floors, (int, float)) and floors > 0:
f = float(floors)
if f > 1000:
f = f / 100
entry["floors_climbed"] = round(f, 1)
active_cal = msg.get(5)
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
entry["active_calories"] = float(active_cal)
total_cal = msg.get(7)
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
entry["total_calories"] = float(total_cal)
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
elif mesg_num == 211:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
rhr = msg.get(0)
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr)
hrv = msg.get(1)
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
entry["hrv_nightly_avg"] = float(hrv)
# ── Proprietary 55: activity accumulation snapshots ───────────────
elif mesg_num == 55:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(2)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
hr = msg.get(19)
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
# Decode the file
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
messages, errors = decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False, # wellness files sometimes have bad CRCs
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
except Exception as e:
return {"error": str(e), "days": {}}
# Aggregate per-day
result = {}
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", [])
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
max_hr = max(hrs) if hrs else None
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Sleep stage seconds (each level record = 30s epoch)
if sleep_levels:
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
"avg_hr_day": avg_hr,
"max_hr_day": max_hr,
"avg_stress": avg_stress,
"spo2_avg": spo2_avg,
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
"hrv_5min_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors_climbed": data.get("floors_climbed"),
"active_calories": data.get("active_calories"),
"total_calories": data.get("total_calories"),
"sleep_duration_s": sleep_duration_s,
"sleep_deep_s": sleep_deep_s,
"sleep_light_s": sleep_light_s,
"sleep_rem_s": sleep_rem_s,
"sleep_awake_s": sleep_awake_s,
}
return {"days": result, "error": None}
@@ -0,0 +1,7 @@
"""
Celery entry point. Re-exports celery_app from tasks so the worker
can be started with: celery -A app.workers.celery_app worker
"""
from app.workers.tasks import celery_app
__all__ = ["celery_app"]
@@ -0,0 +1,451 @@
"""
Background tasks: activity ingestion, route matching, PR calculation.
Uses synchronous SQLAlchemy because Celery's prefork model doesn't play
well with asyncio - each worker process needs its own connection pool,
and async pools don't survive process forks.
"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"milevault",
broker=settings.redis_url,
backend=settings.redis_url,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
)
# Garmin FIT file suffixes that are health/wellness data, not activities
WELLNESS_SUFFIXES = (
"_METRICS.fit",
"_WELLNESS.fit",
"_SLEEP.fit",
"_STRESS.fit",
"_SPO2.fit",
"_HRV.fit",
"_MONITORING.fit",
"_MONITORING_B.fit",
)
def is_wellness_file(file_path: str) -> bool:
name = file_path.upper()
return any(name.endswith(s.upper()) for s in WELLNESS_SUFFIXES)
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
# Route wellness/metrics files to health parser instead
if is_wellness_file(file_path):
parse_wellness_fit.delay(file_path, user_id)
return {"status": "routed_to_wellness", "file": file_path}
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
from app.core.database import SyncSessionLocal
from app.models.user import Activity, ActivityDataPoint, ActivityLap
from sqlalchemy import select
from datetime import datetime
self.update_state(state="PROGRESS", meta={"step": "parsing"})
try:
if source_type == "fit" or file_path.endswith(".fit"):
parsed = parse_fit_file(file_path)
else:
parsed = parse_gpx_file(file_path)
except Exception as e:
raise self.retry(exc=e, countdown=10, max_retries=3)
# Skip files with no usable activity data
if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path}
with SyncSessionLocal() as db:
# Check for duplicate by garmin activity ID
if parsed.get("garmin_activity_id"):
existing = db.execute(
select(Activity).where(
Activity.garmin_activity_id == parsed["garmin_activity_id"]
)
).scalar_one_or_none()
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", []),
user_max_hr
)
start_time = datetime.fromisoformat(parsed["start_time"])
activity = Activity(
user_id=user_id,
name=parsed["name"],
sport_type=parsed["sport_type"],
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"),
max_heart_rate=parsed.get("max_heart_rate"),
avg_cadence=parsed.get("avg_cadence"),
avg_power=parsed.get("avg_power"),
normalized_power=parsed.get("normalized_power"),
avg_speed_ms=parsed.get("avg_speed_ms"),
max_speed_ms=parsed.get("max_speed_ms"),
avg_temperature_c=parsed.get("avg_temperature_c"),
calories=parsed.get("calories"),
training_stress_score=parsed.get("training_stress_score"),
polyline=parsed.get("polyline"),
bounding_box=parsed.get("bounding_box"),
source_file=file_path,
source_type=parsed.get("source_type"),
hr_zones=hr_zones,
)
db.add(activity)
db.flush()
# Insert data points, deduping on (activity_id, timestamp)
seen = set()
points = parsed.get("data_points", [])
batch = []
for p in points:
if not p.get("timestamp"):
continue
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
key = (activity.id, ts)
if key in seen:
continue
seen.add(key)
batch.append(ActivityDataPoint(
activity_id=activity.id,
timestamp=ts,
latitude=p.get("latitude"),
longitude=p.get("longitude"),
altitude_m=p.get("altitude_m"),
heart_rate=p.get("heart_rate"),
cadence=p.get("cadence"),
speed_ms=p.get("speed_ms"),
power=p.get("power"),
temperature_c=p.get("temperature_c"),
distance_m=p.get("distance_m"),
))
if len(batch) >= 500:
db.add_all(batch)
db.flush()
batch = []
if batch:
db.add_all(batch)
db.flush()
# Laps
for lap in parsed.get("laps", []):
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
db.add(ActivityLap(
activity_id=activity.id,
lap_number=lap["lap_number"],
start_time=ls,
duration_s=lap.get("duration_s"),
distance_m=lap.get("distance_m"),
avg_heart_rate=lap.get("avg_heart_rate"),
avg_cadence=lap.get("avg_cadence"),
avg_speed_ms=lap.get("avg_speed_ms"),
avg_power=lap.get("avg_power"),
))
db.commit()
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"}
@celery_app.task(name="parse_wellness_fit")
def parse_wellness_fit(file_path: str, user_id: int):
"""
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
"""
from app.services.wellness_parser import parse_wellness_fit as _parse
from app.core.database import SyncSessionLocal
from datetime import datetime, timezone
from sqlalchemy import text
result = _parse(file_path)
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
days = result.get("days", {})
if not days:
return {"status": "no_data", "file": file_path}
with SyncSessionLocal() as db:
for day_date, data in days.items():
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
db.execute(text("""
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, :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),
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)
"""), {
"user_id": user_id, "date": date_dt,
"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"),
"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(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")
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
"""Calculate personal records for standard distances from this activity."""
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
from app.core.database import SyncSessionLocal
from app.models.user import PersonalRecord
from sqlalchemy import select
from datetime import datetime, timezone
data_points = parsed.get("data_points", [])
total_dist = parsed.get("distance_m", 0) or 0
sport = parsed.get("sport_type", "running")
start_time_str = parsed.get("start_time")
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
best_splits = compute_best_splits(data_points, total_dist)
with SyncSessionLocal() as db:
for label, duration_s in best_splits.items():
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
if dist_m is None:
continue
current = db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == user_id,
PersonalRecord.sport_type == sport,
PersonalRecord.distance_m == dist_m,
PersonalRecord.is_current_record == True,
)
).scalar_one_or_none()
if current is None or duration_s < current.duration_s:
if current:
current.is_current_record = False
db.add(PersonalRecord(
user_id=user_id,
activity_id=activity_id,
sport_type=sport,
distance_m=dist_m,
distance_label=label,
duration_s=duration_s,
achieved_at=start_time,
is_current_record=True,
))
db.commit()
@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."""
import zipfile
import json
from app.core.database import SyncSessionLocal
from app.models.user import HealthMetric
from datetime import datetime, timezone
with SyncSessionLocal() as db:
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist():
if "DailyMetrics" not in name or not name.endswith(".json"):
continue
with zf.open(name) as f:
try:
data = json.load(f)
except Exception:
continue
date_str = data.get("calendarDate") or data.get("date")
if not date_str:
continue
try:
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
except ValueError:
continue
from sqlalchemy import text as _text
db.execute(_text("""
INSERT INTO health_metrics (user_id, date, resting_hr, steps,
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
VALUES (:user_id, :date, :resting_hr, :steps,
:floors, :active_cal, :total_cal, :stress, :spo2)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
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),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": data.get("restingHeartRate"),
"steps": data.get("totalSteps"),
"floors": data.get("floorsAscended"),
"active_cal": data.get("activeKilocalories"),
"total_cal": data.get("totalKilocalories"),
"stress": data.get("averageStressLevel"),
"spo2": data.get("avgSpo2"),
})
db.commit()