diff --git a/backend/Dockerfile b/backend/Dockerfile
index 28c262f..28e00d0 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -13,4 +13,4 @@ COPY . .
# Single worker avoids race condition during DB initialization.
# For a personal app this is fine; async handles concurrent requests well.
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 033843b..c4536ae 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -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}")
diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py
new file mode 100644
index 0000000..bcd2fdd
--- /dev/null
+++ b/backend/app/api/profile.py
@@ -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 100–250")
+ 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 20–120")
+ 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 50–300 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 20–500 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()
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index 8c0757a..ce7c8f1 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -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"}
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index e61e268..71778e8 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -36,4 +36,4 @@ class Settings(BaseSettings):
case_sensitive = False
-settings = Settings()
\ No newline at end of file
+settings = Settings()
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 726fb25..cd4242b 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -44,4 +44,4 @@ async def get_db():
await session.rollback()
raise
finally:
- await session.close()
\ No newline at end of file
+ await session.close()
diff --git a/backend/app/main.py b/backend/app/main.py
index 97e142a..c1a9204 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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"}
\ No newline at end of file
+ return {"status": "ok"}
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 41d8c8e..8275b29 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -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")
\ No newline at end of file
+ user = relationship("User", back_populates="health_metrics")
diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py
index a6d9fe9..13ac510 100644
--- a/backend/app/services/fit_parser.py
+++ b/backend/app/services/fit_parser.py
@@ -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),
- }
diff --git a/backend/app/services/wellness_parser.py b/backend/app/services/wellness_parser.py
index 62be2c0..6352429 100644
--- a/backend/app/services/wellness_parser.py
+++ b/backend/app/services/wellness_parser.py
@@ -306,4 +306,4 @@ def parse_wellness_fit(file_path: str) -> dict:
"sleep_awake_s": sleep_awake_s,
}
- return {"days": result, "error": None}
\ No newline at end of file
+ return {"days": result, "error": None}
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 3ba5108..451d397 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -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"]
\ No newline at end of file
+__all__ = ["celery_app"]
diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py
index f8e044d..5648dd3 100644
--- a/backend/app/workers/tasks.py
+++ b/backend/app/workers/tasks.py
@@ -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()
\ No newline at end of file
+ db.commit()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 2788a83..eed09f5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,6 +12,7 @@ python-multipart==0.0.9
httpx==0.27.0
redis[hiredis]==5.0.4
celery[redis]==5.4.0
+garmin-fit-sdk==21.195.0
fitparse==1.2.0
gpxpy==1.6.2
numpy==1.26.4
@@ -22,4 +23,4 @@ Pillow==10.3.0
aiofiles==23.2.1
python-dateutil==2.9.0
pytz==2024.1
-psycopg2-binary==2.9.9
\ No newline at end of file
+psycopg2-binary==2.9.9
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 13722c5..7649402 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
-RUN npm install
+RUN npm ci
COPY . .
ARG VITE_API_URL=/api
diff --git a/frontend/package.json b/frontend/package.json
index 025339e..60487d3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,7 +20,8 @@
"zustand": "^4.5.2",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
- "react-dropzone": "^14.2.3"
+ "react-dropzone": "^14.2.3",
+ "@polyline-codec/core": "^2.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
@@ -29,4 +30,4 @@
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4b1dc07..cb1a600 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -10,6 +10,7 @@ import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage'
import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage'
+import ProfilePage from './pages/ProfilePage'
function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token)
@@ -24,7 +25,6 @@ export default function App() {
if (token) fetchUser()
}, [token])
- // Handle token from PocketID callback URL
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
@@ -38,14 +38,7 @@ export default function App() {
return (
} />
-
-
-
- }
- >
+ }>
} />
} />
} />
@@ -53,6 +46,7 @@ export default function App() {
} />
} />
} />
+ } />
)
diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx
index 59610dd..0fe01c5 100644
--- a/frontend/src/components/activity/ActivityMap.jsx
+++ b/frontend/src/components/activity/ActivityMap.jsx
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import L from 'leaflet'
import { sportColor } from '../../utils/format'
-// Fix Leaflet default icon issue with bundlers
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
@@ -10,103 +9,87 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
+const TILE_LAYERS = {
+ dark: {
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ attribution: '© OSM © CARTO ',
+ },
+ street: {
+ url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
+ attribution: '© OSM © CARTO ',
+ },
+ satellite: {
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ attribution: '© Esri ',
+ },
+}
+
function decodePolyline(encoded) {
- // Simple polyline decoder
const coords = []
let index = 0, lat = 0, lng = 0
-
while (index < encoded.length) {
let b, shift = 0, result = 0
- do {
- b = encoded.charCodeAt(index++) - 63
- result |= (b & 0x1f) << shift
- shift += 5
- } while (b >= 0x20)
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1
-
shift = 0; result = 0
- do {
- b = encoded.charCodeAt(index++) - 63
- result |= (b & 0x1f) << shift
- shift += 5
- } while (b >= 0x20)
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1
-
coords.push([lat / 1e5, lng / 1e5])
}
return coords
}
-export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) {
+export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const trackRef = useRef(null)
+ const tileLayerRef = useRef(null)
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return
-
- mapInstanceRef.current = L.map(mapRef.current, {
- zoomControl: true,
- attributionControl: true,
- })
-
- // Use CartoDB dark tiles (no API key needed)
- L.tileLayer(
- 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
- {
- attribution: '© OSM © CARTO ',
- maxZoom: 19,
- }
- ).addTo(mapInstanceRef.current)
-
- return () => {
- mapInstanceRef.current?.remove()
- mapInstanceRef.current = null
- }
+ mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
+ const tile = TILE_LAYERS['dark']
+ tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
+ .addTo(mapInstanceRef.current)
+ return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
}, [])
- // Draw route when polyline changes
+ // Switch tile layer when mapType changes
+ useEffect(() => {
+ if (!mapInstanceRef.current) return
+ const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
+ if (tileLayerRef.current) {
+ tileLayerRef.current.remove()
+ }
+ tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
+ .addTo(mapInstanceRef.current)
+ }, [mapType])
+
+ // Draw route
useEffect(() => {
if (!mapInstanceRef.current || !polyline) return
-
- if (trackRef.current) {
- trackRef.current.remove()
- }
-
+ if (trackRef.current) trackRef.current.remove()
const coords = decodePolyline(polyline)
if (!coords.length) return
-
- trackRef.current = L.polyline(coords, {
- color: sportColor(sportType),
- weight: 3,
- opacity: 0.9,
- }).addTo(mapInstanceRef.current)
-
+ trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
+ .addTo(mapInstanceRef.current)
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
-
- // Start/end markers
if (coords.length > 0) {
- const startIcon = L.divIcon({
- html: '
',
+ const dot = (color) => L.divIcon({
+ html: `
`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
- const endIcon = L.divIcon({
- html: '
',
- iconSize: [12, 12], iconAnchor: [6, 6], className: '',
- })
- L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current)
- L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current)
+ L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
+ L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
}
}, [polyline, sportType])
- // Move position marker when timeline is hovered
+ // Position marker on timeline hover
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
-
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return
-
if (markerRef.current) {
markerRef.current.setLatLng([point.latitude, point.longitude])
} else {
@@ -114,8 +97,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
html: '
',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
- markerRef.current = L.marker([point.latitude, point.longitude], { icon })
- .addTo(mapInstanceRef.current)
+ markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
}
}, [hoveredDistance, dataPoints])
diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx
index 5f70ac3..bdc0df6 100644
--- a/frontend/src/components/activity/LapTable.jsx
+++ b/frontend/src/components/activity/LapTable.jsx
@@ -1,4 +1,4 @@
-import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format'
+import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
export default function LapTable({ laps, sportType }) {
return (
@@ -26,7 +26,7 @@ export default function LapTable({ laps, sportType }) {
{formatHeartRate(lap.avg_heart_rate)}
- {lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'}
+ {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx
index 268dbae..6b2b35d 100644
--- a/frontend/src/components/activity/MetricTimeline.jsx
+++ b/frontend/src/components/activity/MetricTimeline.jsx
@@ -1,9 +1,9 @@
-import { useMemo, useCallback } from 'react'
+import { useMemo } from 'react'
import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
- ResponsiveContainer, ReferenceLine,
+ ResponsiveContainer,
} from 'recharts'
-import { formatDuration, formatPace } from '../../utils/format'
+import { formatPace, formatCadence } from '../../utils/format'
function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points
@@ -17,7 +17,7 @@ function buildChartData(dataPoints, activeMetrics) {
.map(p => {
const row = { distance_m: p.distance_m ?? 0 }
for (const key of activeMetrics) {
- row[key] = p[key] ?? null
+ row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
}
return row
})
@@ -25,9 +25,7 @@ function buildChartData(dataPoints, activeMetrics) {
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
if (!active || !payload?.length) return null
-
if (onHover) onHover(label)
-
return (
{(label / 1000).toFixed(2)} km
@@ -37,7 +35,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
let display = entry.value.toFixed(1)
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
- else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm`
+ else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
@@ -61,7 +59,6 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
- // Build per-metric Y-axis domains
const domains = useMemo(() => {
const result = {}
for (const m of activeMetricConfigs) {
@@ -70,6 +67,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
const min = Math.min(...vals)
const max = Math.max(...vals)
const pad = (max - min) * 0.1 || 1
+ // For elevation, don't start from 0 - show actual range
result[m.key] = [min - pad, max + pad]
}
return result
@@ -87,18 +85,14 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
{activeMetricConfigs.map((metric, idx) => {
const domain = domains[metric.key] || ['auto', 'auto']
- const data = chartData.filter(p => p[metric.key] != null)
- if (!data.length) return null
+ const hasData = chartData.some(p => p[metric.key] != null)
+ if (!hasData) return null
return (
-
- {metric.label}
-
- {metric.unit && (
- ({metric.unit})
- )}
+ {metric.label}
+ {metric.unit && ({metric.unit}) }
@@ -118,20 +112,19 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
- width={36}
+ width={40}
tickFormatter={v => {
- if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}`
+ if (metric.key === 'speed_ms') {
+ if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
+ const spm = 1000 / v
+ return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
+ }
+ if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
return Math.round(v)
}}
/>
- }
+ content={ }
isAnimationActive={false}
/>
)
})}
-
- {/* Shared distance axis label */}
Distance (km)
)
diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx
index 71b9603..881172f 100644
--- a/frontend/src/components/ui/Layout.jsx
+++ b/frontend/src/components/ui/Layout.jsx
@@ -8,6 +8,7 @@ const nav = [
{ to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' },
+ { to: '/profile', label: 'Profile', icon: '⚙️' },
]
export default function Layout() {
@@ -21,51 +22,38 @@ export default function Layout() {
return (
- {/* Sidebar */}
- {/* Main content */}
diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx
index e173311..67efcf0 100644
--- a/frontend/src/pages/ActivitiesPage.jsx
+++ b/frontend/src/pages/ActivitiesPage.jsx
@@ -7,7 +7,7 @@ import {
formatDate, sportIcon, sportColor,
} from '../utils/format'
-const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking']
+const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
export default function ActivitiesPage() {
const [sport, setSport] = useState('all')
diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx
index 943117b..e8c92ea 100644
--- a/frontend/src/pages/ActivityDetailPage.jsx
+++ b/frontend/src/pages/ActivityDetailPage.jsx
@@ -9,14 +9,14 @@ import LapTable from '../components/activity/LapTable'
import StatCard from '../components/ui/StatCard'
import {
formatDuration, formatDistance, formatPace, formatElevation,
- formatHeartRate, formatDateTime, sportIcon,
+ formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
- { key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' },
+ { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
]
@@ -25,6 +25,8 @@ export default function ActivityDetailPage() {
const { id } = useParams()
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null)
+ const [mapHeight, setMapHeight] = useState(420)
+ const [mapType, setMapType] = useState('dark')
const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id],
@@ -49,19 +51,21 @@ export default function ActivityDetailPage() {
)
}
- if (isLoading) {
- return (
-
+ // Check which metrics have actual data
+ const availableMetrics = useMemo(() => {
+ if (!dataPoints?.length) return new Set()
+ return new Set(
+ METRICS
+ .filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
+ .map(m => m.key)
)
+ }, [dataPoints])
+
+ if (isLoading) {
+ return
}
-
if (!activity) return null
- const speed = activity.avg_speed_ms
- const pace = formatPace(speed, activity.sport_type)
-
return (
{/* Header */}
@@ -75,12 +79,12 @@ export default function ActivityDetailPage() {
- {/* Summary stats */}
+ {/* Primary stats */}
-
-
+
+
@@ -88,37 +92,71 @@ export default function ActivityDetailPage() {
{/* Secondary stats */}
-
+
+
-
- {/* Map */}
-
-
+ {/* Map with controls */}
+
+ {/* Map toolbar */}
+
+
+ Map style:
+ {['dark', 'street', 'satellite'].map(t => (
+ setMapType(t)}
+ className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
+ mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
+ }`}
+ >
+ {t}
+
+ ))}
+
+
+ Height:
+ {[280, 420, 560].map(h => (
+ setMapHeight(h)}
+ className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
+ mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
+ }`}
+ >
+ {h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
+
+ ))}
+
+
+
{/* HR Zones */}
- {activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && (
+ {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
Heart Rate Zones
)}
- {/* Metric selector */}
+ {/* Metric timeline */}
Activity Timeline
- {METRICS.map(({ key, label, color }) => (
+ {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
toggleMetric(key)}
@@ -134,15 +172,16 @@ export default function ActivityDetailPage() {
))}
-
- {dataPoints && (
+ {dataPoints && dataPoints.length > 0 ? (
availableMetrics.has(m))}
metrics={METRICS}
onHoverDistance={setHoveredDistance}
sportType={activity.sport_type}
/>
+ ) : (
+ No timeline data available for this activity
)}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index e5aeecb..8d302ed 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
-import { format, subDays, startOfWeek } from 'date-fns'
+import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import {
@@ -10,18 +10,29 @@ import {
} from '../utils/format'
function WeeklyChart({ activities }) {
- if (!activities?.length) return null
+ if (!activities?.length) return (
+
No activities yet
+ )
- // Build last 8 weeks of distance data
- const weeks = {}
- activities.forEach(a => {
- const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
- if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
- weeks[week].km += (a.distance_m || 0) / 1000
- weeks[week].runs++
+ // Build last 8 weeks in chronological order
+ const now = new Date()
+ const weeks = eachWeekOfInterval({
+ start: subWeeks(startOfWeek(now), 7),
+ end: startOfWeek(now),
})
- const data = Object.values(weeks).slice(-8)
+ const data = weeks.map(weekStart => {
+ const weekKey = format(weekStart, 'MMM d')
+ const weekEnd = new Date(weekStart)
+ weekEnd.setDate(weekEnd.getDate() + 7)
+ const km = activities
+ .filter(a => {
+ const t = new Date(a.start_time)
+ return t >= weekStart && t < weekEnd
+ })
+ .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
+ return { week: weekKey, km: parseFloat(km.toFixed(2)) }
+ })
return (
@@ -30,10 +41,8 @@ function WeeklyChart({ activities }) {
`${v.toFixed(0)}`} />
- [`${v.toFixed(1)} km`, 'Distance']}
- />
+ [`${v.toFixed(1)} km`, 'Distance']} />
@@ -50,10 +59,7 @@ export default function DashboardPage() {
queryKey: ['activities-all-chart'],
queryFn: () =>
api.get('/activities/', {
- params: {
- per_page: 100,
- from_date: subDays(new Date(), 60).toISOString(),
- },
+ params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
}).then(r => r.data),
})
@@ -68,64 +74,45 @@ export default function DashboardPage() {
})
const latest = healthSummary?.latest
- const totalActivities = recentActivities?.length ?? 0
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
return (
Dashboard
-
- + Import data
-
+ + Import data
- {/* Top stats */}
-
-
+
+
- {/* Weekly distance chart */}
Weekly distance (km)
- {/* Health snapshot */}
Health today
{latest ? (
<>
-
- HRV
- {latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
-
-
- Sleep score
- {latest.sleep_score ? Math.round(latest.sleep_score) : '--'}
-
-
- Steps
- {latest.steps?.toLocaleString() ?? '--'}
-
-
- VO2 Max
- {latest.vo2max ? latest.vo2max.toFixed(1) : '--'}
-
-
- Stress
- {latest.avg_stress ? Math.round(latest.avg_stress) : '--'}
-
-
- View full health dashboard →
-
+ {[
+ ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
+ ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
+ ['Steps', latest.steps?.toLocaleString() ?? '--'],
+ ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
+ ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
+ ].map(([label, val]) => (
+
+ {label}
+ {val}
+
+ ))}
+
View full health dashboard →
>
) : (
No health data. Import a Garmin export.
@@ -141,29 +128,17 @@ export default function DashboardPage() {
{recentActivities?.slice(0, 5).map(activity => (
-
+
{sportIcon(activity.sport_type)}
{activity.name}
{formatDate(activity.start_time)}
-
-
{formatDistance(activity.distance_m)}
-
dist
-
-
-
{formatDuration(activity.duration_s)}
-
time
-
-
-
{formatHeartRate(activity.avg_heart_rate)}
-
HR
-
+
{formatDistance(activity.distance_m)}
dist
+
{formatDuration(activity.duration_s)}
time
+
{formatHeartRate(activity.avg_heart_rate)}
HR
))}
@@ -175,7 +150,6 @@ export default function DashboardPage() {
- {/* PRs snapshot */}
{records?.length > 0 && (
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx
index 3c18e53..6d91413 100644
--- a/frontend/src/pages/HealthPage.jsx
+++ b/frontend/src/pages/HealthPage.jsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, AreaChart, Area, BarChart, Bar,
@@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [
+ { label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
@@ -17,7 +18,13 @@ const RANGES = [
{ label: '1Y', days: 365 },
]
+const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
+
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
+ const vals = data.filter(d => d[dataKey] != null)
+ if (!vals.length) return (
+
No data
+ )
return (
@@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
- format(new Date(d), 'MMM d')}
- interval="preserveStartEnd"
- />
-
- format(new Date(d), 'MMM d, yyyy')}
- formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
- />
-
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+
+ format(new Date(d), 'MMM d, yyyy')}
+ formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
+
)
@@ -71,7 +56,8 @@ function SleepChart({ data }) {
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
}))
-
+ const hasData = chartData.some(d => d.deep || d.rem || d.light)
+ if (!hasData) return
No sleep data
return (
@@ -80,9 +66,8 @@ function SleepChart({ data }) {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
`${v}h`} />
- format(new Date(d), 'MMM d, yyyy')} />
-
+ format(new Date(d), 'MMM d, yyyy')} />
+
@@ -92,21 +77,22 @@ function SleepChart({ data }) {
}
export default function HealthPage() {
- const [rangeDays, setRangeDays] = useState(30)
+ const [rangeDays, setRangeDays] = useState(7) // default 1 week
- const fromDate = subDays(new Date(), rangeDays).toISOString()
+ const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
- const { data: metrics } = useQuery({
+ const { data: metrics, isLoading } = useQuery({
queryKey: ['health-metrics', rangeDays],
queryFn: () =>
api.get('/health-metrics/', {
- params: { from_date: fromDate, limit: rangeDays },
- }).then(r => r.data.reverse()),
+ params: { from_date: fromDate, limit: rangeDays + 1 },
+ }).then(r => r.data.slice().reverse()), // oldest first for charts
+ keepPreviousData: true,
})
const latest = summary?.latest
@@ -118,132 +104,75 @@ export default function HealthPage() {
{/* Summary cards */}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{/* Range selector */}
{RANGES.map(({ label, days }) => (
- setRangeDays(days)}
+ setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
- rangeDays === days
- ? 'bg-blue-600 border-blue-600 text-white'
- : 'border-gray-700 text-gray-400 hover:text-white'
- }`}
- >
+ rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
+ }`}>
{label}
))}
- {metrics && metrics.length > 0 ? (
+ {isLoading ? (
+ Loading…
+ ) : metrics && metrics.length > 0 ? (
- {/* Resting HR */}
Resting Heart Rate
- `${Math.round(v)} bpm`}
- />
+ `${Math.round(v)} bpm`} />
- {/* HRV */}
HRV (nightly avg)
- `${Math.round(v)} ms`}
- />
+ `${Math.round(v)} ms`} />
- {/* Sleep */}
Sleep Stages
- {[
- { label: 'Deep', color: '#6366f1' },
- { label: 'REM', color: '#8b5cf6' },
- { label: 'Light', color: '#a78bfa' },
- { label: 'Awake', color: '#374151' },
- ].map(({ label, color }) => (
-
-
-
{label}
+ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
+
))}
- {/* Weight */}
Weight
- `${v.toFixed(1)} kg`}
- />
+ `${v.toFixed(1)} kg`} />
- {/* VO2 Max */}
VO2 Max
- v.toFixed(1)}
- />
+ v.toFixed(1)} />
- {/* Steps */}
Daily Steps
@@ -253,18 +182,30 @@ export default function HealthPage() {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
- format(new Date(d), 'MMM d, yyyy')} />
+ format(new Date(d), 'MMM d, yyyy')} />
+
+
+
Avg Heart Rate (day)
+ `${Math.round(v)} bpm`} />
+
+
+
+
Stress Level
+ Math.round(v)} />
+
+
) : (
📊
-
No health data yet
-
Import a Garmin export to see your health trends
+
No health data for this period
+
Import a Garmin export or try a longer date range
)}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx
new file mode 100644
index 0000000..0b168b1
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.jsx
@@ -0,0 +1,266 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import api from '../utils/api'
+import { useAuthStore } from '../hooks/useAuth'
+
+function Section({ title, children }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
+function Field({ label, hint, children }) {
+ return (
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
+
+function Input({ type = 'text', value, onChange, placeholder, min, max }) {
+ return (
+
+ )
+}
+
+function SaveButton({ onClick, loading, saved, label = 'Save' }) {
+ return (
+
+
+ {loading ? 'Saving…' : label}
+
+ {saved && ✓ Saved }
+
+ )
+}
+
+export default function ProfilePage() {
+ const qc = useQueryClient()
+ const { user } = useAuthStore()
+
+ const { data: profile } = useQuery({
+ queryKey: ['profile'],
+ queryFn: () => api.get('/profile/').then(r => r.data),
+ })
+
+ const { data: pocketidConfig } = useQuery({
+ queryKey: ['pocketid-config'],
+ queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
+ enabled: !!user?.is_admin,
+ })
+
+ // HR / measurements form
+ const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
+ const [hrSaved, setHrSaved] = useState(false)
+ useEffect(() => {
+ if (profile) setHrForm({
+ max_heart_rate: profile.max_heart_rate || '',
+ resting_heart_rate: profile.resting_heart_rate || '',
+ birth_year: profile.birth_year || '',
+ height_cm: profile.height_cm || '',
+ })
+ }, [profile])
+
+ const updateProfile = useMutation({
+ mutationFn: data => api.patch('/profile/', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
+ })
+
+ // Weight log
+ const { data: weightLog } = useQuery({
+ queryKey: ['weight-log'],
+ queryFn: () => api.get('/profile/weight').then(r => r.data),
+ })
+ const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
+ const [weightSaved, setWeightSaved] = useState(false)
+ const addWeight = useMutation({
+ mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
+ })
+ const deleteWeight = useMutation({
+ mutationFn: id => api.delete(`/profile/weight/${id}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
+ })
+
+ // Password change
+ const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
+ const [pwError, setPwError] = useState('')
+ const [pwSaved, setPwSaved] = useState(false)
+ const changePassword = useMutation({
+ mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
+ onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
+ onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
+ })
+
+ // PocketID config
+ const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
+ const [pidSaved, setPidSaved] = useState(false)
+ useEffect(() => {
+ if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
+ }, [pocketidConfig])
+ const savePocketID = useMutation({
+ mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
+ })
+
+ const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
+
+ return (
+
+
Profile & Settings
+
+ {/* HR & Measurements */}
+
+
+ Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
+ {effectiveMaxHr && (
+
+ Effective max HR: {effectiveMaxHr} bpm
+ {!profile?.max_heart_rate && ' (estimated from age)'}
+ {' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)}
+
+ )}
+
+
+
+
+ setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
+
+
+
+ updateProfile.mutate(Object.fromEntries(
+ Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
+ ))}
+ loading={updateProfile.isPending}
+ saved={hrSaved}
+ />
+
+
+ {/* Weight log */}
+
+
+
+ setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
+
+
+ setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
+
+
+ setWeightForm(f => ({ ...f, date: e.target.value }))} />
+
+
+ addWeight.mutate({
+ weight_kg: parseFloat(weightForm.weight_kg),
+ body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
+ date: new Date(weightForm.date).toISOString(),
+ })}
+ loading={addWeight.isPending}
+ saved={weightSaved}
+ label="Log weight"
+ />
+
+ {weightLog && weightLog.length > 0 && (
+
+
Recent entries
+
+ {weightLog.slice(0, 20).map(entry => (
+
+ {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
+ {entry.weight_kg.toFixed(1)} kg
+ {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat }
+ deleteWeight.mutate(entry.id)}
+ className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕
+
+ ))}
+
+
+ )}
+
+
+ {/* Password change */}
+
+
+ {
+ if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
+ changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
+ }}
+ loading={changePassword.isPending}
+ saved={pwSaved}
+ label="Change password"
+ />
+
+
+ {/* PocketID — admin only */}
+ {user?.is_admin && (
+
+
+ Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
+
+
+ savePocketID.mutate(pidForm)}
+ loading={savePocketID.isPending}
+ saved={pidSaved}
+ label="Save PocketID config"
+ />
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx
index 7975abc..75984e1 100644
--- a/frontend/src/pages/RoutesPage.jsx
+++ b/frontend/src/pages/RoutesPage.jsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
-import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
+import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
@@ -20,18 +20,19 @@ export default function RoutesPage() {
enabled: !!selected,
})
- const { data: segments } = useQuery({
- queryKey: ['route-segments', selected?.id],
- queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
- enabled: !!selected,
+ const { data: recentActivities } = useQuery({
+ queryKey: ['recent-activities-for-route'],
+ queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
+ enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
- onSuccess: () => {
+ onSuccess: (route) => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
+ setSelected(route)
},
})
@@ -40,55 +41,62 @@ export default function RoutesPage() {
return (
-
Named Routes
-
setShowCreate(true)}
- className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
- >
+
+
Named Routes
+
+ Routes are auto-detected when you run the same path twice. You can also create them manually.
+
+
+ setShowCreate(true)}
+ className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route
- {/* Create route modal */}
+ {/* Create route */}
{showCreate && (
Create named route
- Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
+ Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
-
+
Route name
- setNewRoute(r => ({ ...r, name: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="e.g. Morning park loop"
- />
+ placeholder="e.g. Morning park loop" />
-
Reference activity ID
-
setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
- className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="Activity ID"
- />
+
Reference activity (last 2 weeks)
+ {recentActivities?.length === 0 ? (
+
No recent activities found.
+ ) : (
+
setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
+ className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ Select an activity…
+ {recentActivities?.map(a => (
+
+ {sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
+
+ ))}
+
+ )}
createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
- disabled={!newRoute.name || !newRoute.activity_id}
- className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
- >
+ disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
+ className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Create
- setShowCreate(false)}
- className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
- >
+ setShowCreate(false)}
+ className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
Cancel
@@ -98,23 +106,24 @@ export default function RoutesPage() {
{/* Route list */}
- {routes?.length === 0 && (
+ {routes?.length === 0 && !showCreate && (
🗺️
No named routes yet
+
Routes are created automatically when you repeat a run, or create one manually above.
)}
{routes?.map(route => (
-
setSelected(route)}
+ setSelected(route)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
- selected?.id === route.id
- ? 'bg-blue-900/20 border-blue-700'
- : 'bg-gray-900 border-gray-800 hover:border-gray-600'
- }`}
- >
- {route.name}
+ selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
+ }`}>
+
+
{route.name}
+ {route.auto_detected && (
+
auto
+ )}
+
{formatDistance(route.distance_m)}
{route.sport_type &&
{route.sport_type} }
@@ -128,19 +137,20 @@ export default function RoutesPage() {
{selected && (
-
{selected.name}
- {selected.description && (
-
{selected.description}
- )}
+
+
{selected.name}
+ {selected.auto_detected && (
+
+ Auto-detected
+
+ )}
+
- {/* CR */}
{fastest && (
-
Course record
+
Course record 🏆
-
- {formatDuration(fastest.duration_s)}
-
+ {formatDuration(fastest.duration_s)}
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
@@ -148,16 +158,12 @@ export default function RoutesPage() {
)}
- {/* All runs on route */}
All runs ({routeActivities?.length ?? 0})
{routeActivities?.map((act, i) => (
-
+
{i + 1}
{formatDate(act.start_time)}
{formatDuration(act.duration_s)}
@@ -166,37 +172,12 @@ export default function RoutesPage() {
{Math.round(act.avg_heart_rate)} bpm
)}
{i === 0 && (
-
- CR
-
+ CR
)}
))}
-
- {/* Segments */}
- {segments && segments.length > 0 && (
-
-
Segments
-
- {segments.map(seg => (
-
-
-
{seg.name}
- {seg.description && (
-
{seg.description}
- )}
-
-
-
{formatDistance(seg.start_distance_m)} → {formatDistance(seg.end_distance_m)}
-
{formatDistance(seg.end_distance_m - seg.start_distance_m)}
-
-
- ))}
-
-
- )}
)}
diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js
index ba2744f..6f03b35 100644
--- a/frontend/src/utils/format.js
+++ b/frontend/src/utils/format.js
@@ -10,8 +10,7 @@ export function formatDuration(seconds) {
export function formatPace(speedMs, sportType = 'running') {
if (!speedMs || speedMs <= 0) return '--'
if (sportType === 'cycling') {
- const kph = speedMs * 3.6
- return `${kph.toFixed(1)} km/h`
+ return `${(speedMs * 3.6).toFixed(1)} km/h`
}
const secsPerKm = 1000 / speedMs
const mins = Math.floor(secsPerKm / 60)
@@ -62,6 +61,17 @@ export function formatDateTime(dateStr) {
})
}
+export function formatCadence(value, sportType) {
+ if (!value) return '--'
+ // Garmin stores running cadence as steps per minute / 2 (one foot)
+ // We need to double it to get total steps per minute (both feet)
+ if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
+ return `${Math.round(value * 2)} spm`
+ }
+ // Cycling is already in rpm
+ return `${Math.round(value)} rpm`
+}
+
export function hrZoneColor(zone) {
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
return colors[zone] || '#9ca3af'
@@ -69,7 +79,7 @@ export function hrZoneColor(zone) {
export function sportIcon(sportType) {
const icons = {
- running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾',
+ running: '🏃', cycling: '🚴', hiking: '🥾',
walking: '🚶', other: '⚡',
}
return icons[sportType?.toLowerCase()] || '⚡'
@@ -77,7 +87,7 @@ export function sportIcon(sportType) {
export function sportColor(sportType) {
const colors = {
- running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4',
+ running: '#3b82f6', cycling: '#f97316',
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
}
return colors[sportType?.toLowerCase()] || '#6b7280'
diff --git a/milevault_export/.env.example b/milevault_export/.env.example
new file mode 100644
index 0000000..db30053
--- /dev/null
+++ b/milevault_export/.env.example
@@ -0,0 +1,34 @@
+# FitTracker configuration
+# Copy this file to .env and edit, OR just run: bash install.sh
+# install.sh auto-generates all secrets for you.
+
+# ── Required ──────────────────────────────────────────────────────────────────
+
+# Login for the web interface
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=changeme
+
+# Security: generate with: openssl rand -hex 32
+SECRET_KEY=changeme_run_openssl_rand_hex_32
+
+# Database password
+DB_PASSWORD=changeme
+DB_USER=fittracker
+
+# Redis password
+REDIS_PASSWORD=changeme
+
+# ── Optional ──────────────────────────────────────────────────────────────────
+
+# Port to expose (default: 80)
+HTTP_PORT=80
+
+# Mapbox token for satellite map tiles — free at mapbox.com
+# Leave blank to use OpenStreetMap (CartoDB dark tiles, no key needed)
+VITE_MAPBOX_TOKEN=
+
+# PocketID passkey authentication — leave blank to use local auth only
+# See: https://github.com/pocket-id/pocket-id
+POCKETID_ISSUER=
+POCKETID_CLIENT_ID=
+POCKETID_CLIENT_SECRET=
diff --git a/milevault_export/.gitea/workflows/build.yml b/milevault_export/.gitea/workflows/build.yml
new file mode 100644
index 0000000..471bbe1
--- /dev/null
+++ b/milevault_export/.gitea/workflows/build.yml
@@ -0,0 +1,83 @@
+name: Build and push images
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch: # allow manual trigger from Gitea UI
+
+env:
+ REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
+ OWNER: ${{ gitea.repository_owner }}
+
+jobs:
+ build-backend:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.PACKAGE_TOKEN }}
+
+ - name: Build and push backend
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
+
+ build-worker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.PACKAGE_TOKEN }}
+
+ - name: Build and push worker
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile.worker
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
+
+ build-frontend:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.PACKAGE_TOKEN }}
+
+ - name: Build and push frontend
+ uses: docker/build-push-action@v5
+ with:
+ context: ./frontend
+ file: ./frontend/Dockerfile
+ push: true
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
+ ${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
+ build-args: |
+ VITE_API_URL=/api
+ VITE_MAPBOX_TOKEN=
diff --git a/milevault_export/.gitignore b/milevault_export/.gitignore
new file mode 100644
index 0000000..5d2285f
--- /dev/null
+++ b/milevault_export/.gitignore
@@ -0,0 +1,12 @@
+.env
+node_modules/
+__pycache__/
+*.pyc
+*.egg-info/
+dist/
+build/
+.DS_Store
+*.sql.bak
+db_data/
+redis_data/
+file_data/
diff --git a/milevault_export/README.md b/milevault_export/README.md
new file mode 100644
index 0000000..571a011
--- /dev/null
+++ b/milevault_export/README.md
@@ -0,0 +1,153 @@
+# MileVault
+
+Self-hosted fitness tracking — Garmin & Strava import, maps, health trends, personal records.
+
+---
+
+## For users — deploy with two files
+
+Once this repo is pushed to Gitea and the Actions workflow has run once, anyone on your network only needs **two files** to run MileVault. No source code, no cloning.
+
+```bash
+mkdir milevault && cd milevault
+
+# Download the two deployment files
+curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/docker-compose.deploy.yml
+curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/nginx.conf
+
+# Start (images pulled automatically from your Gitea registry)
+docker compose -f docker-compose.deploy.yml up -d
+```
+
+Default login: `admin` / `admin`
+**Change `ADMIN_PASSWORD` in a `.env` file before exposing to a network** (see Configuration below).
+
+To update when a new version is pushed to Gitea:
+```bash
+docker compose -f docker-compose.deploy.yml pull
+docker compose -f docker-compose.deploy.yml up -d
+```
+
+---
+
+## For developers — first-time Gitea setup
+
+### 1. Enable the Gitea container registry
+
+In your Gitea instance (`app.ini` or admin panel):
+
+```ini
+[packages]
+ENABLED = true
+```
+
+Restart Gitea. The registry is then available at `gitea.yourdomain.com`.
+
+### 2. Create a Gitea Actions runner
+
+Gitea Actions needs a runner on your server:
+
+```bash
+# On the server that will build images
+docker run -d \
+ --name gitea-runner \
+ --restart always \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v gitea-runner-data:/data \
+ -e GITEA_INSTANCE_URL=https://gitea.yourdomain.com \
+ -e GITEA_RUNNER_REGISTRATION_TOKEN=
\
+ gitea/act_runner:latest
+```
+
+Get the registration token from: **Gitea → Your repo → Settings → Actions → Runners → Create Runner**
+
+### 3. Create a package token
+
+The workflow needs a token to push images to the registry:
+
+1. Gitea → Your profile → **Settings → Applications → Generate Token**
+2. Scopes: tick **`write:package`**
+3. Copy the token
+
+Then in your repo: **Settings → Secrets → Actions → Add Secret**
+- Name: `PACKAGE_TOKEN`
+- Value: the token you just copied
+
+### 4. Set the registry URL variable
+
+In your repo: **Settings → Variables → Actions → Add Variable**
+- Name: `GITEA_URL`
+- Value: `gitea.yourdomain.com` (no `https://`)
+
+### 5. Push the repo
+
+```bash
+git remote add origin https://gitea.yourdomain.com/yourusername/milevault.git
+git push -u origin main
+```
+
+The Actions workflow (`.gitea/workflows/build.yml`) triggers automatically, builds all three images, and pushes them to your Gitea registry. Check progress under **Actions** in the Gitea UI.
+
+### 6. Update docker-compose.deploy.yml
+
+Before the first deploy, replace the placeholder registry URLs in `docker-compose.deploy.yml`:
+
+```
+gitea.yourdomain.com/yourusername/ → your actual Gitea host and username
+```
+
+---
+
+## Configuration
+
+Create a `.env` file next to `docker-compose.deploy.yml` to override any defaults:
+
+```env
+# Admin login
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=a_strong_password_here
+
+# Generate with: openssl rand -hex 32
+SECRET_KEY=
+
+# Ports
+HTTP_PORT=80
+
+# Optional: Mapbox token for satellite tiles
+VITE_MAPBOX_TOKEN=
+
+# Optional: PocketID passkey auth
+POCKETID_ISSUER=
+POCKETID_CLIENT_ID=
+POCKETID_CLIENT_SECRET=
+```
+
+Docker Compose picks up `.env` automatically.
+
+---
+
+## If your Gitea registry requires authentication to pull
+
+If your Gitea instance is private, add a pull secret on the deploy machine:
+
+```bash
+docker login gitea.yourdomain.com
+# enter your Gitea username and password (or a read:package token)
+```
+
+Docker stores the credentials in `~/.docker/config.json` and uses them automatically on `docker compose pull`.
+
+---
+
+## Repo structure
+
+```
+.gitea/workflows/build.yml ← Gitea Actions: builds & pushes images on push to main
+docker-compose.yml ← dev/build compose (builds from source)
+docker-compose.deploy.yml ← production compose (pulls pre-built images)
+nginx.conf ← standalone nginx config for deploy compose
+backend/ ← FastAPI + Celery worker
+frontend/ ← React + Vite
+nginx/nginx.conf ← nginx config for dev compose
+docker/init.sql ← DB init (enables TimescaleDB extension)
+```
diff --git a/milevault_export/backend/Dockerfile b/milevault_export/backend/Dockerfile
new file mode 100644
index 0000000..28e00d0
--- /dev/null
+++ b/milevault_export/backend/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Single worker avoids race condition during DB initialization.
+# For a personal app this is fine; async handles concurrent requests well.
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/milevault_export/backend/Dockerfile.worker b/milevault_export/backend/Dockerfile.worker
new file mode 100644
index 0000000..a38f3ab
--- /dev/null
+++ b/milevault_export/backend/Dockerfile.worker
@@ -0,0 +1,14 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
diff --git a/milevault_export/backend/app/__init__.py b/milevault_export/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/api/__init__.py b/milevault_export/backend/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/api/activities.py b/milevault_export/backend/app/api/activities.py
new file mode 100644
index 0000000..0412448
--- /dev/null
+++ b/milevault_export/backend/app/api/activities.py
@@ -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()
diff --git a/milevault_export/backend/app/api/auth.py b/milevault_export/backend/app/api/auth.py
new file mode 100644
index 0000000..c4536ae
--- /dev/null
+++ b/milevault_export/backend/app/api/auth.py
@@ -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}")
diff --git a/milevault_export/backend/app/api/health.py b/milevault_export/backend/app/api/health.py
new file mode 100644
index 0000000..0bf0f35
--- /dev/null
+++ b/milevault_export/backend/app/api/health.py
@@ -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"}
diff --git a/milevault_export/backend/app/api/profile.py b/milevault_export/backend/app/api/profile.py
new file mode 100644
index 0000000..bcd2fdd
--- /dev/null
+++ b/milevault_export/backend/app/api/profile.py
@@ -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 100–250")
+ 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 20–120")
+ 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 50–300 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 20–500 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()
diff --git a/milevault_export/backend/app/api/records.py b/milevault_export/backend/app/api/records.py
new file mode 100644
index 0000000..fdee950
--- /dev/null
+++ b/milevault_export/backend/app/api/records.py
@@ -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()
diff --git a/milevault_export/backend/app/api/routes.py b/milevault_export/backend/app/api/routes.py
new file mode 100644
index 0000000..ce7c8f1
--- /dev/null
+++ b/milevault_export/backend/app/api/routes.py
@@ -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
diff --git a/milevault_export/backend/app/api/upload.py b/milevault_export/backend/app/api/upload.py
new file mode 100644
index 0000000..2cfbd37
--- /dev/null
+++ b/milevault_export/backend/app/api/upload.py
@@ -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,
+ }
diff --git a/milevault_export/backend/app/core/__init__.py b/milevault_export/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/core/config.py b/milevault_export/backend/app/core/config.py
new file mode 100644
index 0000000..71778e8
--- /dev/null
+++ b/milevault_export/backend/app/core/config.py
@@ -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()
diff --git a/milevault_export/backend/app/core/database.py b/milevault_export/backend/app/core/database.py
new file mode 100644
index 0000000..cd4242b
--- /dev/null
+++ b/milevault_export/backend/app/core/database.py
@@ -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()
diff --git a/milevault_export/backend/app/core/security.py b/milevault_export/backend/app/core/security.py
new file mode 100644
index 0000000..0bff6ac
--- /dev/null
+++ b/milevault_export/backend/app/core/security.py
@@ -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
diff --git a/milevault_export/backend/app/main.py b/milevault_export/backend/app/main.py
new file mode 100644
index 0000000..c1a9204
--- /dev/null
+++ b/milevault_export/backend/app/main.py
@@ -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"}
diff --git a/milevault_export/backend/app/models/__init__.py b/milevault_export/backend/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/models/user.py b/milevault_export/backend/app/models/user.py
new file mode 100644
index 0000000..8275b29
--- /dev/null
+++ b/milevault_export/backend/app/models/user.py
@@ -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")
diff --git a/milevault_export/backend/app/services/__init__.py b/milevault_export/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/services/fit_parser.py b/milevault_export/backend/app/services/fit_parser.py
new file mode 100644
index 0000000..13ac510
--- /dev/null
+++ b/milevault_export/backend/app/services/fit_parser.py
@@ -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 {}
diff --git a/milevault_export/backend/app/services/route_matcher.py b/milevault_export/backend/app/services/route_matcher.py
new file mode 100644
index 0000000..99a46af
--- /dev/null
+++ b/milevault_export/backend/app/services/route_matcher.py
@@ -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
diff --git a/milevault_export/backend/app/services/wellness_parser.py b/milevault_export/backend/app/services/wellness_parser.py
new file mode 100644
index 0000000..6352429
--- /dev/null
+++ b/milevault_export/backend/app/services/wellness_parser.py
@@ -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}
diff --git a/milevault_export/backend/app/workers/__init__.py b/milevault_export/backend/app/workers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/milevault_export/backend/app/workers/celery_app.py b/milevault_export/backend/app/workers/celery_app.py
new file mode 100644
index 0000000..451d397
--- /dev/null
+++ b/milevault_export/backend/app/workers/celery_app.py
@@ -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"]
diff --git a/milevault_export/backend/app/workers/tasks.py b/milevault_export/backend/app/workers/tasks.py
new file mode 100644
index 0000000..5648dd3
--- /dev/null
+++ b/milevault_export/backend/app/workers/tasks.py
@@ -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()
diff --git a/milevault_export/backend/requirements.txt b/milevault_export/backend/requirements.txt
new file mode 100644
index 0000000..eed09f5
--- /dev/null
+++ b/milevault_export/backend/requirements.txt
@@ -0,0 +1,26 @@
+fastapi==0.111.0
+uvicorn[standard]==0.30.0
+sqlalchemy[asyncio]==2.0.30
+asyncpg==0.29.0
+alembic==1.13.1
+pydantic==2.7.1
+pydantic-settings==2.2.1
+python-jose[cryptography]==3.3.0
+passlib==1.7.4
+bcrypt==4.0.1
+python-multipart==0.0.9
+httpx==0.27.0
+redis[hiredis]==5.0.4
+celery[redis]==5.4.0
+garmin-fit-sdk==21.195.0
+fitparse==1.2.0
+gpxpy==1.6.2
+numpy==1.26.4
+scipy==1.13.0
+geopy==2.4.1
+polyline==2.0.2
+Pillow==10.3.0
+aiofiles==23.2.1
+python-dateutil==2.9.0
+pytz==2024.1
+psycopg2-binary==2.9.9
diff --git a/milevault_export/docker-compose.deploy.yml b/milevault_export/docker-compose.deploy.yml
new file mode 100644
index 0000000..3ee1378
--- /dev/null
+++ b/milevault_export/docker-compose.deploy.yml
@@ -0,0 +1,114 @@
+version: "3.9"
+
+# MileVault — standalone deployment
+#
+# 1. Copy this file somewhere on your server (no other files needed)
+# 2. Run: docker compose up -d
+# 3. Visit http://localhost
+#
+# Images are pulled from your Gitea container registry automatically.
+# To update to the latest build: docker compose pull && docker compose up -d
+
+# ── Replace these with your actual Gitea host and username ───────────────────
+x-registry: ®istry gitea.yourdomain.com/yourusername
+# ─────────────────────────────────────────────────────────────────────────────
+
+services:
+ db:
+ image: timescale/timescaledb:latest-pg16
+ container_name: milevault_db
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: milevault
+ POSTGRES_USER: ${DB_USER:-milevault}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+
+ redis:
+ image: redis:7-alpine
+ container_name: milevault_redis
+ restart: unless-stopped
+ command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
+ backend:
+ image: gitea.yourdomain.com/yourusername/milevault-backend:latest
+ container_name: milevault_backend
+ restart: unless-stopped
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
+ REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
+ SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
+ ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+ ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
+ POCKETID_ISSUER: ${POCKETID_ISSUER:-}
+ POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
+ POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
+ FILE_STORE_PATH: /data/files
+ ENVIRONMENT: production
+ volumes:
+ - file_data:/data/files
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 15s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+
+ worker:
+ image: gitea.yourdomain.com/yourusername/milevault-worker:latest
+ container_name: milevault_worker
+ restart: unless-stopped
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
+ REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
+ SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
+ FILE_STORE_PATH: /data/files
+ volumes:
+ - file_data:/data/files
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ frontend:
+ image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
+ container_name: milevault_frontend
+ restart: unless-stopped
+
+ nginx:
+ image: nginx:alpine
+ container_name: milevault_nginx
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ - backend
+ - frontend
+
+volumes:
+ db_data:
+ redis_data:
+ file_data:
diff --git a/milevault_export/docker-compose.yml b/milevault_export/docker-compose.yml
new file mode 100644
index 0000000..96cfa88
--- /dev/null
+++ b/milevault_export/docker-compose.yml
@@ -0,0 +1,111 @@
+version: "3.9"
+
+services:
+ db:
+ image: timescale/timescaledb:latest-pg16
+ container_name: milevault_db
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: milevault
+ POSTGRES_USER: ${DB_USER:-milevault}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+
+ redis:
+ image: redis:7-alpine
+ container_name: milevault_redis
+ restart: unless-stopped
+ command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: milevault_backend
+ restart: unless-stopped
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
+ REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
+ SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
+ ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+ ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
+ POCKETID_ISSUER: ${POCKETID_ISSUER:-}
+ POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
+ POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
+ FILE_STORE_PATH: /data/files
+ ENVIRONMENT: ${ENVIRONMENT:-production}
+ volumes:
+ - file_data:/data/files
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 15s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+
+ worker:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile.worker
+ container_name: milevault_worker
+ restart: unless-stopped
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
+ REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
+ SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
+ FILE_STORE_PATH: /data/files
+ volumes:
+ - file_data:/data/files
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ args:
+ VITE_API_URL: ${VITE_API_URL:-/api}
+ VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN:-}
+ container_name: milevault_frontend
+ restart: unless-stopped
+
+ nginx:
+ image: nginx:alpine
+ container_name: milevault_nginx
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ - backend
+ - frontend
+
+volumes:
+ db_data:
+ redis_data:
+ file_data:
diff --git a/milevault_export/docker/init.sql b/milevault_export/docker/init.sql
new file mode 100644
index 0000000..ee70c0a
--- /dev/null
+++ b/milevault_export/docker/init.sql
@@ -0,0 +1,7 @@
+-- Enable TimescaleDB extension
+CREATE EXTENSION IF NOT EXISTS timescaledb;
+CREATE EXTENSION IF NOT EXISTS postgis;
+
+-- Activity data points will use TimescaleDB hypertable for efficient
+-- time-series queries on HR, cadence, power, temperature, etc.
+-- Tables are created by Alembic migrations; this just ensures extensions exist.
diff --git a/milevault_export/frontend/Dockerfile b/milevault_export/frontend/Dockerfile
new file mode 100644
index 0000000..7649402
--- /dev/null
+++ b/milevault_export/frontend/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+
+COPY . .
+ARG VITE_API_URL=/api
+ARG VITE_MAPBOX_TOKEN=
+ENV VITE_API_URL=$VITE_API_URL
+ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
+
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/milevault_export/frontend/index.html b/milevault_export/frontend/index.html
new file mode 100644
index 0000000..1a47e01
--- /dev/null
+++ b/milevault_export/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ MileVault
+
+
+
+
+
+
+
diff --git a/milevault_export/frontend/nginx-spa.conf b/milevault_export/frontend/nginx-spa.conf
new file mode 100644
index 0000000..55f7e0a
--- /dev/null
+++ b/milevault_export/frontend/nginx-spa.conf
@@ -0,0 +1,14 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
diff --git a/milevault_export/frontend/package.json b/milevault_export/frontend/package.json
new file mode 100644
index 0000000..60487d3
--- /dev/null
+++ b/milevault_export/frontend/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "milevault-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.23.1",
+ "leaflet": "^1.9.4",
+ "react-leaflet": "^4.2.1",
+ "recharts": "^2.12.7",
+ "date-fns": "^3.6.0",
+ "clsx": "^2.1.1",
+ "zustand": "^4.5.2",
+ "@tanstack/react-query": "^5.40.0",
+ "axios": "^1.7.2",
+ "react-dropzone": "^14.2.3",
+ "@polyline-codec/core": "^2.0.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.2.13",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4"
+ }
+}
diff --git a/milevault_export/frontend/postcss.config.js b/milevault_export/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/milevault_export/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/milevault_export/frontend/src/App.jsx b/milevault_export/frontend/src/App.jsx
new file mode 100644
index 0000000..cb1a600
--- /dev/null
+++ b/milevault_export/frontend/src/App.jsx
@@ -0,0 +1,53 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { useEffect } from 'react'
+import { useAuthStore } from './hooks/useAuth'
+import Layout from './components/ui/Layout'
+import LoginPage from './pages/LoginPage'
+import DashboardPage from './pages/DashboardPage'
+import ActivitiesPage from './pages/ActivitiesPage'
+import ActivityDetailPage from './pages/ActivityDetailPage'
+import HealthPage from './pages/HealthPage'
+import RoutesPage from './pages/RoutesPage'
+import RecordsPage from './pages/RecordsPage'
+import UploadPage from './pages/UploadPage'
+import ProfilePage from './pages/ProfilePage'
+
+function RequireAuth({ children }) {
+ const token = useAuthStore((s) => s.token)
+ if (!token) return
+ return children
+}
+
+export default function App() {
+ const { token, fetchUser } = useAuthStore()
+
+ useEffect(() => {
+ if (token) fetchUser()
+ }, [token])
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search)
+ const urlToken = params.get('token')
+ if (urlToken) {
+ localStorage.setItem('token', urlToken)
+ useAuthStore.setState({ token: urlToken })
+ window.history.replaceState({}, '', '/')
+ }
+ }, [])
+
+ return (
+
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/components/activity/ActivityMap.jsx b/milevault_export/frontend/src/components/activity/ActivityMap.jsx
new file mode 100644
index 0000000..0fe01c5
--- /dev/null
+++ b/milevault_export/frontend/src/components/activity/ActivityMap.jsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef } from 'react'
+import L from 'leaflet'
+import { sportColor } from '../../utils/format'
+
+delete L.Icon.Default.prototype._getIconUrl
+L.Icon.Default.mergeOptions({
+ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
+ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
+ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
+})
+
+const TILE_LAYERS = {
+ dark: {
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ attribution: '© OSM © CARTO ',
+ },
+ street: {
+ url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
+ attribution: '© OSM © CARTO ',
+ },
+ satellite: {
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ attribution: '© Esri ',
+ },
+}
+
+function decodePolyline(encoded) {
+ const coords = []
+ let index = 0, lat = 0, lng = 0
+ while (index < encoded.length) {
+ let b, shift = 0, result = 0
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
+ lat += (result & 1) ? ~(result >> 1) : result >> 1
+ shift = 0; result = 0
+ do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
+ lng += (result & 1) ? ~(result >> 1) : result >> 1
+ coords.push([lat / 1e5, lng / 1e5])
+ }
+ return coords
+}
+
+export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
+ const mapRef = useRef(null)
+ const mapInstanceRef = useRef(null)
+ const markerRef = useRef(null)
+ const trackRef = useRef(null)
+ const tileLayerRef = useRef(null)
+
+ useEffect(() => {
+ if (!mapRef.current || mapInstanceRef.current) return
+ mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
+ const tile = TILE_LAYERS['dark']
+ tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
+ .addTo(mapInstanceRef.current)
+ return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
+ }, [])
+
+ // Switch tile layer when mapType changes
+ useEffect(() => {
+ if (!mapInstanceRef.current) return
+ const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
+ if (tileLayerRef.current) {
+ tileLayerRef.current.remove()
+ }
+ tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
+ .addTo(mapInstanceRef.current)
+ }, [mapType])
+
+ // Draw route
+ useEffect(() => {
+ if (!mapInstanceRef.current || !polyline) return
+ if (trackRef.current) trackRef.current.remove()
+ const coords = decodePolyline(polyline)
+ if (!coords.length) return
+ trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
+ .addTo(mapInstanceRef.current)
+ mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
+ if (coords.length > 0) {
+ const dot = (color) => L.divIcon({
+ html: `
`,
+ iconSize: [12, 12], iconAnchor: [6, 6], className: '',
+ })
+ L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
+ L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
+ }
+ }, [polyline, sportType])
+
+ // Position marker on timeline hover
+ useEffect(() => {
+ if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
+ const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
+ if (!point?.latitude || !point?.longitude) return
+ if (markerRef.current) {
+ markerRef.current.setLatLng([point.latitude, point.longitude])
+ } else {
+ const icon = L.divIcon({
+ html: '
',
+ iconSize: [14, 14], iconAnchor: [7, 7], className: '',
+ })
+ markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
+ }
+ }, [hoveredDistance, dataPoints])
+
+ return
+}
diff --git a/milevault_export/frontend/src/components/activity/HRZoneBar.jsx b/milevault_export/frontend/src/components/activity/HRZoneBar.jsx
new file mode 100644
index 0000000..ce795e5
--- /dev/null
+++ b/milevault_export/frontend/src/components/activity/HRZoneBar.jsx
@@ -0,0 +1,43 @@
+const ZONE_CONFIG = [
+ { key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
+ { key: 'z2', label: 'Z2 Base', color: '#34d399' },
+ { key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' },
+ { key: 'z4', label: 'Z4 Threshold', color: '#f97316' },
+ { key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
+]
+
+export default function HRZoneBar({ zones }) {
+ return (
+
+ {/* Stacked bar */}
+
+ {ZONE_CONFIG.map(({ key, color }) => {
+ const pct = zones[key] || 0
+ if (pct < 0.5) return null
+ return (
+
+ )
+ })}
+
+
+ {/* Legend */}
+
+ {ZONE_CONFIG.map(({ key, label, color }) => {
+ const pct = zones[key] || 0
+ return (
+
+ )
+ })}
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/components/activity/LapTable.jsx b/milevault_export/frontend/src/components/activity/LapTable.jsx
new file mode 100644
index 0000000..bdc0df6
--- /dev/null
+++ b/milevault_export/frontend/src/components/activity/LapTable.jsx
@@ -0,0 +1,40 @@
+import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
+
+export default function LapTable({ laps, sportType }) {
+ return (
+
+
+
+
+ Lap
+ Distance
+ Time
+ Pace
+ Avg HR
+ Cadence
+ Power
+
+
+
+ {laps.map((lap) => (
+
+ {lap.lap_number}
+ {formatDistance(lap.distance_m)}
+ {formatDuration(lap.duration_s)}
+ {formatPace(lap.avg_speed_ms, sportType)}
+
+ {formatHeartRate(lap.avg_heart_rate)}
+
+
+ {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
+
+
+ {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/components/activity/MetricTimeline.jsx b/milevault_export/frontend/src/components/activity/MetricTimeline.jsx
new file mode 100644
index 0000000..6b2b35d
--- /dev/null
+++ b/milevault_export/frontend/src/components/activity/MetricTimeline.jsx
@@ -0,0 +1,147 @@
+import { useMemo } from 'react'
+import {
+ ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
+ ResponsiveContainer,
+} from 'recharts'
+import { formatPace, formatCadence } from '../../utils/format'
+
+function downsample(points, maxPoints = 500) {
+ if (points.length <= maxPoints) return points
+ const step = Math.ceil(points.length / maxPoints)
+ return points.filter((_, i) => i % step === 0)
+}
+
+function buildChartData(dataPoints, activeMetrics) {
+ return dataPoints
+ .filter(p => p.timestamp)
+ .map(p => {
+ const row = { distance_m: p.distance_m ?? 0 }
+ for (const key of activeMetrics) {
+ row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
+ }
+ return row
+ })
+}
+
+const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
+ if (!active || !payload?.length) return null
+ if (onHover) onHover(label)
+ return (
+
+
{(label / 1000).toFixed(2)} km
+ {payload.map(entry => {
+ const metric = metrics.find(m => m.key === entry.dataKey)
+ if (!metric || entry.value == null) return null
+ let display = entry.value.toFixed(1)
+ if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
+ else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
+ else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
+ else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
+ else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
+ else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
+ return (
+
+ ●
+ {metric.label}:
+ {display}
+
+ )
+ })}
+
+ )
+}
+
+export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
+ const chartData = useMemo(() =>
+ downsample(buildChartData(dataPoints, activeMetrics)),
+ [dataPoints, activeMetrics]
+ )
+
+ const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
+
+ const domains = useMemo(() => {
+ const result = {}
+ for (const m of activeMetricConfigs) {
+ const vals = chartData.map(p => p[m.key]).filter(v => v != null)
+ if (!vals.length) continue
+ const min = Math.min(...vals)
+ const max = Math.max(...vals)
+ const pad = (max - min) * 0.1 || 1
+ // For elevation, don't start from 0 - show actual range
+ result[m.key] = [min - pad, max + pad]
+ }
+ return result
+ }, [chartData, activeMetricConfigs])
+
+ if (!chartData.length) {
+ return (
+
+ No timeline data available
+
+ )
+ }
+
+ return (
+
+ {activeMetricConfigs.map((metric, idx) => {
+ const domain = domains[metric.key] || ['auto', 'auto']
+ const hasData = chartData.some(p => p[metric.key] != null)
+ if (!hasData) return null
+
+ return (
+
+
+ {metric.label}
+ {metric.unit && ({metric.unit}) }
+
+
+
+
+ `${(v / 1000).toFixed(1)}`}
+ tick={{ fontSize: 10, fill: '#6b7280' }}
+ axisLine={false}
+ tickLine={false}
+ hide={idx < activeMetricConfigs.length - 1}
+ />
+ {
+ if (metric.key === 'speed_ms') {
+ if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
+ const spm = 1000 / v
+ return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
+ }
+ if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
+ return Math.round(v)
+ }}
+ />
+ }
+ isAnimationActive={false}
+ />
+
+
+
+
+ )
+ })}
+
Distance (km)
+
+ )
+}
diff --git a/milevault_export/frontend/src/components/ui/Layout.jsx b/milevault_export/frontend/src/components/ui/Layout.jsx
new file mode 100644
index 0000000..881172f
--- /dev/null
+++ b/milevault_export/frontend/src/components/ui/Layout.jsx
@@ -0,0 +1,62 @@
+import { Outlet, NavLink, useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../../hooks/useAuth'
+
+const nav = [
+ { to: '/', label: 'Dashboard', icon: '📊', exact: true },
+ { to: '/activities', label: 'Activities', icon: '🏃' },
+ { to: '/health', label: 'Health', icon: '❤️' },
+ { to: '/routes', label: 'Routes', icon: '🗺️' },
+ { to: '/records', label: 'Records', icon: '🏆' },
+ { to: '/upload', label: 'Import', icon: '⬆️' },
+ { to: '/profile', label: 'Profile', icon: '⚙️' },
+]
+
+export default function Layout() {
+ const { user, logout } = useAuthStore()
+ const navigate = useNavigate()
+
+ const handleLogout = () => {
+ logout()
+ navigate('/login')
+ }
+
+ return (
+
+ )
+}
diff --git a/milevault_export/frontend/src/components/ui/StatCard.jsx b/milevault_export/frontend/src/components/ui/StatCard.jsx
new file mode 100644
index 0000000..927dfd5
--- /dev/null
+++ b/milevault_export/frontend/src/components/ui/StatCard.jsx
@@ -0,0 +1,18 @@
+const accentColors = {
+ default: 'text-white',
+ red: 'text-red-400',
+ blue: 'text-blue-400',
+ green: 'text-green-400',
+ orange: 'text-orange-400',
+ purple: 'text-purple-400',
+}
+
+export default function StatCard({ label, value, accent = 'default', sub }) {
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
diff --git a/milevault_export/frontend/src/hooks/useAuth.js b/milevault_export/frontend/src/hooks/useAuth.js
new file mode 100644
index 0000000..2a01804
--- /dev/null
+++ b/milevault_export/frontend/src/hooks/useAuth.js
@@ -0,0 +1,41 @@
+import { create } from 'zustand'
+import api from '../utils/api'
+
+export const useAuthStore = create((set) => ({
+ token: localStorage.getItem('token'),
+ user: null,
+ isLoading: false,
+
+ login: async (username, password) => {
+ set({ isLoading: true })
+ try {
+ const params = new URLSearchParams()
+ params.append('username', username)
+ params.append('password', password)
+ const { data } = await api.post('/auth/token', params, {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ })
+ localStorage.setItem('token', data.access_token)
+ set({ token: data.access_token, user: data, isLoading: false })
+ return true
+ } catch (e) {
+ set({ isLoading: false })
+ throw e
+ }
+ },
+
+ logout: () => {
+ localStorage.removeItem('token')
+ set({ token: null, user: null })
+ },
+
+ fetchUser: async () => {
+ try {
+ const { data } = await api.get('/auth/me')
+ set({ user: data })
+ } catch {
+ set({ token: null, user: null })
+ localStorage.removeItem('token')
+ }
+ },
+}))
diff --git a/milevault_export/frontend/src/index.css b/milevault_export/frontend/src/index.css
new file mode 100644
index 0000000..61398fa
--- /dev/null
+++ b/milevault_export/frontend/src/index.css
@@ -0,0 +1,33 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply bg-gray-950 text-gray-100 antialiased;
+ font-family: system-ui, -apple-system, sans-serif;
+ }
+}
+
+/* Leaflet dark mode fixes */
+.leaflet-container {
+ background: #1a1a2e;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
+
+/* HR zone colours */
+.zone-1 { color: #60a5fa; }
+.zone-2 { color: #34d399; }
+.zone-3 { color: #fbbf24; }
+.zone-4 { color: #f97316; }
+.zone-5 { color: #f43f5e; }
+
+.zone-bg-1 { background-color: #1e3a5f; }
+.zone-bg-2 { background-color: #065f46; }
+.zone-bg-3 { background-color: #78350f; }
+.zone-bg-4 { background-color: #7c2d12; }
+.zone-bg-5 { background-color: #881337; }
diff --git a/milevault_export/frontend/src/main.jsx b/milevault_export/frontend/src/main.jsx
new file mode 100644
index 0000000..2f6b453
--- /dev/null
+++ b/milevault_export/frontend/src/main.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import App from './App'
+import './index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 60_000, retry: 1 },
+ },
+})
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+
+
+)
diff --git a/milevault_export/frontend/src/pages/ActivitiesPage.jsx b/milevault_export/frontend/src/pages/ActivitiesPage.jsx
new file mode 100644
index 0000000..67efcf0
--- /dev/null
+++ b/milevault_export/frontend/src/pages/ActivitiesPage.jsx
@@ -0,0 +1,147 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import api from '../utils/api'
+import {
+ formatDuration, formatDistance, formatPace, formatHeartRate,
+ formatDate, sportIcon, sportColor,
+} from '../utils/format'
+
+const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
+
+export default function ActivitiesPage() {
+ const [sport, setSport] = useState('all')
+ const [page, setPage] = useState(1)
+
+ const { data: activities, isLoading } = useQuery({
+ queryKey: ['activities', sport, page],
+ queryFn: () =>
+ api.get('/activities/', {
+ params: {
+ sport_type: sport === 'all' ? undefined : sport,
+ page,
+ per_page: 20,
+ },
+ }).then(r => r.data),
+ })
+
+ return (
+
+
+
Activities
+
+ + Import
+
+
+
+ {/* Sport filter */}
+
+ {SPORTS.map(s => (
+ { setSport(s); setPage(1) }}
+ className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
+ sport === s
+ ? 'bg-blue-600 border-blue-600 text-white'
+ : 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
+ }`}
+ >
+ {s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
+
+ ))}
+
+
+ {/* Activity list */}
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ {activities?.map(activity => (
+
+ {/* Sport indicator */}
+
+ {sportIcon(activity.sport_type)}
+
+
+ {/* Name + date */}
+
+
+ {activity.name}
+
+
{formatDate(activity.start_time)}
+
+
+ {/* Metrics */}
+
+
+
{formatDistance(activity.distance_m)}
+
distance
+
+
+
{formatDuration(activity.duration_s)}
+
time
+
+
+
{formatPace(activity.avg_speed_ms, activity.sport_type)}
+
pace
+
+
+
{formatHeartRate(activity.avg_heart_rate)}
+
avg HR
+
+
+
+ {activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'}
+
+
elev
+
+
+
+
›
+
+ ))}
+
+ {activities?.length === 0 && (
+
+
🏃
+
No activities yet
+
+ Import your Garmin or Strava data to get started
+
+
+ )}
+
+ )}
+
+ {/* Pagination */}
+ {activities?.length === 20 && (
+
+ setPage(p => Math.max(1, p - 1))}
+ disabled={page === 1}
+ className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg disabled:opacity-30 hover:bg-gray-700 transition-colors"
+ >
+ ← Previous
+
+ Page {page}
+ setPage(p => p + 1)}
+ className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors"
+ >
+ Next →
+
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/ActivityDetailPage.jsx b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx
new file mode 100644
index 0000000..e8c92ea
--- /dev/null
+++ b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx
@@ -0,0 +1,197 @@
+import { useParams } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { useState, useMemo } from 'react'
+import api from '../utils/api'
+import ActivityMap from '../components/activity/ActivityMap'
+import MetricTimeline from '../components/activity/MetricTimeline'
+import HRZoneBar from '../components/activity/HRZoneBar'
+import LapTable from '../components/activity/LapTable'
+import StatCard from '../components/ui/StatCard'
+import {
+ formatDuration, formatDistance, formatPace, formatElevation,
+ formatHeartRate, formatDateTime, formatCadence, sportIcon,
+} from '../utils/format'
+
+const METRICS = [
+ { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
+ { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
+ { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
+ { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
+ { key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
+ { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
+]
+
+export default function ActivityDetailPage() {
+ const { id } = useParams()
+ const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
+ const [hoveredDistance, setHoveredDistance] = useState(null)
+ const [mapHeight, setMapHeight] = useState(420)
+ const [mapType, setMapType] = useState('dark')
+
+ const { data: activity, isLoading } = useQuery({
+ queryKey: ['activity', id],
+ queryFn: () => api.get(`/activities/${id}`).then(r => r.data),
+ })
+
+ const { data: dataPoints } = useQuery({
+ queryKey: ['activity-points', id],
+ queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data),
+ enabled: !!activity,
+ })
+
+ const { data: laps } = useQuery({
+ queryKey: ['activity-laps', id],
+ queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data),
+ enabled: !!activity,
+ })
+
+ const toggleMetric = (key) => {
+ setActiveMetrics(prev =>
+ prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
+ )
+ }
+
+ // Check which metrics have actual data
+ const availableMetrics = useMemo(() => {
+ if (!dataPoints?.length) return new Set()
+ return new Set(
+ METRICS
+ .filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
+ .map(m => m.key)
+ )
+ }, [dataPoints])
+
+ if (isLoading) {
+ return
+ }
+ if (!activity) return null
+
+ return (
+
+ {/* Header */}
+
+
+
+ {sportIcon(activity.sport_type)}
+
{activity.name}
+
+
{formatDateTime(activity.start_time)}
+
+
+
+ {/* Primary stats */}
+
+
+
+
+
+
+
+
+
+ {/* Secondary stats */}
+
+
+
+
+
+
+
+
+
+ {/* Map with controls */}
+
+ {/* Map toolbar */}
+
+
+ Map style:
+ {['dark', 'street', 'satellite'].map(t => (
+ setMapType(t)}
+ className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
+ mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
+ }`}
+ >
+ {t}
+
+ ))}
+
+
+ Height:
+ {[280, 420, 560].map(h => (
+ setMapHeight(h)}
+ className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
+ mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
+ }`}
+ >
+ {h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
+
+ ))}
+
+
+
+
+
+ {/* HR Zones */}
+ {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
+
+
Heart Rate Zones
+
+
+ )}
+
+ {/* Metric timeline */}
+
+
+
Activity Timeline
+
+ {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
+ toggleMetric(key)}
+ className={`text-xs px-3 py-1 rounded-full border transition-colors ${
+ activeMetrics.includes(key)
+ ? 'border-transparent text-white'
+ : 'border-gray-700 text-gray-500 hover:text-gray-300'
+ }`}
+ style={activeMetrics.includes(key) ? { backgroundColor: color + '33', borderColor: color, color } : {}}
+ >
+ {label}
+
+ ))}
+
+
+ {dataPoints && dataPoints.length > 0 ? (
+
availableMetrics.has(m))}
+ metrics={METRICS}
+ onHoverDistance={setHoveredDistance}
+ sportType={activity.sport_type}
+ />
+ ) : (
+ No timeline data available for this activity
+ )}
+
+
+ {/* Laps */}
+ {laps && laps.length > 0 && (
+
+
Laps
+
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/DashboardPage.jsx b/milevault_export/frontend/src/pages/DashboardPage.jsx
new file mode 100644
index 0000000..8d302ed
--- /dev/null
+++ b/milevault_export/frontend/src/pages/DashboardPage.jsx
@@ -0,0 +1,171 @@
+import { Link } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
+import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
+import api from '../utils/api'
+import StatCard from '../components/ui/StatCard'
+import {
+ formatDuration, formatDistance, formatPace, formatHeartRate,
+ formatDate, sportIcon, formatSleep,
+} from '../utils/format'
+
+function WeeklyChart({ activities }) {
+ if (!activities?.length) return (
+ No activities yet
+ )
+
+ // Build last 8 weeks in chronological order
+ const now = new Date()
+ const weeks = eachWeekOfInterval({
+ start: subWeeks(startOfWeek(now), 7),
+ end: startOfWeek(now),
+ })
+
+ const data = weeks.map(weekStart => {
+ const weekKey = format(weekStart, 'MMM d')
+ const weekEnd = new Date(weekStart)
+ weekEnd.setDate(weekEnd.getDate() + 7)
+ const km = activities
+ .filter(a => {
+ const t = new Date(a.start_time)
+ return t >= weekStart && t < weekEnd
+ })
+ .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
+ return { week: weekKey, km: parseFloat(km.toFixed(2)) }
+ })
+
+ return (
+
+
+
+
+ `${v.toFixed(0)}`} />
+ [`${v.toFixed(1)} km`, 'Distance']} />
+
+
+
+ )
+}
+
+export default function DashboardPage() {
+ const { data: recentActivities } = useQuery({
+ queryKey: ['activities-recent'],
+ queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
+ })
+
+ const { data: allActivities } = useQuery({
+ queryKey: ['activities-all-chart'],
+ queryFn: () =>
+ api.get('/activities/', {
+ params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
+ }).then(r => r.data),
+ })
+
+ const { data: healthSummary } = useQuery({
+ queryKey: ['health-summary'],
+ queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
+ })
+
+ const { data: records } = useQuery({
+ queryKey: ['records-running'],
+ queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
+ })
+
+ const latest = healthSummary?.latest
+ const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
+
+ return (
+
+
+
Dashboard
+ + Import data
+
+
+
+
+
+
+
+
+
+
+
+
Weekly distance (km)
+
+
+
+
+
Health today
+ {latest ? (
+ <>
+ {[
+ ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
+ ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
+ ['Steps', latest.steps?.toLocaleString() ?? '--'],
+ ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
+ ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
+ ].map(([label, val]) => (
+
+ {label}
+ {val}
+
+ ))}
+
View full health dashboard →
+ >
+ ) : (
+
No health data. Import a Garmin export.
+ )}
+
+
+
+ {/* Recent activities */}
+
+
+
Recent activities
+ View all →
+
+
+ {recentActivities?.slice(0, 5).map(activity => (
+
+
{sportIcon(activity.sport_type)}
+
+
{activity.name}
+
{formatDate(activity.start_time)}
+
+
+
{formatDistance(activity.distance_m)}
dist
+
{formatDuration(activity.duration_s)}
time
+
{formatHeartRate(activity.avg_heart_rate)}
HR
+
+
+ ))}
+ {!recentActivities?.length && (
+
+ No activities yet — import some data
+
+ )}
+
+
+
+ {records?.length > 0 && (
+
+
+
Running PRs
+ View all →
+
+
+ {records.slice(0, 5).map(rec => (
+
+
{rec.distance_label}
+
{formatDuration(rec.duration_s)}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/HealthPage.jsx b/milevault_export/frontend/src/pages/HealthPage.jsx
new file mode 100644
index 0000000..6d91413
--- /dev/null
+++ b/milevault_export/frontend/src/pages/HealthPage.jsx
@@ -0,0 +1,213 @@
+import { useState, useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import {
+ LineChart, Line, AreaChart, Area, BarChart, Bar,
+ XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
+} from 'recharts'
+import { format, subDays } from 'date-fns'
+import api from '../utils/api'
+import StatCard from '../components/ui/StatCard'
+import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
+
+const RANGES = [
+ { label: '1W', days: 7 },
+ { label: '2W', days: 14 },
+ { label: '1M', days: 30 },
+ { label: '3M', days: 90 },
+ { label: '6M', days: 180 },
+ { label: '1Y', days: 365 },
+]
+
+const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
+
+function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
+ const vals = data.filter(d => d[dataKey] != null)
+ if (!vals.length) return (
+ No data
+ )
+ return (
+
+
+
+
+
+
+
+
+
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+
+ format(new Date(d), 'MMM d, yyyy')}
+ formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
+
+
+
+ )
+}
+
+function SleepChart({ data }) {
+ const chartData = data.map(d => ({
+ date: d.date,
+ deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
+ rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
+ light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
+ awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
+ }))
+ const hasData = chartData.some(d => d.deep || d.rem || d.light)
+ if (!hasData) return No sleep data
+ return (
+
+
+
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+ `${v}h`} />
+ format(new Date(d), 'MMM d, yyyy')} />
+
+
+
+
+
+
+ )
+}
+
+export default function HealthPage() {
+ const [rangeDays, setRangeDays] = useState(7) // default 1 week
+
+ const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
+
+ const { data: summary } = useQuery({
+ queryKey: ['health-summary'],
+ queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
+ })
+
+ const { data: metrics, isLoading } = useQuery({
+ queryKey: ['health-metrics', rangeDays],
+ queryFn: () =>
+ api.get('/health-metrics/', {
+ params: { from_date: fromDate, limit: rangeDays + 1 },
+ }).then(r => r.data.slice().reverse()), // oldest first for charts
+ keepPreviousData: true,
+ })
+
+ const latest = summary?.latest
+ const avg30 = summary?.avg_30d
+
+ return (
+
+
Health
+
+ {/* Summary cards */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Range selector */}
+
+ {RANGES.map(({ label, days }) => (
+ setRangeDays(days)}
+ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
+ rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
+ }`}>
+ {label}
+
+ ))}
+
+
+ {isLoading ? (
+
Loading…
+ ) : metrics && metrics.length > 0 ? (
+
+
+
+
Resting Heart Rate
+ `${Math.round(v)} bpm`} />
+
+
+
+
HRV (nightly avg)
+ `${Math.round(v)} ms`} />
+
+
+
+
Sleep Stages
+
+
+ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
+
+ ))}
+
+
+
+
+
Weight
+ `${v.toFixed(1)} kg`} />
+
+
+
+
VO2 Max
+ v.toFixed(1)} />
+
+
+
+
Daily Steps
+
+
+
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+ v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
+ format(new Date(d), 'MMM d, yyyy')} />
+
+
+
+
+
+
+
Avg Heart Rate (day)
+ `${Math.round(v)} bpm`} />
+
+
+
+
Stress Level
+ Math.round(v)} />
+
+
+
+ ) : (
+
+
📊
+
No health data for this period
+
Import a Garmin export or try a longer date range
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/LoginPage.jsx b/milevault_export/frontend/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..b566e7c
--- /dev/null
+++ b/milevault_export/frontend/src/pages/LoginPage.jsx
@@ -0,0 +1,102 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../hooks/useAuth'
+import { useQuery } from '@tanstack/react-query'
+import api from '../utils/api'
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const { login, isLoading } = useAuthStore()
+ const navigate = useNavigate()
+
+ const { data: pocketidData } = useQuery({
+ queryKey: ['pocketid-available'],
+ queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
+ })
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ try {
+ await login(username, password)
+ navigate('/')
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Login failed')
+ }
+ }
+
+ const handlePocketID = async () => {
+ const { data } = await api.get('/auth/pocketid/login-url')
+ window.location.href = data.url
+ }
+
+ return (
+
+
+
+
+ Mile Vault
+
+
Your personal fitness dashboard
+
+
+
+
+
+ {pocketidData?.available && (
+ <>
+
+
+ 🔑 Sign in with passkey
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/ProfilePage.jsx b/milevault_export/frontend/src/pages/ProfilePage.jsx
new file mode 100644
index 0000000..0b168b1
--- /dev/null
+++ b/milevault_export/frontend/src/pages/ProfilePage.jsx
@@ -0,0 +1,266 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import api from '../utils/api'
+import { useAuthStore } from '../hooks/useAuth'
+
+function Section({ title, children }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
+function Field({ label, hint, children }) {
+ return (
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
+
+function Input({ type = 'text', value, onChange, placeholder, min, max }) {
+ return (
+
+ )
+}
+
+function SaveButton({ onClick, loading, saved, label = 'Save' }) {
+ return (
+
+
+ {loading ? 'Saving…' : label}
+
+ {saved && ✓ Saved }
+
+ )
+}
+
+export default function ProfilePage() {
+ const qc = useQueryClient()
+ const { user } = useAuthStore()
+
+ const { data: profile } = useQuery({
+ queryKey: ['profile'],
+ queryFn: () => api.get('/profile/').then(r => r.data),
+ })
+
+ const { data: pocketidConfig } = useQuery({
+ queryKey: ['pocketid-config'],
+ queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
+ enabled: !!user?.is_admin,
+ })
+
+ // HR / measurements form
+ const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
+ const [hrSaved, setHrSaved] = useState(false)
+ useEffect(() => {
+ if (profile) setHrForm({
+ max_heart_rate: profile.max_heart_rate || '',
+ resting_heart_rate: profile.resting_heart_rate || '',
+ birth_year: profile.birth_year || '',
+ height_cm: profile.height_cm || '',
+ })
+ }, [profile])
+
+ const updateProfile = useMutation({
+ mutationFn: data => api.patch('/profile/', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
+ })
+
+ // Weight log
+ const { data: weightLog } = useQuery({
+ queryKey: ['weight-log'],
+ queryFn: () => api.get('/profile/weight').then(r => r.data),
+ })
+ const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
+ const [weightSaved, setWeightSaved] = useState(false)
+ const addWeight = useMutation({
+ mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
+ })
+ const deleteWeight = useMutation({
+ mutationFn: id => api.delete(`/profile/weight/${id}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
+ })
+
+ // Password change
+ const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
+ const [pwError, setPwError] = useState('')
+ const [pwSaved, setPwSaved] = useState(false)
+ const changePassword = useMutation({
+ mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
+ onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
+ onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
+ })
+
+ // PocketID config
+ const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
+ const [pidSaved, setPidSaved] = useState(false)
+ useEffect(() => {
+ if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
+ }, [pocketidConfig])
+ const savePocketID = useMutation({
+ mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
+ })
+
+ const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
+
+ return (
+
+
Profile & Settings
+
+ {/* HR & Measurements */}
+
+
+ Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
+ {effectiveMaxHr && (
+
+ Effective max HR: {effectiveMaxHr} bpm
+ {!profile?.max_heart_rate && ' (estimated from age)'}
+ {' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)}
+
+ )}
+
+
+
+
+ setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
+
+
+ setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
+
+
+
+ updateProfile.mutate(Object.fromEntries(
+ Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
+ ))}
+ loading={updateProfile.isPending}
+ saved={hrSaved}
+ />
+
+
+ {/* Weight log */}
+
+
+
+ setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
+
+
+ setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
+
+
+ setWeightForm(f => ({ ...f, date: e.target.value }))} />
+
+
+ addWeight.mutate({
+ weight_kg: parseFloat(weightForm.weight_kg),
+ body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
+ date: new Date(weightForm.date).toISOString(),
+ })}
+ loading={addWeight.isPending}
+ saved={weightSaved}
+ label="Log weight"
+ />
+
+ {weightLog && weightLog.length > 0 && (
+
+
Recent entries
+
+ {weightLog.slice(0, 20).map(entry => (
+
+ {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
+ {entry.weight_kg.toFixed(1)} kg
+ {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat }
+ deleteWeight.mutate(entry.id)}
+ className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕
+
+ ))}
+
+
+ )}
+
+
+ {/* Password change */}
+
+
+ {
+ if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
+ changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
+ }}
+ loading={changePassword.isPending}
+ saved={pwSaved}
+ label="Change password"
+ />
+
+
+ {/* PocketID — admin only */}
+ {user?.is_admin && (
+
+
+ Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
+
+
+ savePocketID.mutate(pidForm)}
+ loading={savePocketID.isPending}
+ saved={pidSaved}
+ label="Save PocketID config"
+ />
+
+ )}
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/RecordsPage.jsx b/milevault_export/frontend/src/pages/RecordsPage.jsx
new file mode 100644
index 0000000..98e4c2e
--- /dev/null
+++ b/milevault_export/frontend/src/pages/RecordsPage.jsx
@@ -0,0 +1,177 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Link } from 'react-router-dom'
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
+import { format } from 'date-fns'
+import api from '../utils/api'
+import { formatDuration, formatDate } from '../utils/format'
+
+const SPORTS = ['running', 'cycling', 'swimming']
+
+const DISTANCE_ORDER = [
+ '400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
+ 'Half marathon', 'Marathon', '50k', '100k',
+]
+
+export default function RecordsPage() {
+ const [sport, setSport] = useState('running')
+ const [selectedDistance, setSelectedDistance] = useState(null)
+
+ const { data: records } = useQuery({
+ queryKey: ['records', sport],
+ queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data),
+ })
+
+ const { data: history } = useQuery({
+ queryKey: ['record-history', selectedDistance, sport],
+ queryFn: () =>
+ api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, {
+ params: { sport_type: sport },
+ }).then(r => r.data),
+ enabled: !!selectedDistance,
+ })
+
+ // Sort by standard distance order
+ const sortedRecords = records?.slice().sort((a, b) => {
+ const ai = DISTANCE_ORDER.indexOf(a.distance_label)
+ const bi = DISTANCE_ORDER.indexOf(b.distance_label)
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
+ })
+
+ return (
+
+
Personal Records
+
+ {/* Sport selector */}
+
+ {SPORTS.map(s => (
+ { setSport(s); setSelectedDistance(null) }}
+ className={`capitalize text-sm px-4 py-1.5 rounded-full border transition-colors ${
+ sport === s
+ ? 'bg-blue-600 border-blue-600 text-white'
+ : 'border-gray-700 text-gray-400 hover:text-white'
+ }`}
+ >
+ {s}
+
+ ))}
+
+
+ {sortedRecords?.length === 0 && (
+
+
🏆
+
No records yet — import activities to track your best times
+
+ )}
+
+
+ {/* Records table */}
+
+
+
+
+ Distance
+ Best time
+ Date
+
+
+
+
+ {sortedRecords?.map(rec => (
+ setSelectedDistance(rec.distance_label)}
+ className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
+ selectedDistance === rec.distance_label
+ ? 'bg-blue-900/20'
+ : 'hover:bg-gray-800/40'
+ }`}
+ >
+ {rec.distance_label}
+
+ {formatDuration(rec.duration_s)}
+
+
+ {formatDate(rec.achieved_at)}
+
+
+ e.stopPropagation()}
+ className="text-xs text-blue-400 hover:underline"
+ >
+ View →
+
+
+
+ ))}
+
+
+
+
+ {/* Progress chart */}
+
+ {selectedDistance && history ? (
+ <>
+
+ {selectedDistance} progression
+
+
Lower is faster
+ {history.length > 1 ? (
+
+ ({
+ date: h.achieved_at,
+ time: h.duration_s,
+ }))}
+ margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
+ >
+
+ format(new Date(d), 'MMM yy')}
+ />
+
+ format(new Date(d), 'MMM d, yyyy')}
+ formatter={v => [formatDuration(v), 'Time']}
+ />
+
+
+
+ ) : (
+
+ Only one record — complete more activities to see progression
+
+ )}
+ >
+ ) : (
+
+ Select a distance to see your progression
+
+ )}
+
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/RoutesPage.jsx b/milevault_export/frontend/src/pages/RoutesPage.jsx
new file mode 100644
index 0000000..75984e1
--- /dev/null
+++ b/milevault_export/frontend/src/pages/RoutesPage.jsx
@@ -0,0 +1,186 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import api from '../utils/api'
+import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
+
+export default function RoutesPage() {
+ const [selected, setSelected] = useState(null)
+ const [showCreate, setShowCreate] = useState(false)
+ const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
+ const qc = useQueryClient()
+
+ const { data: routes } = useQuery({
+ queryKey: ['routes'],
+ queryFn: () => api.get('/routes/').then(r => r.data),
+ })
+
+ const { data: routeActivities } = useQuery({
+ queryKey: ['route-activities', selected?.id],
+ queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
+ enabled: !!selected,
+ })
+
+ const { data: recentActivities } = useQuery({
+ queryKey: ['recent-activities-for-route'],
+ queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
+ enabled: showCreate,
+ })
+
+ const createRoute = useMutation({
+ mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
+ onSuccess: (route) => {
+ qc.invalidateQueries({ queryKey: ['routes'] })
+ setShowCreate(false)
+ setNewRoute({ name: '', activity_id: '' })
+ setSelected(route)
+ },
+ })
+
+ const fastest = routeActivities?.[0]
+
+ return (
+
+
+
+
Named Routes
+
+ Routes are auto-detected when you run the same path twice. You can also create them manually.
+
+
+
setShowCreate(true)}
+ className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ + New route
+
+
+
+ {/* Create route */}
+ {showCreate && (
+
+
Create named route
+
+ Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
+
+
+
+ Route name
+ setNewRoute(r => ({ ...r, name: e.target.value }))}
+ className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ placeholder="e.g. Morning park loop" />
+
+
+
Reference activity (last 2 weeks)
+ {recentActivities?.length === 0 ? (
+
No recent activities found.
+ ) : (
+
setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
+ className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ Select an activity…
+ {recentActivities?.map(a => (
+
+ {sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
+
+ ))}
+
+ )}
+
+
+
+ createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
+ disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
+ className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ Create
+
+ setShowCreate(false)}
+ className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ Cancel
+
+
+
+ )}
+
+
+ {/* Route list */}
+
+ {routes?.length === 0 && !showCreate && (
+
+
🗺️
+
No named routes yet
+
Routes are created automatically when you repeat a run, or create one manually above.
+
+ )}
+ {routes?.map(route => (
+
setSelected(route)}
+ className={`w-full text-left p-4 rounded-xl border transition-all ${
+ selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
+ }`}>
+
+
{route.name}
+ {route.auto_detected && (
+
auto
+ )}
+
+
+ {formatDistance(route.distance_m)}
+ {route.sport_type && {route.sport_type} }
+ {formatDate(route.created_at)}
+
+
+ ))}
+
+
+ {/* Route detail */}
+ {selected && (
+
+
+
+
{selected.name}
+ {selected.auto_detected && (
+
+ Auto-detected
+
+ )}
+
+
+ {fastest && (
+
+
Course record 🏆
+
+ {formatDuration(fastest.duration_s)}
+
+ {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
+
+
+
+ )}
+
+
+ All runs ({routeActivities?.length ?? 0})
+
+
+ {routeActivities?.map((act, i) => (
+
+ {i + 1}
+ {formatDate(act.start_time)}
+ {formatDuration(act.duration_s)}
+ {formatPace(act.avg_speed_ms, selected.sport_type)}
+ {act.avg_heart_rate && (
+ {Math.round(act.avg_heart_rate)} bpm
+ )}
+ {i === 0 && (
+ CR
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/pages/UploadPage.jsx b/milevault_export/frontend/src/pages/UploadPage.jsx
new file mode 100644
index 0000000..62dc1c5
--- /dev/null
+++ b/milevault_export/frontend/src/pages/UploadPage.jsx
@@ -0,0 +1,178 @@
+import { useState, useCallback } from 'react'
+import { useDropzone } from 'react-dropzone'
+import { useMutation } from '@tanstack/react-query'
+import api from '../utils/api'
+
+function UploadZone({ title, description, accept, endpoint, icon }) {
+ const [tasks, setTasks] = useState([])
+
+ const upload = useMutation({
+ mutationFn: async (file) => {
+ const form = new FormData()
+ form.append('file', file)
+ const { data } = await api.post(endpoint, form, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ return { file: file.name, ...data }
+ },
+ onSuccess: (data) => {
+ setTasks(t => [...t, { ...data, status: 'queued' }])
+ },
+ })
+
+ const onDrop = useCallback((accepted) => {
+ accepted.forEach(file => upload.mutate(file))
+ }, [upload])
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept,
+ multiple: true,
+ })
+
+ return (
+
+
+
{icon}
+
+
{title}
+
{description}
+
+
+
+
+
+ {isDragActive ? (
+
Drop files here…
+ ) : (
+
+
Drag & drop files here, or click to browse
+
+ {Object.values(accept).flat().join(', ')}
+
+
+ )}
+
+
+ {upload.isPending && (
+
Uploading…
+ )}
+
+ {tasks.length > 0 && (
+
+ {tasks.map((task, i) => (
+
+ {task.file}
+ {task.activity_tasks !== undefined && (
+ {task.activity_tasks} activities queued
+ )}
+ ✓ Queued
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default function UploadPage() {
+ return (
+
+
+
Import Data
+
+ Import activities from Garmin or Strava. Large exports are processed in the background.
+
+
+
+ {/* How to export guides */}
+
+
+
📥 How to export from Garmin Connect
+
+ Go to Garmin Connect → Profile → Account
+ Scroll to Data Management → Export Your Data
+ Request export and wait for the email
+ Download and upload the ZIP file below
+
+
+
+
📥 How to export from Strava
+
+ Go to strava.com → Settings → My Account
+ Scroll to Download or Delete Your Account
+ Click "Request Your Archive"
+ Download and upload the ZIP file below
+
+
+
+
+
+ {/* Single FIT/GPX */}
+
+
+ {/* Garmin full export */}
+
+
+ {/* Strava export */}
+
+
+ {/* Ongoing FIT files */}
+
+
+
🔄
+
+
Ongoing sync
+
Automatically import new Garmin watch files
+
+
+
+
After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:
+
+ GARMIN/Activity/*.fit
+
+
Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:
+
+{`# Example: auto-upload new FIT files
+inotifywait -m ~/Garmin/Activity/ -e create \\
+ --format '%f' | while read file; do
+ curl -X POST /api/upload/activity \\
+ -H "Authorization: Bearer TOKEN" \\
+ -F "file=@$file"
+ done`}
+
+
+
+
+
+ )
+}
diff --git a/milevault_export/frontend/src/utils/api.js b/milevault_export/frontend/src/utils/api.js
new file mode 100644
index 0000000..502fb07
--- /dev/null
+++ b/milevault_export/frontend/src/utils/api.js
@@ -0,0 +1,26 @@
+import axios from 'axios'
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_URL || '/api',
+})
+
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('token')
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+})
+
+api.interceptors.response.use(
+ (res) => res,
+ (err) => {
+ if (err.response?.status === 401) {
+ localStorage.removeItem('token')
+ window.location.href = '/login'
+ }
+ return Promise.reject(err)
+ }
+)
+
+export default api
diff --git a/milevault_export/frontend/src/utils/format.js b/milevault_export/frontend/src/utils/format.js
new file mode 100644
index 0000000..6f03b35
--- /dev/null
+++ b/milevault_export/frontend/src/utils/format.js
@@ -0,0 +1,94 @@
+export function formatDuration(seconds) {
+ if (!seconds) return '--'
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ const s = Math.floor(seconds % 60)
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+ return `${m}:${String(s).padStart(2, '0')}`
+}
+
+export function formatPace(speedMs, sportType = 'running') {
+ if (!speedMs || speedMs <= 0) return '--'
+ if (sportType === 'cycling') {
+ return `${(speedMs * 3.6).toFixed(1)} km/h`
+ }
+ const secsPerKm = 1000 / speedMs
+ const mins = Math.floor(secsPerKm / 60)
+ const secs = Math.floor(secsPerKm % 60)
+ return `${mins}:${String(secs).padStart(2, '0')} /km`
+}
+
+export function formatDistance(metres) {
+ if (!metres) return '--'
+ if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
+ return `${Math.round(metres)} m`
+}
+
+export function formatElevation(metres) {
+ if (metres == null) return '--'
+ return `${Math.round(metres)} m`
+}
+
+export function formatHeartRate(bpm) {
+ if (!bpm) return '--'
+ return `${Math.round(bpm)} bpm`
+}
+
+export function formatSleep(seconds) {
+ if (!seconds) return '--'
+ const h = Math.floor(seconds / 3600)
+ const m = Math.round((seconds % 3600) / 60)
+ return `${h}h ${m}m`
+}
+
+export function formatWeight(kg) {
+ if (!kg) return '--'
+ return `${kg.toFixed(1)} kg`
+}
+
+export function formatDate(dateStr) {
+ if (!dateStr) return '--'
+ return new Date(dateStr).toLocaleDateString('en-GB', {
+ day: 'numeric', month: 'short', year: 'numeric',
+ })
+}
+
+export function formatDateTime(dateStr) {
+ if (!dateStr) return '--'
+ return new Date(dateStr).toLocaleDateString('en-GB', {
+ day: 'numeric', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ })
+}
+
+export function formatCadence(value, sportType) {
+ if (!value) return '--'
+ // Garmin stores running cadence as steps per minute / 2 (one foot)
+ // We need to double it to get total steps per minute (both feet)
+ if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
+ return `${Math.round(value * 2)} spm`
+ }
+ // Cycling is already in rpm
+ return `${Math.round(value)} rpm`
+}
+
+export function hrZoneColor(zone) {
+ const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
+ return colors[zone] || '#9ca3af'
+}
+
+export function sportIcon(sportType) {
+ const icons = {
+ running: '🏃', cycling: '🚴', hiking: '🥾',
+ walking: '🚶', other: '⚡',
+ }
+ return icons[sportType?.toLowerCase()] || '⚡'
+}
+
+export function sportColor(sportType) {
+ const colors = {
+ running: '#3b82f6', cycling: '#f97316',
+ hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
+ }
+ return colors[sportType?.toLowerCase()] || '#6b7280'
+}
diff --git a/milevault_export/frontend/tailwind.config.js b/milevault_export/frontend/tailwind.config.js
new file mode 100644
index 0000000..53c34f8
--- /dev/null
+++ b/milevault_export/frontend/tailwind.config.js
@@ -0,0 +1,18 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,jsx}'],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ brand: {
+ 50: '#eff6ff',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ },
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/milevault_export/frontend/vite.config.js b/milevault_export/frontend/vite.config.js
new file mode 100644
index 0000000..d379536
--- /dev/null
+++ b/milevault_export/frontend/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://backend:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+})
diff --git a/milevault_export/install.sh b/milevault_export/install.sh
new file mode 100755
index 0000000..335bf68
--- /dev/null
+++ b/milevault_export/install.sh
@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+# MileVault installer
+# Usage: curl -fsSL https://raw.githubusercontent.com/you/milevault/main/install.sh | bash
+# Or: bash install.sh
+set -euo pipefail
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
+BOLD='\033[1m'
+
+info() { echo -e "${GREEN}✓${NC} $*"; }
+warn() { echo -e "${YELLOW}!${NC} $*"; }
+error() { echo -e "${RED}✗ $*${NC}"; exit 1; }
+step() { echo -e "\n${CYAN}${BOLD}── $* ──${NC}"; }
+
+echo -e "${BOLD}"
+echo " ███████╗██╗████████╗████████╗██████╗ █████╗ ██████╗██╗ ██╗███████╗██████╗ "
+echo " ██╔════╝██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗"
+echo " █████╗ ██║ ██║ ██║ ██████╔╝███████║██║ █████╔╝ █████╗ ██████╔╝"
+echo " ██╔══╝ ██║ ██║ ██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗"
+echo " ██║ ██║ ██║ ██║ ██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║"
+echo " ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝"
+echo -e "${NC}"
+echo " Self-hosted fitness tracking — Garmin & Strava"
+echo ""
+
+# ── Preflight checks ──────────────────────────────────────────────────────────
+
+step "Checking requirements"
+
+command -v docker >/dev/null 2>&1 || error "Docker is not installed. Install from https://docs.docker.com/get-docker/"
+info "Docker found: $(docker --version | head -1)"
+
+# Check docker compose (v2 plugin or v1 standalone)
+if docker compose version >/dev/null 2>&1; then
+ COMPOSE_CMD="docker compose"
+elif command -v docker-compose >/dev/null 2>&1; then
+ COMPOSE_CMD="docker-compose"
+else
+ error "Docker Compose not found. Install from https://docs.docker.com/compose/install/"
+fi
+info "Docker Compose found: $($COMPOSE_CMD version | head -1)"
+
+# Check Docker daemon is running
+docker info >/dev/null 2>&1 || error "Docker daemon is not running. Start Docker and retry."
+info "Docker daemon is running"
+
+# ── Install directory ─────────────────────────────────────────────────────────
+
+step "Setting up install directory"
+
+INSTALL_DIR="${FITTRACKER_DIR:-$HOME/milevault}"
+
+if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then
+ warn "Directory $INSTALL_DIR already exists."
+ read -rp " Continue and update existing install? [y/N] " confirm
+ [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
+fi
+
+mkdir -p "$INSTALL_DIR"
+cd "$INSTALL_DIR"
+info "Install directory: $INSTALL_DIR"
+
+# ── Download project files ────────────────────────────────────────────────────
+
+step "Downloading MileVault"
+
+# If we're already inside the repo (files exist), skip download
+if [ -f "docker-compose.yml" ]; then
+ info "Project files already present — skipping download"
+else
+ # Try git first, fall back to curl
+ if command -v git >/dev/null 2>&1; then
+ git clone --depth 1 https://github.com/yourusername/milevault.git . 2>/dev/null || {
+ warn "Git clone failed — copying bundled files instead"
+ }
+ fi
+
+ # Fallback: if running this script from inside a downloaded zip, the files are next to it
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ if [ "$SCRIPT_DIR" != "$INSTALL_DIR" ] && [ -f "$SCRIPT_DIR/docker-compose.yml" ]; then
+ cp -r "$SCRIPT_DIR"/. "$INSTALL_DIR/"
+ info "Copied project files from $SCRIPT_DIR"
+ fi
+fi
+
+[ -f "docker-compose.yml" ] || error "docker-compose.yml not found. Place install.sh inside the project directory."
+info "Project files ready"
+
+# ── Generate .env ─────────────────────────────────────────────────────────────
+
+step "Configuring environment"
+
+if [ -f ".env" ]; then
+ warn ".env already exists — skipping generation (delete it to regenerate)"
+else
+ # Generate secure random values
+ if command -v openssl >/dev/null 2>&1; then
+ SECRET_KEY=$(openssl rand -hex 32)
+ DB_PASSWORD=$(openssl rand -base64 18 | tr -d '/+=')
+ REDIS_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=')
+ ADMIN_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=')
+ else
+ # Fallback if openssl not available
+ SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 64)
+ DB_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 18)
+ REDIS_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12)
+ ADMIN_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12)
+ fi
+
+ ADMIN_USERNAME="${FITTRACKER_ADMIN:-admin}"
+ PORT="${FITTRACKER_PORT:-80}"
+
+ cat > .env << ENV
+# MileVault configuration — generated $(date)
+# Edit this file to change settings, then run: docker compose up -d
+
+# Admin login
+ADMIN_USERNAME=${ADMIN_USERNAME}
+ADMIN_PASSWORD=${ADMIN_PASSWORD}
+
+# Secrets (auto-generated — do not share)
+SECRET_KEY=${SECRET_KEY}
+DB_PASSWORD=${DB_PASSWORD}
+DB_USER=milevault
+REDIS_PASSWORD=${REDIS_PASSWORD}
+
+# Server
+HTTP_PORT=${PORT}
+ENVIRONMENT=production
+
+# Optional: Mapbox token for satellite map tiles (free at mapbox.com)
+VITE_MAPBOX_TOKEN=
+
+# Optional: PocketID passkey authentication
+# POCKETID_ISSUER=https://your-pocketid.example.com
+# POCKETID_CLIENT_ID=milevault
+# POCKETID_CLIENT_SECRET=
+ENV
+
+ info ".env created with secure random secrets"
+
+ # Save credentials for display at end
+ SHOW_CREDS=true
+fi
+
+source .env
+
+# ── Build & start ─────────────────────────────────────────────────────────────
+
+step "Building and starting containers"
+echo " This takes 3–5 minutes on first run (building images)..."
+echo ""
+
+$COMPOSE_CMD up -d --build
+
+# ── Wait for healthy ──────────────────────────────────────────────────────────
+
+step "Waiting for services to be ready"
+
+TIMEOUT=120
+ELAPSED=0
+printf " Waiting"
+while ! docker inspect milevault_backend 2>/dev/null | grep -q '"healthy"' ; do
+ if [ $ELAPSED -ge $TIMEOUT ]; then
+ echo ""
+ warn "Backend taking longer than expected. Check logs: docker compose logs backend"
+ break
+ fi
+ printf "."
+ sleep 3
+ ELAPSED=$((ELAPSED + 3))
+done
+echo ""
+info "All services are up"
+
+# ── Done ──────────────────────────────────────────────────────────────────────
+
+PORT="${HTTP_PORT:-80}"
+URL="http://localhost${PORT:+:${PORT}}"
+[[ "$PORT" == "80" ]] && URL="http://localhost"
+
+echo ""
+echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}${BOLD}║ MileVault is ready! ║${NC}"
+echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════╝${NC}"
+echo ""
+echo -e " 🌐 Open: ${CYAN}${URL}${NC}"
+echo -e " 👤 Username: ${BOLD}${ADMIN_USERNAME:-admin}${NC}"
+
+if [ "${SHOW_CREDS:-false}" = "true" ]; then
+ echo -e " 🔑 Password: ${BOLD}${ADMIN_PASSWORD}${NC}"
+ echo ""
+ warn "Save this password — it won't be shown again."
+ warn "It's also stored in: ${INSTALL_DIR}/.env"
+else
+ echo -e " 🔑 Password: (see ${INSTALL_DIR}/.env — ADMIN_PASSWORD)"
+fi
+
+echo ""
+echo " Useful commands:"
+echo " docker compose logs -f # View live logs"
+echo " docker compose logs backend # Backend logs only"
+echo " docker compose down # Stop everything"
+echo " docker compose up -d # Start again"
+echo " docker compose pull && docker compose up -d --build # Update"
+echo ""
+echo " Import your data:"
+echo " Go to ${URL} → Import → upload your Garmin export ZIP or Strava ZIP"
+echo ""
diff --git a/milevault_export/nginx.conf b/milevault_export/nginx.conf
new file mode 100644
index 0000000..e7095b1
--- /dev/null
+++ b/milevault_export/nginx.conf
@@ -0,0 +1,50 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ keepalive_timeout 65;
+ client_max_body_size 512M;
+
+ limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
+ limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m;
+
+ upstream backend { server backend:8000; keepalive 32; }
+ upstream frontend { server frontend:80; }
+
+ server {
+ listen 80;
+ server_name _;
+
+ location /api/upload/ {
+ limit_req zone=upload burst=5 nodelay;
+ proxy_pass http://backend/api/upload/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ client_max_body_size 512M;
+ }
+
+ location /api/ {
+ limit_req zone=api burst=20 nodelay;
+ proxy_pass http://backend/api/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_read_timeout 300s;
+ }
+
+ location /health {
+ proxy_pass http://backend/health;
+ }
+
+ location / {
+ proxy_pass http://frontend;
+ proxy_set_header Host $host;
+ }
+ }
+}
diff --git a/milevault_export/nginx/nginx.conf b/milevault_export/nginx/nginx.conf
new file mode 100644
index 0000000..c764c66
--- /dev/null
+++ b/milevault_export/nginx/nginx.conf
@@ -0,0 +1,59 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ keepalive_timeout 65;
+ client_max_body_size 512M;
+
+ limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
+ limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m;
+
+ upstream backend {
+ server backend:8000;
+ keepalive 32;
+ }
+
+ upstream frontend {
+ server frontend:80;
+ }
+
+ server {
+ listen 80;
+ server_name _;
+
+ location /api/upload/ {
+ limit_req zone=upload burst=5 nodelay;
+ proxy_pass http://backend/api/upload/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ client_max_body_size 512M;
+ }
+
+ location /api/ {
+ limit_req zone=api burst=20 nodelay;
+ proxy_pass http://backend/api/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_read_timeout 300s;
+ }
+
+ location /health {
+ proxy_pass http://backend/health;
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ proxy_pass http://frontend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ }
+}
diff --git a/milevault_export/scripts/manage.sh b/milevault_export/scripts/manage.sh
new file mode 100755
index 0000000..46b4c52
--- /dev/null
+++ b/milevault_export/scripts/manage.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR/.."
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
+
+info() { echo -e "${GREEN}[milevault]${NC} $*"; }
+warn() { echo -e "${YELLOW}[milevault]${NC} $*"; }
+error() { echo -e "${RED}[milevault]${NC} $*"; exit 1; }
+
+check_env() {
+ if [ ! -f .env ]; then
+ warn ".env not found — copying from .env.example"
+ cp .env.example .env
+ warn "Please edit .env and set required secrets, then re-run this script."
+ exit 1
+ fi
+
+ source .env
+ [ -z "${DB_PASSWORD:-}" ] && error "DB_PASSWORD must be set in .env"
+ [ -z "${SECRET_KEY:-}" ] && error "SECRET_KEY must be set in .env (use: openssl rand -hex 32)"
+ [ -z "${ADMIN_PASSWORD:-}" ] && error "ADMIN_PASSWORD must be set in .env"
+ info "Environment looks good"
+}
+
+generate_secrets() {
+ info "Generating .env from template..."
+ cp .env.example .env
+ SECRET=$(openssl rand -hex 32)
+ DB_PASS=$(openssl rand -base64 16 | tr -d '/+=')
+ ADMIN_PASS=$(openssl rand -base64 12 | tr -d '/+=')
+ REDIS_PASS=$(openssl rand -base64 12 | tr -d '/+=')
+
+ sed -i "s/changeme_generate_with_openssl_rand_hex_32/$SECRET/" .env
+ sed -i "s/changeme_strong_password/$DB_PASS/" .env
+ sed -i "s/changeme_admin_password/$ADMIN_PASS/" .env
+ sed -i "s/redispass/$REDIS_PASS/" .env
+
+ echo ""
+ echo -e "${GREEN}Generated secrets:${NC}"
+ echo " Admin username: admin"
+ echo " Admin password: $ADMIN_PASS"
+ echo " (saved to .env)"
+ echo ""
+ warn "Save these credentials! The admin password won't be shown again."
+}
+
+cmd_start() {
+ check_env
+ info "Starting MileVault..."
+ docker compose up -d --build
+ info "Started! Visit http://localhost:${HTTP_PORT:-80}"
+}
+
+cmd_stop() {
+ info "Stopping MileVault..."
+ docker compose down
+}
+
+cmd_logs() {
+ docker compose logs -f "${1:-}"
+}
+
+cmd_setup() {
+ info "First-time setup"
+ generate_secrets
+ cmd_start
+}
+
+cmd_backup() {
+ source .env
+ BACKUP_FILE="milevault_backup_$(date +%Y%m%d_%H%M%S).sql"
+ info "Backing up database to $BACKUP_FILE..."
+ docker compose exec -T db pg_dump \
+ -U "${DB_USER:-milevault}" milevault > "$BACKUP_FILE"
+ info "Backup saved: $BACKUP_FILE"
+}
+
+cmd_restore() {
+ [ -z "${1:-}" ] && error "Usage: $0 restore "
+ source .env
+ info "Restoring from $1..."
+ docker compose exec -T db psql \
+ -U "${DB_USER:-milevault}" milevault < "$1"
+ info "Restore complete"
+}
+
+cmd_update() {
+ info "Pulling latest and rebuilding..."
+ git pull
+ docker compose build --no-cache
+ docker compose up -d
+ info "Update complete"
+}
+
+case "${1:-help}" in
+ setup) cmd_setup ;;
+ start) cmd_start ;;
+ stop) cmd_stop ;;
+ restart) cmd_stop; cmd_start ;;
+ logs) cmd_logs "${2:-}" ;;
+ backup) cmd_backup ;;
+ restore) cmd_restore "${2:-}" ;;
+ update) cmd_update ;;
+ *)
+ echo "MileVault management script"
+ echo ""
+ echo "Usage: $0 "
+ echo ""
+ echo "Commands:"
+ echo " setup First-time setup (generates secrets, starts containers)"
+ echo " start Start all containers"
+ echo " stop Stop all containers"
+ echo " restart Restart all containers"
+ echo " logs Follow logs (optionally: logs backend)"
+ echo " backup Backup PostgreSQL database"
+ echo " restore Restore from backup: restore "
+ echo " update Pull and rebuild"
+ ;;
+esac