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

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+31 -43
View File
@@ -7,13 +7,23 @@ from typing import Optional
import httpx import httpx
from app.core.database import get_db 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.core.config import settings
from app.models.user import User from app.models.user import User
router = APIRouter() 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): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
@@ -37,24 +47,15 @@ async def login(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( result = await db.execute(select(User).where(User.username == form_data.username))
select(User).where(User.username == form_data.username)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not user.hashed_password: if not user or not user.hashed_password:
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password): if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials") raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
return Token( return Token(access_token=token, token_type="bearer",
access_token=token, user_id=user.id, username=user.username, is_admin=user.is_admin)
token_type="bearer",
user_id=user.id,
username=user.username,
is_admin=user.is_admin,
)
@router.get("/me", response_model=UserOut) @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") @router.get("/pocketid/available")
async def pocketid_available(): async def pocketid_available(db: AsyncSession = Depends(get_db)):
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)} issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url") @router.get("/pocketid/login-url")
async def pocketid_login_url(): async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
"""Return the OIDC authorization URL for PocketID.""" issuer, client_id, _ = await _get_pocketid_config(db)
if not settings.pocketid_issuer: if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured") raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = { params = {
"client_id": settings.pocketid_client_id, "client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback", "redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code", "response_type": "code",
"scope": "openid profile email", "scope": "openid profile email",
} }
from urllib.parse import urlencode return {"url": f"{issuer}/authorize?{urlencode(params)}"}
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
return {"url": url}
@router.get("/pocketid/callback") @router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
"""Exchange OIDC code for tokens and create/login user.""" issuer, client_id, client_secret = await _get_pocketid_config(db)
if not settings.pocketid_issuer: if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured") raise HTTPException(status_code=404, detail="PocketID not configured")
# Exchange code for tokens
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{settings.pocketid_issuer}/token", f"{issuer}/token",
data={ data={"grant_type": "authorization_code", "code": code,
"grant_type": "authorization_code", "redirect_uri": "/api/auth/pocketid/callback",
"code": code, "client_id": client_id, "client_secret": client_secret},
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": settings.pocketid_client_id,
"client_secret": settings.pocketid_client_secret,
},
) )
if resp.status_code != 200: if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Token exchange failed") raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json() tokens = resp.json()
userinfo_resp = await client.get( userinfo_resp = await client.get(
f"{settings.pocketid_issuer}/userinfo", f"{issuer}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"}, headers={"Authorization": f"Bearer {tokens['access_token']}"},
) )
userinfo = userinfo_resp.json() 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)) result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
user = User( user = User(username=preferred_username, email=email, pocketid_sub=sub)
username=preferred_username,
email=email,
pocketid_sub=sub,
)
db.add(user) db.add(user)
await db.flush() await db.flush()
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
# Redirect to frontend with token
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}") return RedirectResponse(url=f"/?token={token}")
+220
View File
@@ -0,0 +1,220 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
class Config:
from_attributes = True
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
await db.commit()
await db.refresh(current_user)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None:
current_user.pocketid_client_secret = body.client_secret or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = 365,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
+36 -8
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List 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.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
@@ -23,7 +23,7 @@ class RouteCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
sport_type: Optional[str] = None sport_type: Optional[str] = None
activity_id: int # use this activity as the reference route activity_id: int
class RouteOut(BaseModel): class RouteOut(BaseModel):
@@ -34,6 +34,7 @@ class RouteOut(BaseModel):
reference_polyline: Optional[str] reference_polyline: Optional[str]
bounding_box: Optional[dict] bounding_box: Optional[dict]
distance_m: Optional[float] distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime created_at: datetime
class Config: class Config:
@@ -64,13 +65,44 @@ async def list_routes(
return result.scalars().all() 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) @router.post("/", response_model=RouteOut)
async def create_route( async def create_route(
body: RouteCreate, body: RouteCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
# Load the reference activity
act_result = await db.execute( act_result = await db.execute(
select(Activity).where( select(Activity).where(
Activity.id == body.activity_id, Activity.id == body.activity_id,
@@ -89,11 +121,10 @@ async def create_route(
reference_polyline=activity.polyline, reference_polyline=activity.polyline,
bounding_box=activity.bounding_box, bounding_box=activity.bounding_box,
distance_m=activity.distance_m, distance_m=activity.distance_m,
auto_detected=False,
) )
db.add(route) db.add(route)
await db.flush() await db.flush()
# Link this activity to the route
activity.named_route_id = route.id activity.named_route_id = route.id
await db.commit() await db.commit()
await db.refresh(route) await db.refresh(route)
@@ -124,7 +155,6 @@ async def route_activities(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""All activities on this named route, ordered fastest first."""
result = await db.execute( result = await db.execute(
select(Activity).where( select(Activity).where(
Activity.named_route_id == route_id, Activity.named_route_id == route_id,
@@ -153,7 +183,6 @@ async def assign_activity_to_route(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Manually assign an activity to a named route."""
activity_id = body.get("activity_id") activity_id = body.get("activity_id")
act_result = await db.execute( act_result = await db.execute(
select(Activity).where( select(Activity).where(
@@ -164,7 +193,6 @@ async def assign_activity_to_route(
activity = act_result.scalar_one_or_none() activity = act_result.scalar_one_or_none()
if not activity: if not activity:
raise HTTPException(status_code=404, detail="Activity not found") raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id activity.named_route_id = route_id
await db.commit() await db.commit()
return {"status": "ok"} return {"status": "ok"}
+2 -1
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings 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(): async def init_db():
@@ -97,6 +97,7 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"]) app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"]) app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@app.get("/health") @app.get("/health")
+31 -5
View File
@@ -22,9 +22,39 @@ class User(Base):
pocketid_sub = Column(String(256), unique=True, nullable=True) pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc) 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") activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", 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") 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): class Activity(Base):
@@ -68,11 +98,6 @@ class Activity(Base):
class ActivityDataPoint(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" __tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True) 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) reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True) bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True) distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=now_utc) created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="named_routes") user = relationship("User", back_populates="named_routes")
+108 -142
View File
@@ -1,21 +1,24 @@
""" """
Parses Garmin .fit files and GPX files into normalized activity data. FIT and GPX file parser using:
Handles full Strava and Garmin data export archives. - 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 import math
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import fitparse
import gpxpy import gpxpy
import polyline as polyline_lib import polyline as polyline_lib
FIT_EPOCH_S = 631065600
def haversine_distance(lat1, lon1, lat2, lon2) -> float: 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 R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2) phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1) 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)) return 2 * R * math.asin(math.sqrt(a))
def semicircles_to_degrees(sc: int) -> float: def _safe_float(val) -> Optional[float]:
return sc * (180 / 2**31) 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: def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit file and return normalized activity dict.""" """Parse a Garmin .fit activity file using the official Garmin SDK."""
fit = fitparse.FitFile(filepath) from garmin_fit_sdk import Decoder, Stream
data_points = []
laps = []
session = {} session = {}
records = []
laps = []
for record in fit.get_messages(): def listener(mesg_num: int, msg: dict):
name = record.name 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": stream = Stream.from_file(filepath)
for f in record: decoder = Decoder(stream)
session[f.name] = f.value 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": # Map sport type
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
sport = str(session.get("sport", "generic")).lower() sport = str(session.get("sport", "generic")).lower()
sport_map = { sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming", "running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other", "hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running", "open_water_swimming": "swimming", "trail_running": "running",
"e_biking": "cycling",
} }
sport_type = sport_map.get(sport, sport) sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time") 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) start_time = start_time.replace(tzinfo=timezone.utc)
# Build GPS track for polyline # Build GPS track
coords = [ coords = [
(p["position_lat"], p["position_long"]) (r["position_lat"], r["position_long"])
for p in data_points for r in records
if p.get("position_lat") is not None and p.get("position_long") is not None 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 encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords) bounding_box = _bounding_box(coords)
# Calculate cumulative distance if not in FIT # Normalize data points
cumulative_dist = 0.0
prev_lat, prev_lon = None, None
normalized_points = [] normalized_points = []
for p in data_points: for r in records:
ts = p.get("timestamp") ts = r.get("timestamp")
if ts and ts.tzinfo is None: if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc) 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({ normalized_points.append({
"timestamp": ts.isoformat() if ts else None, "timestamp": ts.isoformat() if ts else None,
"latitude": lat, "latitude": r.get("position_lat"),
"longitude": lon, "longitude": r.get("position_long"),
"altitude_m": p.get("altitude"), "altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
"heart_rate": p.get("heart_rate"), "heart_rate": r.get("heart_rate"),
"cadence": p.get("cadence"), "cadence": r.get("cadence") or r.get("fractional_cadence"),
"speed_ms": p.get("speed"), "speed_ms": r.get("speed") or r.get("enhanced_speed"),
"power": p.get("power"), "power": r.get("power"),
"temperature_c": p.get("temperature"), "temperature_c": r.get("temperature"),
"distance_m": dist, "distance_m": r.get("distance"),
}) })
# Parse laps # Normalize laps
normalized_laps = [] normalized_laps = []
for i, lap in enumerate(laps): for i, lap in enumerate(laps):
ls = lap.get("start_time") 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) ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({ normalized_laps.append({
"lap_number": i + 1, "lap_number": i + 1,
@@ -132,13 +129,17 @@ def parse_fit_file(filepath: str) -> dict:
"distance_m": _safe_float(lap.get("total_distance")), "distance_m": _safe_float(lap.get("total_distance")),
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")), "avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
"avg_cadence": _safe_float(lap.get("avg_cadence")), "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")), "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 { return {
"name": session.get("sport", "Activity").title() + " " + ( "name": name,
start_time.strftime("%Y-%m-%d") if start_time else ""),
"sport_type": sport_type, "sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None, "start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")), "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_cadence": _safe_float(session.get("avg_cadence")),
"avg_power": _safe_float(session.get("avg_power")), "avg_power": _safe_float(session.get("avg_power")),
"normalized_power": _safe_float(session.get("normalized_power")), "normalized_power": _safe_float(session.get("normalized_power")),
"avg_speed_ms": _safe_float(session.get("avg_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")), "max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
"avg_temperature_c": _safe_float(session.get("avg_temperature")), "avg_temperature_c": _safe_float(session.get("avg_temperature")),
"calories": _safe_float(session.get("total_calories")), "calories": _safe_float(session.get("total_calories")),
"training_stress_score": _safe_float(session.get("training_stress_score")), "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, "polyline": encoded_polyline,
"bounding_box": bounding_box, "bounding_box": bounding_box,
"source_type": "fit", "source_type": "fit",
@@ -165,13 +166,12 @@ def parse_fit_file(filepath: str) -> dict:
def parse_gpx_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: with open(filepath) as f:
gpx = gpxpy.parse(f) gpx = gpxpy.parse(f)
data_points = [] data_points = []
track = gpx.tracks[0] if gpx.tracks else None track = gpx.tracks[0] if gpx.tracks else None
if not track: if not track:
raise ValueError("No tracks found in GPX file") raise ValueError("No tracks found in GPX file")
@@ -204,7 +204,6 @@ def parse_gpx_file(filepath: str) -> dict:
"distance_m": None, "distance_m": None,
}) })
# Calculate distance and elevation
coords = [(p["latitude"], p["longitude"]) for p in data_points coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]] if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None 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"]) prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0 uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]] alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)): 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 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 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: if track.type:
sport = track.type.lower() 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. Calculate % time in each HR zone using the user's configured max HR.
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
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 = [] if not user_max_hr or user_max_hr < 100:
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:
return {} return {}
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0} zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0] zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0 total = 0
for p in data_points: for p in data_points:
hr = p.get("heart_rate") hr = p.get("heart_rate")
if not hr: if not hr or hr < 20:
continue continue
pct = hr / max_hr pct = hr / user_max_hr
total += 1 total += 1
if pct < zone_bounds[1]: for i, key in enumerate(zone_keys):
zones["z1"] += 1 if zone_bounds[i] <= pct < zone_bounds[i+1]:
elif pct < zone_bounds[2]: zones[key] += 1
zones["z2"] += 1 break
elif pct < zone_bounds[3]:
zones["z3"] += 1
elif pct < zone_bounds[4]:
zones["z4"] += 1
else: else:
zones["z5"] += 1 zones["z5"] += 1 # anything above 90% goes to z5
if total: if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()} return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {} 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),
}
+146 -133
View File
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
if existing: if existing:
return {"activity_id": existing.id, "status": "duplicate"} 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( hr_zones = calculate_hr_zones(
parsed.get("data_points", []), parsed.get("data_points", []),
parsed.get("max_heart_rate") or 190 user_max_hr
) )
start_time = datetime.fromisoformat(parsed["start_time"]) 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 activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed) 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"} 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): def parse_wellness_fit(file_path: str, user_id: int):
""" """
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics. 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.core.database import SyncSessionLocal
from app.models.user import HealthMetric from datetime import datetime, timezone
from sqlalchemy import text from sqlalchemy import text
from datetime import datetime, timezone, date
try: result = _parse(file_path)
fit = fitparse.FitFile(file_path) if result.get("error"):
except Exception as e: return {"status": "error", "error": result["error"], "file": file_path}
return {"status": "error", "error": str(e)}
# Collect all monitoring/daily summary records keyed by date days = result.get("days", {})
daily = {} # date -> dict of fields if not days:
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:
return {"status": "no_data", "file": file_path} return {"status": "no_data", "file": file_path}
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
with SyncSessionLocal() as db: with SyncSessionLocal() as db:
for day_date, data in daily.items(): for day_date, data in days.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
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc) 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(""" db.execute(text("""
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress, INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps, 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) 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, VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps, :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) :sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
ON CONFLICT (user_id, date) DO UPDATE SET ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr), resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day), avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress), max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg), avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg), spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high), hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status), hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps), 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_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_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_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_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_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
"""), { """), {
"user_id": user_id, "date": date_dt, "user_id": user_id, "date": date_dt,
"resting_hr": resting_hr, "avg_hr": avg_hr, "resting_hr": data.get("resting_hr"),
"avg_stress": avg_stress, "spo2_avg": spo2_avg, "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_avg": data.get("hrv_nightly_avg"),
"hrv_high": data.get("hrv_5min_high"), "hrv_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"), "hrv_status": data.get("hrv_status"),
"steps": data.get("steps"), "steps": data.get("steps"),
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s, "floors": data.get("floors_climbed"),
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s, "active_cal": data.get("active_calories"),
"sleep_awake": sleep_awake_s, "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() 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") @celery_app.task(name="compute_personal_records")
+1
View File
@@ -12,6 +12,7 @@ python-multipart==0.0.9
httpx==0.27.0 httpx==0.27.0
redis[hiredis]==5.0.4 redis[hiredis]==5.0.4
celery[redis]==5.4.0 celery[redis]==5.4.0
garmin-fit-sdk==21.195.0
fitparse==1.2.0 fitparse==1.2.0
gpxpy==1.6.2 gpxpy==1.6.2
numpy==1.26.4 numpy==1.26.4
+1 -1
View File
@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
ARG VITE_API_URL=/api ARG VITE_API_URL=/api
+2 -1
View File
@@ -20,7 +20,8 @@
"zustand": "^4.5.2", "zustand": "^4.5.2",
"@tanstack/react-query": "^5.40.0", "@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"react-dropzone": "^14.2.3" "react-dropzone": "^14.2.3",
"@polyline-codec/core": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
+3 -9
View File
@@ -10,6 +10,7 @@ import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage' import RoutesPage from './pages/RoutesPage'
import RecordsPage from './pages/RecordsPage' import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage' import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage'
function RequireAuth({ children }) { function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
@@ -24,7 +25,6 @@ export default function App() {
if (token) fetchUser() if (token) fetchUser()
}, [token]) }, [token])
// Handle token from PocketID callback URL
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token') const urlToken = params.get('token')
@@ -38,14 +38,7 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="activities" element={<ActivitiesPage />} /> <Route path="activities" element={<ActivitiesPage />} />
<Route path="activities/:id" element={<ActivityDetailPage />} /> <Route path="activities/:id" element={<ActivityDetailPage />} />
@@ -53,6 +46,7 @@ export default function App() {
<Route path="routes" element={<RoutesPage />} /> <Route path="routes" element={<RoutesPage />} />
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} /> <Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route> </Route>
</Routes> </Routes>
) )
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import L from 'leaflet' import L from 'leaflet'
import { sportColor } from '../../utils/format' import { sportColor } from '../../utils/format'
// Fix Leaflet default icon issue with bundlers
delete L.Icon.Default.prototype._getIconUrl delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', 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', 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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
function decodePolyline(encoded) { function decodePolyline(encoded) {
// Simple polyline decoder
const coords = [] const coords = []
let index = 0, lat = 0, lng = 0 let index = 0, lat = 0, lng = 0
while (index < encoded.length) { while (index < encoded.length) {
let b, shift = 0, result = 0 let b, shift = 0, result = 0
do { do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
b = encoded.charCodeAt(index++) - 63
result |= (b & 0x1f) << shift
shift += 5
} while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1 lat += (result & 1) ? ~(result >> 1) : result >> 1
shift = 0; result = 0 shift = 0; result = 0
do { do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
b = encoded.charCodeAt(index++) - 63
result |= (b & 0x1f) << shift
shift += 5
} while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1 lng += (result & 1) ? ~(result >> 1) : result >> 1
coords.push([lat / 1e5, lng / 1e5]) coords.push([lat / 1e5, lng / 1e5])
} }
return coords 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 mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
const trackRef = useRef(null) const trackRef = useRef(null)
const tileLayerRef = useRef(null)
useEffect(() => { useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return if (!mapRef.current || mapInstanceRef.current) return
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
mapInstanceRef.current = L.map(mapRef.current, { const tile = TILE_LAYERS['dark']
zoomControl: true, tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
attributionControl: true, .addTo(mapInstanceRef.current)
}) return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
// Use CartoDB dark tiles (no API key needed)
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
{
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
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(() => { useEffect(() => {
if (!mapInstanceRef.current || !polyline) return if (!mapInstanceRef.current || !polyline) return
if (trackRef.current) trackRef.current.remove()
if (trackRef.current) {
trackRef.current.remove()
}
const coords = decodePolyline(polyline) const coords = decodePolyline(polyline)
if (!coords.length) return if (!coords.length) return
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
trackRef.current = L.polyline(coords, { .addTo(mapInstanceRef.current)
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(mapInstanceRef.current)
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
// Start/end markers
if (coords.length > 0) { if (coords.length > 0) {
const startIcon = L.divIcon({ const dot = (color) => L.divIcon({
html: '<div style="width:12px;height:12px;background:#22c55e;border:2px solid white;border-radius:50%"></div>', html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '', iconSize: [12, 12], iconAnchor: [6, 6], className: '',
}) })
const endIcon = L.divIcon({ L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
html: '<div style="width:12px;height:12px;background:#ef4444;border:2px solid white;border-radius:50%"></div>', L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
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)
} }
}, [polyline, sportType]) }, [polyline, sportType])
// Move position marker when timeline is hovered // Position marker on timeline hover
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance) const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return if (!point?.latitude || !point?.longitude) return
if (markerRef.current) { if (markerRef.current) {
markerRef.current.setLatLng([point.latitude, point.longitude]) markerRef.current.setLatLng([point.latitude, point.longitude])
} else { } else {
@@ -114,8 +97,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>', html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '', iconSize: [14, 14], iconAnchor: [7, 7], className: '',
}) })
markerRef.current = L.marker([point.latitude, point.longitude], { icon }) markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
.addTo(mapInstanceRef.current)
} }
}, [hoveredDistance, dataPoints]) }, [hoveredDistance, dataPoints])
@@ -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 }) { export default function LapTable({ laps, sportType }) {
return ( return (
@@ -26,7 +26,7 @@ export default function LapTable({ laps, sportType }) {
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span> <span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td> </td>
<td className="py-2 text-right text-gray-400"> <td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'} {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
</td> </td>
<td className="py-2 text-right text-gray-400"> <td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
@@ -1,9 +1,9 @@
import { useMemo, useCallback } from 'react' import { useMemo } from 'react'
import { import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { formatDuration, formatPace } from '../../utils/format' import { formatPace, formatCadence } from '../../utils/format'
function downsample(points, maxPoints = 500) { function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points if (points.length <= maxPoints) return points
@@ -17,7 +17,7 @@ function buildChartData(dataPoints, activeMetrics) {
.map(p => { .map(p => {
const row = { distance_m: p.distance_m ?? 0 } const row = { distance_m: p.distance_m ?? 0 }
for (const key of activeMetrics) { for (const key of activeMetrics) {
row[key] = p[key] ?? null row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
} }
return row return row
}) })
@@ -25,9 +25,7 @@ function buildChartData(dataPoints, activeMetrics) {
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
if (onHover) onHover(label) if (onHover) onHover(label)
return ( return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl"> <div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p> <p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
@@ -37,7 +35,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
let display = entry.value.toFixed(1) let display = entry.value.toFixed(1)
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType) 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 === '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 === 'power') display = `${Math.round(entry.value)} W`
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C` else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m` 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)) const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
// Build per-metric Y-axis domains
const domains = useMemo(() => { const domains = useMemo(() => {
const result = {} const result = {}
for (const m of activeMetricConfigs) { for (const m of activeMetricConfigs) {
@@ -70,6 +67,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
const min = Math.min(...vals) const min = Math.min(...vals)
const max = Math.max(...vals) const max = Math.max(...vals)
const pad = (max - min) * 0.1 || 1 const pad = (max - min) * 0.1 || 1
// For elevation, don't start from 0 - show actual range
result[m.key] = [min - pad, max + pad] result[m.key] = [min - pad, max + pad]
} }
return result return result
@@ -87,18 +85,14 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
<div className="space-y-4"> <div className="space-y-4">
{activeMetricConfigs.map((metric, idx) => { {activeMetricConfigs.map((metric, idx) => {
const domain = domains[metric.key] || ['auto', 'auto'] const domain = domains[metric.key] || ['auto', 'auto']
const data = chartData.filter(p => p[metric.key] != null) const hasData = chartData.some(p => p[metric.key] != null)
if (!data.length) return null if (!hasData) return null
return ( return (
<div key={metric.key}> <div key={metric.key}>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span style={{ color: metric.color }} className="text-xs font-medium"> <span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
{metric.label} {metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
</span>
{metric.unit && (
<span className="text-xs text-gray-600">({metric.unit})</span>
)}
</div> </div>
<ResponsiveContainer width="100%" height={100}> <ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}> <ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
@@ -118,20 +112,19 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
tick={{ fontSize: 10, fill: '#6b7280' }} tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
width={36} width={40}
tickFormatter={v => { 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) return Math.round(v)
}} }}
/> />
<Tooltip <Tooltip
content={ content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
<CustomTooltip
metrics={metrics}
sportType={sportType}
onHover={onHoverDistance}
/>
}
isAnimationActive={false} isAnimationActive={false}
/> />
<Line <Line
@@ -148,8 +141,6 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
</div> </div>
) )
})} })}
{/* Shared distance axis label */}
<p className="text-xs text-gray-600 text-center">Distance (km)</p> <p className="text-xs text-gray-600 text-center">Distance (km)</p>
</div> </div>
) )
+6 -18
View File
@@ -8,6 +8,7 @@ const nav = [
{ to: '/routes', label: 'Routes', icon: '🗺️' }, { to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/records', label: 'Records', icon: '🏆' }, { to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' },
] ]
export default function Layout() { export default function Layout() {
@@ -21,51 +22,38 @@ export default function Layout() {
return ( return (
<div className="flex h-screen overflow-hidden bg-gray-950"> <div className="flex h-screen overflow-hidden bg-gray-950">
{/* Sidebar */}
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col"> <aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
{/* Logo */}
<div className="px-4 py-5 border-b border-gray-800"> <div className="px-4 py-5 border-b border-gray-800">
<h1 className="text-lg font-bold text-white tracking-tight"> <h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault <span className="text-blue-400">Mile</span>Vault
</h1> </h1>
{user && ( {user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
<p className="text-xs text-gray-500 mt-0.5">@{user.username}</p>
)}
</div> </div>
{/* Nav */}
<nav className="flex-1 py-4 overflow-y-auto"> <nav className="flex-1 py-4 overflow-y-auto">
{nav.map(({ to, label, icon, exact }) => ( {nav.map(({ to, label, icon, exact }) => (
<NavLink <NavLink key={to} to={to} end={exact}
key={to}
to={to}
end={exact}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${ `flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive isActive
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400' ? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800' : 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
}` }`
} }>
>
<span>{icon}</span> <span>{icon}</span>
{label} {label}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
{/* Footer */}
<div className="px-4 py-4 border-t border-gray-800"> <div className="px-4 py-4 border-t border-gray-800">
<button <button onClick={handleLogout}
onClick={handleLogout} className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out Sign out
</button> </button>
</div> </div>
</aside> </aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<Outlet /> <Outlet />
</main> </main>
+1 -1
View File
@@ -7,7 +7,7 @@ import {
formatDate, sportIcon, sportColor, formatDate, sportIcon, sportColor,
} from '../utils/format' } from '../utils/format'
const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking'] const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
export default function ActivitiesPage() { export default function ActivitiesPage() {
const [sport, setSport] = useState('all') const [sport, setSport] = useState('all')
+69 -30
View File
@@ -9,14 +9,14 @@ import LapTable from '../components/activity/LapTable'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import { import {
formatDuration, formatDistance, formatPace, formatElevation, formatDuration, formatDistance, formatPace, formatElevation,
formatHeartRate, formatDateTime, sportIcon, formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format' } from '../utils/format'
const METRICS = [ const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, { 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: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' }, { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
] ]
@@ -25,6 +25,8 @@ export default function ActivityDetailPage() {
const { id } = useParams() const { id } = useParams()
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null) const [hoveredDistance, setHoveredDistance] = useState(null)
const [mapHeight, setMapHeight] = useState(420)
const [mapType, setMapType] = useState('dark')
const { data: activity, isLoading } = useQuery({ const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id], queryKey: ['activity', id],
@@ -49,19 +51,21 @@ export default function ActivityDetailPage() {
) )
} }
if (isLoading) { // Check which metrics have actual data
return ( const availableMetrics = useMemo(() => {
<div className="flex items-center justify-center h-full"> if (!dataPoints?.length) return new Set()
<div className="text-gray-500">Loading activity</div> return new Set(
</div> METRICS
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
.map(m => m.key)
) )
}, [dataPoints])
if (isLoading) {
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity</div></div>
} }
if (!activity) return null if (!activity) return null
const speed = activity.avg_speed_ms
const pace = formatPace(speed, activity.sport_type)
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */} {/* Header */}
@@ -75,12 +79,12 @@ export default function ActivityDetailPage() {
</div> </div>
</div> </div>
{/* Summary stats */} {/* Primary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} /> <StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} /> <StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={pace} /> <StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation" value={`${formatElevation(activity.elevation_gain_m)}`} /> <StatCard label="Elevation" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" /> <StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} /> <StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div> </div>
@@ -88,37 +92,71 @@ export default function ActivityDetailPage() {
{/* Secondary stats */} {/* Secondary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3"> <div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} /> <StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
<StatCard label="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} /> <StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} /> <StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} /> <StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} /> <StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div> </div>
{/* Map */} {/* Map with controls */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}> <div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
<ActivityMap {/* Map toolbar */}
polyline={activity.polyline} <div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
dataPoints={dataPoints} <div className="flex items-center gap-2">
hoveredDistance={hoveredDistance} <span className="text-xs text-gray-500">Map style:</span>
sportType={activity.sport_type} {['dark', 'street', 'satellite'].map(t => (
/> <button
key={t}
onClick={() => 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}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span>
{[280, 420, 560].map(h => (
<button
key={h}
onClick={() => 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'}
</button>
))}
</div>
</div>
<div style={{ height: mapHeight }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
mapType={mapType}
/>
</div>
</div> </div>
{/* HR Zones */} {/* HR Zones */}
{activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && ( {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} /> <HRZoneBar zones={activity.hr_zones} />
</div> </div>
)} )}
{/* Metric selector */} {/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3> <h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{METRICS.map(({ key, label, color }) => ( {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
<button <button
key={key} key={key}
onClick={() => toggleMetric(key)} onClick={() => toggleMetric(key)}
@@ -134,15 +172,16 @@ export default function ActivityDetailPage() {
))} ))}
</div> </div>
</div> </div>
{dataPoints && dataPoints.length > 0 ? (
{dataPoints && (
<MetricTimeline <MetricTimeline
dataPoints={dataPoints} dataPoints={dataPoints}
activeMetrics={activeMetrics} activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
metrics={METRICS} metrics={METRICS}
onHoverDistance={setHoveredDistance} onHoverDistance={setHoveredDistance}
sportType={activity.sport_type} sportType={activity.sport_type}
/> />
) : (
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
)} )}
</div> </div>
+45 -71
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' 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 api from '../utils/api'
import StatCard from '../components/ui/StatCard' import StatCard from '../components/ui/StatCard'
import { import {
@@ -10,18 +10,29 @@ import {
} from '../utils/format' } from '../utils/format'
function WeeklyChart({ activities }) { function WeeklyChart({ activities }) {
if (!activities?.length) return null if (!activities?.length) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
)
// Build last 8 weeks of distance data // Build last 8 weeks in chronological order
const weeks = {} const now = new Date()
activities.forEach(a => { const weeks = eachWeekOfInterval({
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d') start: subWeeks(startOfWeek(now), 7),
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 } end: startOfWeek(now),
weeks[week].km += (a.distance_m || 0) / 1000
weeks[week].runs++
}) })
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 ( return (
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
@@ -30,10 +41,8 @@ function WeeklyChart({ activities }) {
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} /> <XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => `${v.toFixed(0)}`} /> tickFormatter={v => `${v.toFixed(0)}`} />
<Tooltip <Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }} formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
formatter={(v, name) => [`${v.toFixed(1)} km`, 'Distance']}
/>
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} /> <Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -50,10 +59,7 @@ export default function DashboardPage() {
queryKey: ['activities-all-chart'], queryKey: ['activities-all-chart'],
queryFn: () => queryFn: () =>
api.get('/activities/', { api.get('/activities/', {
params: { params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
per_page: 100,
from_date: subDays(new Date(), 60).toISOString(),
},
}).then(r => r.data), }).then(r => r.data),
}) })
@@ -68,64 +74,45 @@ export default function DashboardPage() {
}) })
const latest = healthSummary?.latest const latest = healthSummary?.latest
const totalActivities = recentActivities?.length ?? 0
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0 const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Dashboard</h1> <h1 className="text-2xl font-bold text-white">Dashboard</h1>
<Link <Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
to="/upload"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
+ Import data
</Link>
</div> </div>
{/* Top stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Activities (10)" value={totalActivities} /> <StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" /> <StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" /> <StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} /> <StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Weekly distance chart */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} /> <WeeklyChart activities={allActivities} />
</div> </div>
{/* Health snapshot */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3> <h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? ( {latest ? (
<> <>
<div className="flex justify-between text-sm"> {[
<span className="text-gray-500">HRV</span> ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span> ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
</div> ['Steps', latest.steps?.toLocaleString() ?? '--'],
<div className="flex justify-between text-sm"> ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
<span className="text-gray-500">Sleep score</span> ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span> ].map(([label, val]) => (
</div> <div key={label} className="flex justify-between text-sm">
<div className="flex justify-between text-sm"> <span className="text-gray-500">{label}</span>
<span className="text-gray-500">Steps</span> <span className="text-white">{val}</span>
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span> </div>
</div> ))}
<div className="flex justify-between text-sm"> <Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
<span className="text-gray-500">VO2 Max</span>
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Stress</span>
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
</div>
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
View full health dashboard
</Link>
</> </>
) : ( ) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p> <p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
@@ -141,29 +128,17 @@ export default function DashboardPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{recentActivities?.slice(0, 5).map(activity => ( {recentActivities?.slice(0, 5).map(activity => (
<Link <Link key={activity.id} to={`/activities/${activity.id}`}
key={activity.id} className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="text-lg">{sportIcon(activity.sport_type)}</span> <span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p> <p className="text-sm font-medium text-white truncate">{activity.name}</p>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p> <p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div> </div>
<div className="flex gap-4 text-sm text-right"> <div className="flex gap-4 text-sm text-right">
<div> <div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p> <div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
<p className="text-xs text-gray-600">dist</p> <div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
</div>
<div>
<p className="text-gray-200">{formatDuration(activity.duration_s)}</p>
<p className="text-xs text-gray-600">time</p>
</div>
<div>
<p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
<p className="text-xs text-gray-600">HR</p>
</div>
</div> </div>
</Link> </Link>
))} ))}
@@ -175,7 +150,6 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* PRs snapshot */}
{records?.length > 0 && ( {records?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
+73 -132
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
LineChart, Line, AreaChart, Area, BarChart, Bar, LineChart, Line, AreaChart, Area, BarChart, Bar,
@@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [ const RANGES = [
{ label: '1W', days: 7 },
{ label: '2W', days: 14 }, { label: '2W', days: 14 },
{ label: '1M', days: 30 }, { label: '1M', days: 30 },
{ label: '3M', days: 90 }, { label: '3M', days: 90 },
@@ -17,7 +18,13 @@ const RANGES = [
{ label: '1Y', days: 365 }, { label: '1Y', days: 365 },
] ]
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
function MetricChart({ data, dataKey, color, formatter, height = 140 }) { function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
)
return ( return (
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}> <AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
@@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis <XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
dataKey="date" tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
tick={{ fontSize: 10, fill: '#6b7280' }} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
axisLine={false} tickFormatter={formatter} />
tickLine={false} <Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
tickFormatter={d => format(new Date(d), 'MMM d')} formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
interval="preserveStartEnd" <Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
/> fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
) )
@@ -71,7 +56,8 @@ function SleepChart({ data }) {
light: d.sleep_light_s ? +(d.sleep_light_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, 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 <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
return ( return (
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}> <BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
@@ -80,9 +66,8 @@ function SleepChart({ data }) {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} /> tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }} <Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} /> <Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" /> <Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} /> <Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
@@ -92,21 +77,22 @@ function SleepChart({ data }) {
} }
export default function HealthPage() { 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({ const { data: summary } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data), queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
}) })
const { data: metrics } = useQuery({ const { data: metrics, isLoading } = useQuery({
queryKey: ['health-metrics', rangeDays], queryKey: ['health-metrics', rangeDays],
queryFn: () => queryFn: () =>
api.get('/health-metrics/', { api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays }, params: { from_date: fromDate, limit: rangeDays + 1 },
}).then(r => r.data.reverse()), }).then(r => r.data.slice().reverse()), // oldest first for charts
keepPreviousData: true,
}) })
const latest = summary?.latest const latest = summary?.latest
@@ -118,132 +104,75 @@ export default function HealthPage() {
{/* Summary cards */} {/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard <StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
label="Resting HR" sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
value={formatHeartRate(latest?.resting_hr)} <StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} sub={latest?.hrv_status || undefined} />
accent="red" <StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
/> sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
<StatCard <StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
label="HRV" sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'} <StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.hrv_status || undefined} sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
/> <StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
<StatCard sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
label="Sleep" <StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
value={formatSleep(latest?.sleep_duration_s)} <StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined}
/>
<StatCard
label="Weight"
value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined}
/>
<StatCard
label="VO2 Max"
value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined}
accent="blue"
/>
<StatCard
label="Steps"
value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined}
/>
<StatCard
label="Avg Stress"
value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'}
/>
<StatCard
label="SpO2"
value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'}
/>
</div> </div>
{/* Range selector */} {/* Range selector */}
<div className="flex gap-2"> <div className="flex gap-2">
{RANGES.map(({ label, days }) => ( {RANGES.map(({ label, days }) => (
<button <button key={label} onClick={() => setRangeDays(days)}
key={label}
onClick={() => setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
? 'bg-blue-600 border-blue-600 text-white' }`}>
: 'border-gray-700 text-gray-400 hover:text-white'
}`}
>
{label} {label}
</button> </button>
))} ))}
</div> </div>
{metrics && metrics.length > 0 ? ( {isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Resting HR */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart <MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
data={metrics} formatter={v => `${Math.round(v)} bpm`} />
dataKey="resting_hr"
color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
/>
</div> </div>
{/* HRV */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart <MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
data={metrics} formatter={v => `${Math.round(v)} ms`} />
dataKey="hrv_nightly_avg"
color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
/>
</div> </div>
{/* Sleep */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
<SleepChart data={metrics} /> <SleepChart data={metrics} />
<div className="flex gap-4 mt-2"> <div className="flex gap-4 mt-2">
{[ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
{ label: 'Deep', color: '#6366f1' }, <div key={l} className="flex items-center gap-1.5">
{ label: 'REM', color: '#8b5cf6' }, <div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
{ label: 'Light', color: '#a78bfa' }, <span className="text-xs text-gray-400">{l}</span>
{ label: 'Awake', color: '#374151' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Weight */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart <MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
data={metrics} formatter={v => `${v.toFixed(1)} kg`} />
dataKey="weight_kg"
color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
/>
</div> </div>
{/* VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart <MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
data={metrics}
dataKey="vo2max"
color="#3b82f6"
formatter={v => v.toFixed(1)}
/>
</div> </div>
{/* Steps */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
@@ -253,18 +182,30 @@ export default function HealthPage() {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }} <Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} /> <Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)} />
</div>
</div> </div>
) : ( ) : (
<div className="text-center py-16 text-gray-600"> <div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p> <p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data yet</p> <p className="text-lg">No health data for this period</p>
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p> <p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
</div> </div>
)} )}
</div> </div>
+266
View File
@@ -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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
{children}
</div>
)
}
function Field({ label, hint, children }) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
{children}
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
</div>
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
<div className="flex items-center gap-3 pt-1">
<button onClick={onClick} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{loading ? 'Saving…' : label}
</button>
{saved && <span className="text-green-400 text-sm"> Saved</span>}
</div>
)
}
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 (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */}
<Section title="Heart Rate & Measurements">
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
{effectiveMaxHr && (
<div className="mt-2 text-white">
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
{!profile?.max_heart_rate && ' (estimated from age)'}
{' · '}Zones: Z1 &lt;{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 &gt;{Math.round(effectiveMaxHr * 0.9)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
</Field>
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
</Field>
<Field label="Birth year" hint="Used to estimate max HR if not set above">
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
</Field>
<Field label="Height (cm)">
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => updateProfile.mutate(Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
))}
loading={updateProfile.isPending}
saved={hrSaved}
/>
</Section>
{/* Weight log */}
<Section title="Weight Log">
<div className="grid grid-cols-3 gap-3">
<Field label="Weight (kg)">
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
</Field>
<Field label="Body fat % (optional)">
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
</Field>
<Field label="Date">
<Input type="datetime-local" value={weightForm.date}
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => 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 && (
<div className="mt-2">
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
<div className="space-y-1 max-h-48 overflow-y-auto">
{weightLog.slice(0, 20).map(entry => (
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
<button onClick={() => deleteWeight.mutate(entry.id)}
className="text-gray-700 hover:text-red-400 text-xs transition-colors"></button>
</div>
))}
</div>
</div>
)}
</Section>
{/* Password change */}
<Section title="Change Password">
<div className="space-y-3">
<Field label="Current password">
<Input type="password" value={pwForm.current_password}
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
</Field>
<Field label="New password (min 8 characters)">
<Input type="password" value={pwForm.new_password}
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
</Field>
<Field label="Confirm new password">
<Input type="password" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
</Field>
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
</div>
<SaveButton
onClick={() => {
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"
/>
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
<p className="text-xs text-gray-500">
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
</p>
<div className="space-y-3">
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
</Field>
<Field label="Client ID">
<Input value={pidForm.client_id} placeholder="milevault"
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
</Field>
<Field label="Client secret" hint="Leave blank to keep existing secret">
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
</div>
<SaveButton
onClick={() => savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
</Section>
)}
</div>
)
}
+64 -83
View File
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api' 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() { export default function RoutesPage() {
const [selected, setSelected] = useState(null) const [selected, setSelected] = useState(null)
@@ -20,18 +20,19 @@ export default function RoutesPage() {
enabled: !!selected, enabled: !!selected,
}) })
const { data: segments } = useQuery({ const { data: recentActivities } = useQuery({
queryKey: ['route-segments', selected?.id], queryKey: ['recent-activities-for-route'],
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data), queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: !!selected, enabled: showCreate,
}) })
const createRoute = useMutation({ const createRoute = useMutation({
mutationFn: (data) => api.post('/routes/', data).then(r => r.data), mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
onSuccess: () => { onSuccess: (route) => {
qc.invalidateQueries({ queryKey: ['routes'] }) qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false) setShowCreate(false)
setNewRoute({ name: '', activity_id: '' }) setNewRoute({ name: '', activity_id: '' })
setSelected(route)
}, },
}) })
@@ -40,55 +41,62 @@ export default function RoutesPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Named Routes</h1> <div>
<button <h1 className="text-2xl font-bold text-white">Named Routes</h1>
onClick={() => setShowCreate(true)} <p className="text-xs text-gray-500 mt-1">
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors" Routes are auto-detected when you run the same path twice. You can also create them manually.
> </p>
</div>
<button onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route + New route
</button> </button>
</div> </div>
{/* Create route modal */} {/* Create route */}
{showCreate && ( {showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3> <h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
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.
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Route name</label> <label className="text-xs text-gray-400 mb-1 block">Route name</label>
<input <input value={newRoute.name}
value={newRoute.name}
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))} onChange={e => 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" 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" />
/>
</div> </div>
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label> <label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
<input {recentActivities?.length === 0 ? (
type="number" <p className="text-xs text-gray-600 py-2">No recent activities found.</p>
value={newRoute.activity_id} ) : (
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))} <select
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" value={newRoute.activity_id}
placeholder="Activity ID" onChange={e => 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"
>
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
</option>
))}
</select>
)}
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })} onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !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" className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
>
Create Create
</button> </button>
<button <button onClick={() => setShowCreate(false)}
onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
Cancel Cancel
</button> </button>
</div> </div>
@@ -98,23 +106,24 @@ export default function RoutesPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Route list */} {/* Route list */}
<div className="space-y-2"> <div className="space-y-2">
{routes?.length === 0 && ( {routes?.length === 0 && !showCreate && (
<div className="text-center py-12 text-gray-600"> <div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p> <p className="text-3xl mb-2">🗺</p>
<p className="text-sm">No named routes yet</p> <p className="text-sm">No named routes yet</p>
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
</div> </div>
)} )}
{routes?.map(route => ( {routes?.map(route => (
<button <button key={route.id} onClick={() => setSelected(route)}
key={route.id}
onClick={() => setSelected(route)}
className={`w-full text-left p-4 rounded-xl border transition-all ${ className={`w-full text-left p-4 rounded-xl border transition-all ${
selected?.id === route.id selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
? 'bg-blue-900/20 border-blue-700' }`}>
: 'bg-gray-900 border-gray-800 hover:border-gray-600' <div className="flex items-start justify-between">
}`} <p className="font-medium text-white">{route.name}</p>
> {route.auto_detected && (
<p className="font-medium text-white">{route.name}</p> <span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
)}
</div>
<div className="flex gap-3 mt-1 text-xs text-gray-500"> <div className="flex gap-3 mt-1 text-xs text-gray-500">
<span>{formatDistance(route.distance_m)}</span> <span>{formatDistance(route.distance_m)}</span>
{route.sport_type && <span className="capitalize">{route.sport_type}</span>} {route.sport_type && <span className="capitalize">{route.sport_type}</span>}
@@ -128,19 +137,20 @@ export default function RoutesPage() {
{selected && ( {selected && (
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2> <div className="flex items-start justify-between mb-3">
{selected.description && ( <h2 className="text-lg font-semibold text-white">{selected.name}</h2>
<p className="text-sm text-gray-400 mb-3">{selected.description}</p> {selected.auto_detected && (
)} <span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
Auto-detected
</span>
)}
</div>
{/* CR */}
{fastest && ( {fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4"> <div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record</p> <p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400"> <span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
{formatDuration(fastest.duration_s)}
</span>
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)} {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span> </span>
@@ -148,16 +158,12 @@ export default function RoutesPage() {
</div> </div>
)} )}
{/* All runs on route */}
<h3 className="text-sm font-medium text-gray-400 mb-2"> <h3 className="text-sm font-medium text-gray-400 mb-2">
All runs ({routeActivities?.length ?? 0}) All runs ({routeActivities?.length ?? 0})
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{routeActivities?.map((act, i) => ( {routeActivities?.map((act, i) => (
<div <div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
key={act.id}
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
>
<span className="text-gray-600 w-5 text-right">{i + 1}</span> <span className="text-gray-600 w-5 text-right">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span> <span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span> <span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
@@ -166,37 +172,12 @@ export default function RoutesPage() {
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span> <span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)} )}
{i === 0 && ( {i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40"> <span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
CR
</span>
)} )}
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Segments */}
{segments && segments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<div className="space-y-2">
{segments.map(seg => (
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
<div>
<p className="text-sm font-medium text-white">{seg.name}</p>
{seg.description && (
<p className="text-xs text-gray-500">{seg.description}</p>
)}
</div>
<div className="text-xs text-gray-400 text-right">
<p>{formatDistance(seg.start_distance_m)} {formatDistance(seg.end_distance_m)}</p>
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>
+14 -4
View File
@@ -10,8 +10,7 @@ export function formatDuration(seconds) {
export function formatPace(speedMs, sportType = 'running') { export function formatPace(speedMs, sportType = 'running') {
if (!speedMs || speedMs <= 0) return '--' if (!speedMs || speedMs <= 0) return '--'
if (sportType === 'cycling') { if (sportType === 'cycling') {
const kph = speedMs * 3.6 return `${(speedMs * 3.6).toFixed(1)} km/h`
return `${kph.toFixed(1)} km/h`
} }
const secsPerKm = 1000 / speedMs const secsPerKm = 1000 / speedMs
const mins = Math.floor(secsPerKm / 60) 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) { export function hrZoneColor(zone) {
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' } const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
return colors[zone] || '#9ca3af' return colors[zone] || '#9ca3af'
@@ -69,7 +79,7 @@ export function hrZoneColor(zone) {
export function sportIcon(sportType) { export function sportIcon(sportType) {
const icons = { const icons = {
running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾', running: '🏃', cycling: '🚴', hiking: '🥾',
walking: '🚶', other: '⚡', walking: '🚶', other: '⚡',
} }
return icons[sportType?.toLowerCase()] || '⚡' return icons[sportType?.toLowerCase()] || '⚡'
@@ -77,7 +87,7 @@ export function sportIcon(sportType) {
export function sportColor(sportType) { export function sportColor(sportType) {
const colors = { const colors = {
running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4', running: '#3b82f6', cycling: '#f97316',
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280', hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
} }
return colors[sportType?.toLowerCase()] || '#6b7280' return colors[sportType?.toLowerCase()] || '#6b7280'
+34
View File
@@ -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=
@@ -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=
+12
View File
@@ -0,0 +1,12 @@
.env
node_modules/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.DS_Store
*.sql.bak
db_data/
redis_data/
file_data/
+153
View File
@@ -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=<token from Gitea → Settings → Runners> \
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)
```
+16
View File
@@ -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"]
@@ -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"]
@@ -0,0 +1,213 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
router = APIRouter()
class ActivitySummary(BaseModel):
id: int
name: str
sport_type: str
start_time: datetime
distance_m: Optional[float]
duration_s: Optional[float]
elevation_gain_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
calories: Optional[float]
polyline: Optional[str]
bounding_box: Optional[dict]
hr_zones: Optional[dict]
named_route_id: Optional[int]
class Config:
from_attributes = True
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
elevation_loss_m: Optional[float]
max_heart_rate: Optional[float]
avg_power: Optional[float]
normalized_power: Optional[float]
max_speed_ms: Optional[float]
avg_temperature_c: Optional[float]
training_stress_score: Optional[float]
vo2max_estimate: Optional[float]
class DataPointOut(BaseModel):
timestamp: Optional[datetime]
latitude: Optional[float]
longitude: Optional[float]
altitude_m: Optional[float]
heart_rate: Optional[float]
cadence: Optional[float]
speed_ms: Optional[float]
power: Optional[float]
temperature_c: Optional[float]
distance_m: Optional[float]
class Config:
from_attributes = True
class LapOut(BaseModel):
lap_number: int
start_time: Optional[datetime]
duration_s: Optional[float]
distance_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
avg_power: Optional[float]
class Config:
from_attributes = True
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
sport_type: Optional[str] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Activity).where(Activity.user_id == current_user.id)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
q = q.order_by(desc(Activity.start_time))
q = q.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(q)
return result.scalars().all()
@router.get("/{activity_id}", response_model=ActivityDetail)
async def get_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
async def get_data_points(
activity_id: int,
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify ownership
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
q = select(ActivityDataPoint).where(
ActivityDataPoint.activity_id == activity_id
).order_by(ActivityDataPoint.timestamp)
result = await db.execute(q)
points = result.scalars().all()
if downsample > 1:
points = points[::downsample]
return points
@router.get("/{activity_id}/laps", response_model=List[LapOut])
async def get_laps(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
result = await db.execute(
select(ActivityLap)
.where(ActivityLap.activity_id == activity_id)
.order_by(ActivityLap.lap_number)
)
return result.scalars().all()
@router.patch("/{activity_id}/name")
async def rename_activity(
activity_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.name = body.get("name", activity.name)
await db.commit()
return {"id": activity_id, "name": activity.name}
@router.delete("/{activity_id}", status_code=204)
async def delete_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(activity)
await db.commit()
+122
View File
@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
import httpx
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, get_current_user
from app.core.config import settings
from app.models.user import User
router = APIRouter()
async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
admin = result.scalar_one_or_none()
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
return issuer, client_id, client_secret
class Token(BaseModel):
access_token: str
token_type: str
user_id: int
username: str
is_admin: bool
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
class Config:
from_attributes = True
@router.post("/token", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)})
return Token(access_token=token, token_type="bearer",
user_id=user.id, username=user.username, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/pocketid/available")
async def pocketid_available(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url")
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{issuer}/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": client_secret},
)
if resp.status_code != 200:
raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json()
userinfo_resp = await client.get(
f"{issuer}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
if not user:
user = User(username=preferred_username, email=email, pocketid_sub=sub)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
+156
View File
@@ -0,0 +1,156 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, HealthMetric
router = APIRouter()
class HealthMetricOut(BaseModel):
id: int
date: datetime
resting_hr: Optional[float]
max_hr_day: Optional[float]
avg_hr_day: Optional[float]
hrv_nightly_avg: Optional[float]
hrv_status: Optional[str]
hrv_5min_high: Optional[float]
hrv_5min_low: Optional[float]
sleep_duration_s: Optional[float]
sleep_deep_s: Optional[float]
sleep_light_s: Optional[float]
sleep_rem_s: Optional[float]
sleep_awake_s: Optional[float]
sleep_score: Optional[float]
sleep_start: Optional[datetime]
sleep_end: Optional[datetime]
weight_kg: Optional[float]
bmi: Optional[float]
body_fat_pct: Optional[float]
muscle_mass_kg: Optional[float]
vo2max: Optional[float]
fitness_age: Optional[int]
training_load: Optional[float]
recovery_time_h: Optional[float]
avg_stress: Optional[float]
steps: Optional[int]
floors_climbed: Optional[int]
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
class Config:
from_attributes = True
@router.get("/", response_model=List[HealthMetricOut])
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
q = q.where(HealthMetric.date >= from_date)
if to_date:
q = q.where(HealthMetric.date <= to_date)
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@router.get("/summary")
async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
.order_by(desc(HealthMetric.date))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
func.avg(HealthMetric.avg_stress).label("avg_stress"),
func.avg(HealthMetric.steps).label("avg_steps"),
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
)
)
avgs = avg_result.one()
return {
"latest": HealthMetricOut.model_validate(latest) if latest else None,
"avg_30d": {
"resting_hr": avgs.avg_resting_hr,
"hrv": avgs.avg_hrv,
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
"sleep_score": avgs.avg_sleep_score,
"stress": avgs.avg_stress,
"steps": avgs.avg_steps,
"weight_kg": avgs.avg_weight,
},
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually add or update a health metric for a given date."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
date_str = body.get("date")
if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date.date(),
)
)
metric = existing.scalar_one_or_none()
if metric:
for key, val in body.items():
if hasattr(metric, key) and key not in ("id", "user_id"):
setattr(metric, key, val)
else:
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
k: v for k, v in body.items()
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
})
db.add(metric)
await db.commit()
return {"status": "ok"}
+220
View File
@@ -0,0 +1,220 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
class Config:
from_attributes = True
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
await db.commit()
await db.refresh(current_user)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None:
current_user.pocketid_client_secret = body.client_secret or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = 365,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
router = APIRouter()
# ─── Personal Records ────────────────────────────────────────────────────────
class PROut(BaseModel):
id: int
sport_type: str
distance_m: float
distance_label: str
duration_s: float
achieved_at: datetime
activity_id: int
class Config:
from_attributes = True
@router.get("/", response_model=List[PROut])
async def list_records(
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.is_current_record == True,
)
if sport_type:
q = q.where(PersonalRecord.sport_type == sport_type)
q = q.order_by(PersonalRecord.distance_m)
result = await db.execute(q)
return result.scalars().all()
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
sport_type: str = "running",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Show progression of a PR over time."""
result = await db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.sport_type == sport_type,
PersonalRecord.distance_label == distance_label,
).order_by(PersonalRecord.achieved_at)
)
return result.scalars().all()
+232
View File
@@ -0,0 +1,232 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
class Config:
from_attributes = True
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
+134
View File
@@ -0,0 +1,134 @@
import os
import shutil
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import User
from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename
with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f)
return dest
@router.post("/activity")
async def upload_activity(
file: UploadFile = File(...),
background_tasks: BackgroundTasks = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a single .fit or .gpx activity file."""
suffix = Path(file.filename).suffix.lower()
if suffix not in {".fit", ".gpx"}:
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
dest = save_upload(file, dest_dir)
# Queue processing
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
return {"task_id": task.id, "status": "queued", "filename": file.filename}
@router.post("/garmin-export")
async def upload_garmin_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Upload a full Garmin Connect data export ZIP.
Processes all FIT files for activities + wellness data.
"""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
# Extract and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit"):
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
"health_task": health_task.id,
}
@router.post("/strava-export")
async def upload_strava_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit") or lower.endswith(".gpx"):
file_path = extract_dir / name
ext = Path(name).suffix[1:]
task = process_activity_file.delay(str(file_path), current_user.id, ext)
task_ids.append(task.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
}
@router.get("/task/{task_id}")
async def check_task_status(
task_id: str,
current_user: User = Depends(get_current_user),
):
"""Check the status of an upload processing task."""
from app.workers.celery_app import celery_app
result = celery_app.AsyncResult(task_id)
return {
"task_id": task_id,
"status": result.status,
"result": result.result if result.ready() else None,
}
@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = Field(..., env="DATABASE_URL")
# Redis
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
# Auth
secret_key: str = Field(..., env="SECRET_KEY")
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Admin account - optional so the worker (which doesn't seed users) can start
# without it. The backend service checks this at seed time.
admin_username: str = Field("admin", env="ADMIN_USERNAME")
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
# PocketID OIDC (optional)
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
# Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment
environment: str = Field("production", env="ENVIRONMENT")
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()
@@ -0,0 +1,47 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
# Async engine for FastAPI
engine = create_async_engine(
settings.database_url,
echo=settings.environment == "development",
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Sync engine for Celery workers (Celery + asyncio don't mix well)
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
sync_engine = create_engine(
sync_url,
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
+105
View File
@@ -0,0 +1,105 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import text
import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload, profile
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup.
Multiple uvicorn workers may race here on first start. We tolerate
duplicate table errors since they just mean another worker got there first.
"""
for attempt in range(15):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
print("Tables already created by another worker - skipping")
break
if attempt == 14:
raise
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
await asyncio.sleep(2)
# Try TimescaleDB hypertable (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"SELECT create_hypertable('activity_data_points', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
))
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
return
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
except Exception as e:
msg = str(e).lower()
if "duplicate" in msg or "unique" in msg:
print("Admin user already exists - skipping seed")
else:
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="MileVault",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.environment == "development" else [],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@app.get("/health")
async def healthcheck():
return {"status": "ok"}
+227
View File
@@ -0,0 +1,227 @@
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint
)
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
def now_utc():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(256), unique=True, nullable=True)
hashed_password = Column(String(256), nullable=True)
is_admin = Column(Boolean, default=False)
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
# Health profile
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
pocketid_client_id = Column(String(256), nullable=True)
pocketid_client_secret = Column(String(256), nullable=True)
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
class WeightLog(Base):
"""Manual weight entries separate from health_metrics for easy tracking."""
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
weight_kg = Column(Float, nullable=False)
body_fat_pct = Column(Float, nullable=True)
note = Column(String(256), nullable=True)
__table_args__ = (
Index("ix_weight_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="weight_logs")
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
max_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
normalized_power = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
max_speed_ms = Column(Float, nullable=True)
avg_temperature_c = Column(Float, nullable=True)
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
vo2max_estimate = Column(Float, nullable=True)
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
hr_zones = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="activities")
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
named_route = relationship("NamedRoute", back_populates="activities")
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
class ActivityDataPoint(Base):
__tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
altitude_m = Column(Float, nullable=True)
heart_rate = Column(Float, nullable=True)
cadence = Column(Float, nullable=True)
speed_ms = Column(Float, nullable=True)
power = Column(Float, nullable=True)
temperature_c = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="data_points")
class ActivityLap(Base):
__tablename__ = "activity_laps"
id = Column(Integer, primary_key=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
lap_number = Column(Integer, nullable=False)
start_time = Column(DateTime(timezone=True), nullable=True)
duration_s = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="laps")
class NamedRoute(Base):
__tablename__ = "named_routes"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base):
__tablename__ = "route_segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
route = relationship("NamedRoute", back_populates="segments")
class PersonalRecord(Base):
__tablename__ = "personal_records"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
sport_type = Column(String(64), nullable=False)
distance_m = Column(Float, nullable=False)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
__table_args__ = (
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
name="uq_pr_current"),
)
class HealthMetric(Base):
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
sleep_rem_s = Column(Float, nullable=True)
sleep_awake_s = Column(Float, nullable=True)
sleep_score = Column(Float, nullable=True)
sleep_start = Column(DateTime(timezone=True), nullable=True)
sleep_end = Column(DateTime(timezone=True), nullable=True)
weight_kg = Column(Float, nullable=True)
bmi = Column(Float, nullable=True)
body_fat_pct = Column(Float, nullable=True)
muscle_mass_kg = Column(Float, nullable=True)
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
avg_stress = Column(Float, nullable=True)
steps = Column(Integer, nullable=True)
floors_climbed = Column(Integer, nullable=True)
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
Index("ix_health_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="health_metrics")
@@ -0,0 +1,307 @@
"""
FIT and GPX file parser using:
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
- gpxpy for .gpx files
The official SDK correctly handles scale/offset, component expansion,
semicircle-to-degree conversion, and HR message merging.
"""
import math
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional
import gpxpy
import polyline as polyline_lib
FIT_EPOCH_S = 631065600
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return 2 * R * math.asin(math.sqrt(a))
def _safe_float(val) -> Optional[float]:
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def _bounding_box(coords: list) -> Optional[dict]:
if not coords:
return None
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons)}
def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
session = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
stream = Stream.from_file(filepath)
decoder = Decoder(stream)
decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
# Map sport type
sport = str(session.get("sport", "generic")).lower()
sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running",
"e_biking": "cycling",
}
sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time")
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
# Build GPS track
coords = [
(r["position_lat"], r["position_long"])
for r in records
if r.get("position_lat") is not None and r.get("position_long") is not None
]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = []
for r in records:
ts = r.get("timestamp")
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": r.get("position_lat"),
"longitude": r.get("position_long"),
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
"heart_rate": r.get("heart_rate"),
"cadence": r.get("cadence") or r.get("fractional_cadence"),
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
"power": r.get("power"),
"temperature_c": r.get("temperature"),
"distance_m": r.get("distance"),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": _safe_float(lap.get("total_elapsed_time")),
"distance_m": _safe_float(lap.get("total_distance")),
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
"avg_cadence": _safe_float(lap.get("avg_cadence")),
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
"avg_power": _safe_float(lap.get("avg_power")),
})
# Build activity name
name = session.get("sport", "Activity").title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
return {
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")),
"duration_s": _safe_float(session.get("total_elapsed_time")),
"elevation_gain_m": _safe_float(session.get("total_ascent")),
"elevation_loss_m": _safe_float(session.get("total_descent")),
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
"avg_cadence": _safe_float(session.get("avg_cadence")),
"avg_power": _safe_float(session.get("avg_power")),
"normalized_power": _safe_float(session.get("normalized_power")),
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
"calories": _safe_float(session.get("total_calories")),
"training_stress_score": _safe_float(session.get("training_stress_score")),
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"data_points": normalized_points,
"laps": normalized_laps,
}
def parse_gpx_file(filepath: str) -> dict:
"""Parse a GPX file."""
with open(filepath) as f:
gpx = gpxpy.parse(f)
data_points = []
track = gpx.tracks[0] if gpx.tracks else None
if not track:
raise ValueError("No tracks found in GPX file")
for segment in track.segments:
for pt in segment.points:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
for child in ext:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
try:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude,
"longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
"speed_ms": extensions.get("speed"),
"power": extensions.get("power"),
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
"distance_m": None,
})
coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
diff = alts[i] - alts[i-1]
if diff > 0:
uphill += diff
else:
downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = "running"
if track.type:
sport = track.type.lower()
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport,
"start_time": start_time_str,
"distance_m": total_dist,
"duration_s": duration,
"elevation_gain_m": uphill,
"elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None,
"avg_power": None,
"normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"max_speed_ms": None,
"avg_temperature_c": None,
"calories": None,
"training_stress_score": None,
"vo2max_estimate": None,
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "gpx",
"data_points": data_points,
"laps": [],
}
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
"""
Calculate % time in each HR zone using the user's configured max HR.
Zones follow the standard 5-zone model as % of max HR:
Z1 Recovery: < 60%
Z2 Base: 60 - 70%
Z3 Tempo: 70 - 80%
Z4 Threshold: 80 - 90%
Z5 Max: > 90%
user_max_hr should be the user's actual physiological max HR, NOT the
highest HR recorded in this activity. Using activity max shifts all zones
upward and makes easy runs look harder than they are.
"""
if not user_max_hr or user_max_hr < 100:
return {}
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr or hr < 20:
continue
pct = hr / user_max_hr
total += 1
for i, key in enumerate(zone_keys):
if zone_bounds[i] <= pct < zone_bounds[i+1]:
zones[key] += 1
break
else:
zones["z5"] += 1 # anything above 90% goes to z5
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
@@ -0,0 +1,190 @@
"""
Route matching: identifies when multiple activities were on the same route.
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
"""
import math
from typing import Optional
import polyline as polyline_lib
import numpy as np
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
return polyline_lib.decode(encoded)
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
return (
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
)
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
if len(coords) <= n:
return coords
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
return [coords[i] for i in indices]
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
"""
Compute DTW distance between two GPS tracks.
Each point is (lat, lon). Returns average distance in metres per matched pair.
"""
n, m = len(track1), len(track2)
dtw = np.full((n + 1, m + 1), np.inf)
dtw[0][0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = haversine_m(track1[i-1], track2[j-1])
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
return dtw[n][m] / max(n, m)
def haversine_m(p1: tuple, p2: tuple) -> float:
R = 6371000
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
return 2 * R * math.asin(math.sqrt(a))
def routes_are_similar(
poly1: str,
poly2: str,
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
"""
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
try:
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
except Exception:
return False
if not coords1 or not coords2:
return False
dist = dtw_distance(coords1, coords2)
return dist < dtw_threshold_m
def find_segment_times(
data_points: list[dict],
start_dist_m: float,
end_dist_m: float,
) -> Optional[float]:
"""
Given activity data points (with cumulative distance_m),
find the time to traverse from start_dist_m to end_dist_m.
Returns duration in seconds, or None if not found.
"""
start_time = None
end_time = None
for p in data_points:
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
continue
if start_time is None and dist >= start_dist_m:
start_time = ts
if start_time is not None and dist >= end_dist_m:
end_time = ts
break
if start_time and end_time:
from datetime import datetime
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
return None
def find_best_split_time(
data_points: list[dict],
target_distance_m: float,
) -> Optional[float]:
"""
Find the best (fastest) time over any target_distance_m window within an activity.
E.g. fastest 1km split in a 10km run.
Returns duration in seconds.
"""
points_with_dist = [
p for p in data_points
if p.get("distance_m") is not None and p.get("timestamp") is not None
]
if not points_with_dist:
return None
best = None
j = 0
for i, start_p in enumerate(points_with_dist):
start_dist = start_p["distance_m"]
start_ts = start_p["timestamp"]
# Advance j until distance covered >= target
while j < len(points_with_dist):
end_p = points_with_dist[j]
covered = end_p["distance_m"] - start_dist
if covered >= target_distance_m:
from datetime import datetime
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
duration = (t2 - t1).total_seconds()
if best is None or duration < best:
best = duration
break
j += 1
if j >= len(points_with_dist):
break
return best
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
(1000, "1k"),
(1609.34, "1 mile"),
(3000, "3k"),
(5000, "5k"),
(10000, "10k"),
(21097.5, "Half marathon"),
(42195, "Marathon"),
(50000, "50k"),
(100000, "100k"),
]
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
"""Compute best split times for all standard distances that fit within the activity."""
results = {}
for dist_m, label in STANDARD_DISTANCES:
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
best = find_best_split_time(data_points, dist_m)
if best:
results[label] = best
return results
@@ -0,0 +1,309 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The official SDK (garmin-fit-sdk) correctly handles:
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
- Garmin proprietary messages stored by numeric mesg_num
- Unknown fields stored by field definition number
- Scale/offset application, component expansion, HR merging
Fenix 6X proprietary message numbers identified by binary analysis:
55 - activity accumulation snapshots (cumulative steps, HR per interval)
103 - daily totals summary (total steps, floors, calories)
211 - resting HR + HRV summary
227 - per-minute stress level + heart rate (most valuable for health dashboard)
"""
from datetime import datetime, timezone, timedelta, date
from typing import Optional
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
def fit_ts(seconds) -> Optional[datetime]:
"""Convert FIT timestamp to UTC datetime."""
if seconds is None:
return None
try:
s = int(seconds)
if s == 0 or s == 0xFFFFFFFF:
return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return None
def _is_datetime(v) -> bool:
return isinstance(v, datetime)
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
Returns {"days": {date: metrics_dict}, "error": str|None}
"""
try:
from garmin_fit_sdk import Decoder, Stream
except ImportError:
# Fall back to fitparse-based parser if SDK not installed yet
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
return _fb(file_path)
daily = {} # date -> aggregation dict
def ensure_day(d: date) -> dict:
if d not in daily:
daily[d] = {
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
"sleep_levels": [],
"steps": None,
"floors_climbed": None,
"active_calories": None,
"total_calories": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
}
return daily[d]
def get_date(msg: dict, *keys) -> Optional[date]:
"""Extract a date from a message, trying multiple field names."""
for key in keys:
v = msg.get(key)
if v is None:
continue
if _is_datetime(v):
return v.date()
if isinstance(v, (int, float)):
dt = fit_ts(v)
if dt:
return dt.date()
return None
def listener(mesg_num: int, msg: dict):
"""Called for every message after full decoding."""
# ── Standard: monitoring (148) ────────────────────────────────────
if mesg_num == 148:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress))
# ── Standard: monitoring_info (147) ───────────────────────────────
elif mesg_num == 147:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
rhr = msg.get("resting_heart_rate")
if rhr and 20 < rhr < 120:
ensure_day(d)["resting_hr"] = int(rhr)
# ── Standard: hrv_status_summary (275) ────────────────────────────
elif mesg_num == 275:
d = get_date(msg, "timestamp")
if not d:
return
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status")
if status:
entry["hrv_status"] = str(status)
# ── Standard: stress_level (132) ──────────────────────────────────
elif mesg_num == 132:
d = get_date(msg, "stress_level_time", "timestamp")
if not d:
return
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress))
# ── Standard: spo2_data (258) ─────────────────────────────────────
elif mesg_num == 258:
d = get_date(msg, "timestamp")
if not d:
return
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2))
# ── Standard: sleep_level (269) ───────────────────────────────────
elif mesg_num == 269:
d = get_date(msg, "timestamp")
if not d:
return
level = msg.get("sleep_level")
if level is not None:
# Convert string level names to numeric codes if SDK decoded them
if isinstance(level, str):
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
level = level_map.get(level.lower())
if level is not None:
ensure_day(d)["sleep_levels"].append(int(level))
# ── Proprietary 227: per-minute stress + HR ───────────────────────
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
elif mesg_num == 227:
# SDK stores unknown fields as "unknown_N" or by def_num
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
ts_raw if _is_datetime(ts_raw) else None
)
if not ts:
return
entry = ensure_day(ts.date())
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
entry["stress_values"].append(int(stress_raw))
# ── Proprietary 103: daily totals summary ─────────────────────────
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
elif mesg_num == 103:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(3)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = int(steps)
floors = msg.get(4)
if floors and isinstance(floors, (int, float)) and floors > 0:
f = float(floors)
if f > 1000:
f = f / 100
entry["floors_climbed"] = round(f, 1)
active_cal = msg.get(5)
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
entry["active_calories"] = float(active_cal)
total_cal = msg.get(7)
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
entry["total_calories"] = float(total_cal)
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
elif mesg_num == 211:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
rhr = msg.get(0)
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr)
hrv = msg.get(1)
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
entry["hrv_nightly_avg"] = float(hrv)
# ── Proprietary 55: activity accumulation snapshots ───────────────
elif mesg_num == 55:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(2)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
hr = msg.get(19)
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
# Decode the file
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
messages, errors = decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False, # wellness files sometimes have bad CRCs
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
except Exception as e:
return {"error": str(e), "days": {}}
# Aggregate per-day
result = {}
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_levels = data.pop("sleep_levels", [])
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
max_hr = max(hrs) if hrs else None
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Sleep stage seconds (each level record = 30s epoch)
if sleep_levels:
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
"avg_hr_day": avg_hr,
"max_hr_day": max_hr,
"avg_stress": avg_stress,
"spo2_avg": spo2_avg,
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
"hrv_5min_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors_climbed": data.get("floors_climbed"),
"active_calories": data.get("active_calories"),
"total_calories": data.get("total_calories"),
"sleep_duration_s": sleep_duration_s,
"sleep_deep_s": sleep_deep_s,
"sleep_light_s": sleep_light_s,
"sleep_rem_s": sleep_rem_s,
"sleep_awake_s": sleep_awake_s,
}
return {"days": result, "error": None}
@@ -0,0 +1,7 @@
"""
Celery entry point. Re-exports celery_app from tasks so the worker
can be started with: celery -A app.workers.celery_app worker
"""
from app.workers.tasks import celery_app
__all__ = ["celery_app"]
@@ -0,0 +1,451 @@
"""
Background tasks: activity ingestion, route matching, PR calculation.
Uses synchronous SQLAlchemy because Celery's prefork model doesn't play
well with asyncio - each worker process needs its own connection pool,
and async pools don't survive process forks.
"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"milevault",
broker=settings.redis_url,
backend=settings.redis_url,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
)
# Garmin FIT file suffixes that are health/wellness data, not activities
WELLNESS_SUFFIXES = (
"_METRICS.fit",
"_WELLNESS.fit",
"_SLEEP.fit",
"_STRESS.fit",
"_SPO2.fit",
"_HRV.fit",
"_MONITORING.fit",
"_MONITORING_B.fit",
)
def is_wellness_file(file_path: str) -> bool:
name = file_path.upper()
return any(name.endswith(s.upper()) for s in WELLNESS_SUFFIXES)
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
# Route wellness/metrics files to health parser instead
if is_wellness_file(file_path):
parse_wellness_fit.delay(file_path, user_id)
return {"status": "routed_to_wellness", "file": file_path}
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
from app.core.database import SyncSessionLocal
from app.models.user import Activity, ActivityDataPoint, ActivityLap
from sqlalchemy import select
from datetime import datetime
self.update_state(state="PROGRESS", meta={"step": "parsing"})
try:
if source_type == "fit" or file_path.endswith(".fit"):
parsed = parse_fit_file(file_path)
else:
parsed = parse_gpx_file(file_path)
except Exception as e:
raise self.retry(exc=e, countdown=10, max_retries=3)
# Skip files with no usable activity data
if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path}
with SyncSessionLocal() as db:
# Check for duplicate by garmin activity ID
if parsed.get("garmin_activity_id"):
existing = db.execute(
select(Activity).where(
Activity.garmin_activity_id == parsed["garmin_activity_id"]
)
).scalar_one_or_none()
if existing:
return {"activity_id": existing.id, "status": "duplicate"}
# Get user's configured max HR for accurate zone calculation
# Falls back to: user-set value → 220-age → activity max → 190
from app.models.user import User as UserModel
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
user_max_hr = None
if user_obj:
user_max_hr = user_obj.max_heart_rate
if not user_max_hr and user_obj.birth_year:
from datetime import date as _date
age = _date.today().year - user_obj.birth_year
user_max_hr = 220 - age
if not user_max_hr:
# Last resort: use activity max but warn this may shift zones
user_max_hr = parsed.get("max_heart_rate") or 190
hr_zones = calculate_hr_zones(
parsed.get("data_points", []),
user_max_hr
)
start_time = datetime.fromisoformat(parsed["start_time"])
activity = Activity(
user_id=user_id,
name=parsed["name"],
sport_type=parsed["sport_type"],
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"),
max_heart_rate=parsed.get("max_heart_rate"),
avg_cadence=parsed.get("avg_cadence"),
avg_power=parsed.get("avg_power"),
normalized_power=parsed.get("normalized_power"),
avg_speed_ms=parsed.get("avg_speed_ms"),
max_speed_ms=parsed.get("max_speed_ms"),
avg_temperature_c=parsed.get("avg_temperature_c"),
calories=parsed.get("calories"),
training_stress_score=parsed.get("training_stress_score"),
polyline=parsed.get("polyline"),
bounding_box=parsed.get("bounding_box"),
source_file=file_path,
source_type=parsed.get("source_type"),
hr_zones=hr_zones,
)
db.add(activity)
db.flush()
# Insert data points, deduping on (activity_id, timestamp)
seen = set()
points = parsed.get("data_points", [])
batch = []
for p in points:
if not p.get("timestamp"):
continue
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
key = (activity.id, ts)
if key in seen:
continue
seen.add(key)
batch.append(ActivityDataPoint(
activity_id=activity.id,
timestamp=ts,
latitude=p.get("latitude"),
longitude=p.get("longitude"),
altitude_m=p.get("altitude_m"),
heart_rate=p.get("heart_rate"),
cadence=p.get("cadence"),
speed_ms=p.get("speed_ms"),
power=p.get("power"),
temperature_c=p.get("temperature_c"),
distance_m=p.get("distance_m"),
))
if len(batch) >= 500:
db.add_all(batch)
db.flush()
batch = []
if batch:
db.add_all(batch)
db.flush()
# Laps
for lap in parsed.get("laps", []):
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
db.add(ActivityLap(
activity_id=activity.id,
lap_number=lap["lap_number"],
start_time=ls,
duration_s=lap.get("duration_s"),
distance_m=lap.get("distance_m"),
avg_heart_rate=lap.get("avg_heart_rate"),
avg_cadence=lap.get("avg_cadence"),
avg_speed_ms=lap.get("avg_speed_ms"),
avg_power=lap.get("avg_power"),
))
db.commit()
activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed)
# Auto route detection for running and cycling
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@celery_app.task(name="parse_wellness_fit")
def parse_wellness_fit(file_path: str, user_id: int):
"""
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
"""
from app.services.wellness_parser import parse_wellness_fit as _parse
from app.core.database import SyncSessionLocal
from datetime import datetime, timezone
from sqlalchemy import text
result = _parse(file_path)
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
days = result.get("days", {})
if not days:
return {"status": "no_data", "file": file_path}
with SyncSessionLocal() as db:
for day_date, data in days.items():
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
db.execute(text("""
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
steps, floors_climbed, active_calories, total_calories,
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
:steps, :floors, :active_cal, :total_cal,
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": data.get("resting_hr"),
"avg_hr": data.get("avg_hr_day"),
"max_hr": data.get("max_hr_day"),
"avg_stress": data.get("avg_stress"),
"spo2_avg": data.get("spo2_avg"),
"hrv_avg": data.get("hrv_nightly_avg"),
"hrv_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors": data.get("floors_climbed"),
"active_cal": data.get("active_calories"),
"total_cal": data.get("total_calories"),
"sleep_dur": data.get("sleep_duration_s"),
"sleep_deep": data.get("sleep_deep_s"),
"sleep_light": data.get("sleep_light_s"),
"sleep_rem": data.get("sleep_rem_s"),
"sleep_awake": data.get("sleep_awake_s"),
})
db.commit()
return {"status": "ok", "days_processed": len(days), "file": file_path}
@celery_app.task(name="detect_route")
def detect_route(activity_id: int, user_id: int):
"""
After importing an activity, check if it matches any existing named routes.
If two+ unassigned activities match each other, auto-create a named route.
"""
from app.services.route_matcher import routes_are_similar
from app.core.database import SyncSessionLocal
from app.models.user import Activity, NamedRoute
from sqlalchemy import select
with SyncSessionLocal() as db:
# Get the new activity
new_act = db.execute(
select(Activity).where(Activity.id == activity_id)
).scalar_one_or_none()
if not new_act or not new_act.polyline:
return {"status": "no_polyline"}
# Already assigned to a route?
if new_act.named_route_id:
return {"status": "already_assigned"}
# Check against existing named routes first
routes = db.execute(
select(NamedRoute).where(
NamedRoute.user_id == user_id,
NamedRoute.sport_type == new_act.sport_type,
)
).scalars().all()
for route in routes:
if route.reference_polyline and routes_are_similar(
new_act.polyline, route.reference_polyline,
new_act.bounding_box, route.bounding_box,
):
new_act.named_route_id = route.id
db.commit()
return {"status": "matched_existing", "route_id": route.id}
# No existing route matched - check unassigned activities for a match
candidates = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == new_act.sport_type,
Activity.named_route_id == None,
Activity.id != activity_id,
Activity.polyline != None,
# Within 20% distance
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
)
).scalars().all()
for candidate in candidates:
if routes_are_similar(
new_act.polyline, candidate.polyline,
new_act.bounding_box, candidate.bounding_box,
):
# Auto-create a route from the older activity
older = candidate if candidate.start_time < new_act.start_time else new_act
newer = new_act if candidate.start_time < new_act.start_time else candidate
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
new_route = NamedRoute(
user_id=user_id,
name=route_name,
sport_type=older.sport_type,
reference_polyline=older.polyline,
bounding_box=older.bounding_box,
distance_m=older.distance_m,
auto_detected=True,
)
db.add(new_route)
db.flush()
older.named_route_id = new_route.id
newer.named_route_id = new_route.id
db.commit()
return {"status": "auto_created", "route_id": new_route.id}
return {"status": "no_match"}
@celery_app.task(name="compute_personal_records")
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
"""Calculate personal records for standard distances from this activity."""
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
from app.core.database import SyncSessionLocal
from app.models.user import PersonalRecord
from sqlalchemy import select
from datetime import datetime, timezone
data_points = parsed.get("data_points", [])
total_dist = parsed.get("distance_m", 0) or 0
sport = parsed.get("sport_type", "running")
start_time_str = parsed.get("start_time")
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
best_splits = compute_best_splits(data_points, total_dist)
with SyncSessionLocal() as db:
for label, duration_s in best_splits.items():
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
if dist_m is None:
continue
current = db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == user_id,
PersonalRecord.sport_type == sport,
PersonalRecord.distance_m == dist_m,
PersonalRecord.is_current_record == True,
)
).scalar_one_or_none()
if current is None or duration_s < current.duration_s:
if current:
current.is_current_record = False
db.add(PersonalRecord(
user_id=user_id,
activity_id=activity_id,
sport_type=sport,
distance_m=dist_m,
distance_label=label,
duration_s=duration_s,
achieved_at=start_time,
is_current_record=True,
))
db.commit()
@celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int):
"""Extract wellness data from a Garmin Connect export ZIP."""
import zipfile
import json
from app.core.database import SyncSessionLocal
from app.models.user import HealthMetric
from datetime import datetime, timezone
with SyncSessionLocal() as db:
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist():
if "DailyMetrics" not in name or not name.endswith(".json"):
continue
with zf.open(name) as f:
try:
data = json.load(f)
except Exception:
continue
date_str = data.get("calendarDate") or data.get("date")
if not date_str:
continue
try:
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
except ValueError:
continue
from sqlalchemy import text as _text
db.execute(_text("""
INSERT INTO health_metrics (user_id, date, resting_hr, steps,
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
VALUES (:user_id, :date, :resting_hr, :steps,
:floors, :active_cal, :total_cal, :stress, :spo2)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": data.get("restingHeartRate"),
"steps": data.get("totalSteps"),
"floors": data.get("floorsAscended"),
"active_cal": data.get("activeKilocalories"),
"total_cal": data.get("totalKilocalories"),
"stress": data.get("averageStressLevel"),
"spo2": data.get("avgSpo2"),
})
db.commit()
+26
View File
@@ -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
+114
View File
@@ -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: &registry 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:
+111
View File
@@ -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:
+7
View File
@@ -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.
+18
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MileVault</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+14
View File
@@ -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";
}
}
+33
View File
@@ -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"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+53
View File
@@ -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 <Navigate to="/login" replace />
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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<DashboardPage />} />
<Route path="activities" element={<ActivitiesPage />} />
<Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
</Routes>
)
}
@@ -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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
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: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
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: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
}
}, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
}
@@ -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 (
<div className="space-y-2">
{/* Stacked bar */}
<div className="flex h-4 rounded-full overflow-hidden gap-0.5">
{ZONE_CONFIG.map(({ key, color }) => {
const pct = zones[key] || 0
if (pct < 0.5) return null
return (
<div
key={key}
style={{ width: `${pct}%`, backgroundColor: color }}
className="h-full"
title={`${key.toUpperCase()}: ${pct}%`}
/>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4">
{ZONE_CONFIG.map(({ key, label, color }) => {
const pct = zones[key] || 0
return (
<div key={key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
<span className="text-xs font-medium text-white">{pct}%</span>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
export default function LapTable({ laps, sportType }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">Lap</th>
<th className="text-right pb-2 font-medium">Distance</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th>
<th className="text-right pb-2 font-medium">Power</th>
</tr>
</thead>
<tbody>
{laps.map((lap) => (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-gray-400">{lap.lap_number}</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
<td className="py-2 text-right">
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -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 (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
{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 (
<div key={entry.dataKey} className="flex items-center gap-2">
<span style={{ color: entry.color }}></span>
<span className="text-gray-300">{metric.label}:</span>
<span className="text-white font-medium">{display}</span>
</div>
)
})}
</div>
)
}
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 (
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
No timeline data available
</div>
)
}
return (
<div className="space-y-4">
{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 (
<div key={metric.key}>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
</div>
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
hide={idx < activeMetricConfigs.length - 1}
/>
<YAxis
domain={domain}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={40}
tickFormatter={v => {
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)
}}
/>
<Tooltip
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey={metric.key}
stroke={metric.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)
})}
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
</div>
)
}
@@ -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 (
<div className="flex h-screen overflow-hidden bg-gray-950">
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="px-4 py-5 border-b border-gray-800">
<h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault
</h1>
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
</div>
<nav className="flex-1 py-4 overflow-y-auto">
{nav.map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
}`
}>
<span>{icon}</span>
{label}
</NavLink>
))}
</nav>
<div className="px-4 py-4 border-t border-gray-800">
<button onClick={handleLogout}
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
Sign out
</button>
</div>
</aside>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
)
}
@@ -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 (
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
</div>
)
}
@@ -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')
}
},
}))
+33
View File
@@ -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; }
+22
View File
@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
)
@@ -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 (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-white">Activities</h1>
<Link
to="/upload"
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
+ Import
</Link>
</div>
{/* Sport filter */}
<div className="flex gap-2 mb-6 flex-wrap">
{SPORTS.map(s => (
<button
key={s}
onClick={() => { 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}`}
</button>
))}
</div>
{/* Activity list */}
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : (
<div className="space-y-2">
{activities?.map(activity => (
<Link
key={activity.id}
to={`/activities/${activity.id}`}
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group"
>
{/* Sport indicator */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
>
{sportIcon(activity.sport_type)}
</div>
{/* Name + date */}
<div className="flex-1 min-w-0">
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
{activity.name}
</p>
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
</div>
{/* Metrics */}
<div className="hidden sm:flex items-center gap-6 text-sm">
<div className="text-right">
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
<p className="text-xs text-gray-600">distance</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">{formatDuration(activity.duration_s)}</p>
<p className="text-xs text-gray-600">time</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
<p className="text-xs text-gray-600">pace</p>
</div>
<div className="text-right">
<p className="text-red-400 font-medium">{formatHeartRate(activity.avg_heart_rate)}</p>
<p className="text-xs text-gray-600">avg HR</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">
{activity.elevation_gain_m ? `${Math.round(activity.elevation_gain_m)}m` : '--'}
</p>
<p className="text-xs text-gray-600">elev</p>
</div>
</div>
<span className="text-gray-700 group-hover:text-gray-400 transition-colors ml-2"></span>
</Link>
))}
{activities?.length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🏃</p>
<p className="text-lg">No activities yet</p>
<p className="text-sm mt-1">
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
</p>
</div>
)}
</div>
)}
{/* Pagination */}
{activities?.length === 20 && (
<div className="flex justify-center gap-3 mt-6">
<button
onClick={() => 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
</button>
<span className="px-4 py-2 text-sm text-gray-500">Page {page}</span>
<button
onClick={() => 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
</button>
</div>
)}
</div>
)
}
@@ -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 <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity</div></div>
}
if (!activity) return null
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
<h1 className="text-2xl font-bold text-white">{activity.name}</h1>
</div>
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
</div>
</div>
{/* Primary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div>
{/* Secondary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div>
{/* Map with controls */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Map style:</span>
{['dark', 'street', 'satellite'].map(t => (
<button
key={t}
onClick={() => 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}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span>
{[280, 420, 560].map(h => (
<button
key={h}
onClick={() => 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'}
</button>
))}
</div>
</div>
<div style={{ height: mapHeight }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
mapType={mapType}
/>
</div>
</div>
{/* HR Zones */}
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
</div>
)}
{/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2">
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
<button
key={key}
onClick={() => 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}
</button>
))}
</div>
</div>
{dataPoints && dataPoints.length > 0 ? (
<MetricTimeline
dataPoints={dataPoints}
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
metrics={METRICS}
onHoverDistance={setHoveredDistance}
sportType={activity.sport_type}
/>
) : (
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
)}
</div>
{/* Laps */}
{laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} />
</div>
)}
</div>
)
}
@@ -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 (
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
)
// 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 (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => `${v.toFixed(0)}`} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)
}
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 (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{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]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
</div>
</div>
{/* Recent activities */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Recent activities</h3>
<Link to="/activities" className="text-xs text-blue-400 hover:underline">View all </Link>
</div>
<div className="space-y-2">
{recentActivities?.slice(0, 5).map(activity => (
<Link key={activity.id} to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
<div className="flex gap-4 text-sm text-right">
<div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
<div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
<div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
</div>
</Link>
))}
{!recentActivities?.length && (
<p className="text-gray-600 text-sm text-center py-8">
No activities yet <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link>
</p>
)}
</div>
</div>
{records?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Running PRs</h3>
<Link to="/records" className="text-xs text-blue-400 hover:underline">View all </Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{records.slice(0, 5).map(rec => (
<div key={rec.id} className="bg-gray-800/60 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 mb-1">{rec.distance_label}</p>
<p className="font-mono font-semibold text-yellow-400">{formatDuration(rec.duration_s)}</p>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -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 (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
)
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
<defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)
}
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 <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)
}
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 (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Health</h1>
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined} />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
</div>
{/* Range selector */}
<div className="flex gap-2">
{RANGES.map(({ label, days }) => (
<button key={label} onClick={() => 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}
</button>
))}
</div>
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
</div>
))}
</div>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)} />
</div>
</div>
) : (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data for this period</p>
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
</div>
)}
</div>
)
}
@@ -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 (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white">
<span className="text-blue-400">Mile</span>Vault
</h1>
<p className="text-gray-500 mt-2 text-sm">Your personal fitness dashboard</p>
</div>
<div className="bg-gray-900 rounded-2xl p-6 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="current-password"
required
/>
</div>
{error && (
<p className="text-red-400 text-xs">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 rounded-lg text-sm transition-colors"
>
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{pocketidData?.available && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-gray-800" />
<span className="text-xs text-gray-600">or</span>
<div className="flex-1 h-px bg-gray-800" />
</div>
<button
onClick={handlePocketID}
className="w-full bg-gray-800 hover:bg-gray-700 text-gray-300 font-medium py-2.5 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
>
🔑 Sign in with passkey
</button>
</>
)}
</div>
</div>
</div>
)
}
@@ -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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
{children}
</div>
)
}
function Field({ label, hint, children }) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
{children}
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
</div>
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
<div className="flex items-center gap-3 pt-1">
<button onClick={onClick} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{loading ? 'Saving…' : label}
</button>
{saved && <span className="text-green-400 text-sm"> Saved</span>}
</div>
)
}
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 (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */}
<Section title="Heart Rate & Measurements">
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
{effectiveMaxHr && (
<div className="mt-2 text-white">
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
{!profile?.max_heart_rate && ' (estimated from age)'}
{' · '}Zones: Z1 &lt;{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 &gt;{Math.round(effectiveMaxHr * 0.9)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
</Field>
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
</Field>
<Field label="Birth year" hint="Used to estimate max HR if not set above">
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
</Field>
<Field label="Height (cm)">
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => updateProfile.mutate(Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
))}
loading={updateProfile.isPending}
saved={hrSaved}
/>
</Section>
{/* Weight log */}
<Section title="Weight Log">
<div className="grid grid-cols-3 gap-3">
<Field label="Weight (kg)">
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
</Field>
<Field label="Body fat % (optional)">
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
</Field>
<Field label="Date">
<Input type="datetime-local" value={weightForm.date}
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => 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 && (
<div className="mt-2">
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
<div className="space-y-1 max-h-48 overflow-y-auto">
{weightLog.slice(0, 20).map(entry => (
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
<button onClick={() => deleteWeight.mutate(entry.id)}
className="text-gray-700 hover:text-red-400 text-xs transition-colors"></button>
</div>
))}
</div>
</div>
)}
</Section>
{/* Password change */}
<Section title="Change Password">
<div className="space-y-3">
<Field label="Current password">
<Input type="password" value={pwForm.current_password}
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
</Field>
<Field label="New password (min 8 characters)">
<Input type="password" value={pwForm.new_password}
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
</Field>
<Field label="Confirm new password">
<Input type="password" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
</Field>
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
</div>
<SaveButton
onClick={() => {
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"
/>
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
<p className="text-xs text-gray-500">
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
</p>
<div className="space-y-3">
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
</Field>
<Field label="Client ID">
<Input value={pidForm.client_id} placeholder="milevault"
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
</Field>
<Field label="Client secret" hint="Leave blank to keep existing secret">
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
</div>
<SaveButton
onClick={() => savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
</Section>
)}
</div>
)
}
@@ -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 (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
{/* Sport selector */}
<div className="flex gap-2">
{SPORTS.map(s => (
<button
key={s}
onClick={() => { 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}
</button>
))}
</div>
{sortedRecords?.length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🏆</p>
<p>No records yet import activities to track your best times</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Records table */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Distance</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Date</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{sortedRecords?.map(rec => (
<tr
key={rec.id}
onClick={() => 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'
}`}
>
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
<td className="px-4 py-3 text-right font-mono text-yellow-400 font-semibold">
{formatDuration(rec.duration_s)}
</td>
<td className="px-4 py-3 text-right text-gray-400 text-xs">
{formatDate(rec.achieved_at)}
</td>
<td className="px-4 py-3 text-right">
<Link
to={`/activities/${rec.activity_id}`}
onClick={e => e.stopPropagation()}
className="text-xs text-blue-400 hover:underline"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Progress chart */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
{selectedDistance && history ? (
<>
<h3 className="text-sm font-medium text-gray-300 mb-1">
{selectedDistance} progression
</h3>
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
{history.length > 1 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart
data={history.map(h => ({
date: h.achieved_at,
time: h.duration_s,
}))}
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')}
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={40}
tickFormatter={formatDuration}
reversed
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatDuration(v), 'Time']}
/>
<Line
type="monotone"
dataKey="time"
stroke="#fbbf24"
strokeWidth={2}
dot={{ fill: '#fbbf24', r: 4 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
Only one record complete more activities to see progression
</div>
)}
</>
) : (
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Select a distance to see your progression
</div>
)}
</div>
</div>
</div>
)
}
@@ -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 (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<p className="text-xs text-gray-500 mt-1">
Routes are auto-detected when you run the same path twice. You can also create them manually.
</p>
</div>
<button onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route
</button>
</div>
{/* Create route */}
{showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500">
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
</p>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
<input value={newRoute.name}
onChange={e => 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" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
{recentActivities?.length === 0 ? (
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
) : (
<select
value={newRoute.activity_id}
onChange={e => 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"
>
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
</option>
))}
</select>
)}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => 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
</button>
<button onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Route list */}
<div className="space-y-2">
{routes?.length === 0 && !showCreate && (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
<p className="text-sm">No named routes yet</p>
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
</div>
)}
{routes?.map(route => (
<button key={route.id} onClick={() => 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'
}`}>
<div className="flex items-start justify-between">
<p className="font-medium text-white">{route.name}</p>
{route.auto_detected && (
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
)}
</div>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span>{formatDistance(route.distance_m)}</span>
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
<span>{formatDate(route.created_at)}</span>
</div>
</button>
))}
</div>
{/* Route detail */}
{selected && (
<div className="lg:col-span-2 space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-start justify-between mb-3">
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
{selected.auto_detected && (
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
Auto-detected
</span>
)}
</div>
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All runs ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-2">
{routeActivities?.map((act, i) => (
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}
@@ -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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{icon}</span>
<div>
<h3 className="font-semibold text-white">{title}</h3>
<p className="text-xs text-gray-500">{description}</p>
</div>
</div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-blue-500 bg-blue-950/30'
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
}`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p className="text-blue-400 text-sm">Drop files here</p>
) : (
<div>
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
<p className="text-gray-600 text-xs mt-1">
{Object.values(accept).flat().join(', ')}
</p>
</div>
)}
</div>
{upload.isPending && (
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading</p>
)}
{tasks.length > 0 && (
<div className="mt-4 space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<span className="ml-2 text-green-400"> Queued</span>
</div>
))}
</div>
)}
</div>
)
}
export default function UploadPage() {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Import Data</h1>
<p className="text-gray-500 text-sm mt-1">
Import activities from Garmin or Strava. Large exports are processed in the background.
</p>
</div>
{/* How to export guides */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
<li>Go to Garmin Connect Profile Account</li>
<li>Scroll to Data Management Export Your Data</li>
<li>Request export and wait for the email</li>
<li>Download and upload the ZIP file below</li>
</ol>
</div>
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
<li>Go to strava.com Settings My Account</li>
<li>Scroll to Download or Delete Your Account</li>
<li>Click "Request Your Archive"</li>
<li>Download and upload the ZIP file below</li>
</ol>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Single FIT/GPX */}
<UploadZone
title="Single activity"
description="Upload a .fit or .gpx file"
icon="🏃"
endpoint="/upload/activity"
accept={{
'application/octet-stream': ['.fit'],
'application/gpx+xml': ['.gpx'],
'text/xml': ['.gpx'],
}}
/>
{/* Garmin full export */}
<UploadZone
title="Garmin Connect export"
description="Upload your full Garmin data export ZIP"
icon="⌚"
endpoint="/upload/garmin-export"
accept={{ 'application/zip': ['.zip'] }}
/>
{/* Strava export */}
<UploadZone
title="Strava bulk export"
description="Upload your Strava archive ZIP"
icon="🚴"
endpoint="/upload/strava-export"
accept={{ 'application/zip': ['.zip'] }}
/>
{/* Ongoing FIT files */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">🔄</span>
<div>
<h3 className="font-semibold text-white">Ongoing sync</h3>
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
</div>
</div>
<div className="space-y-3 text-xs text-gray-500">
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
GARMIN/Activity/*.fit
</code>
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
{`# 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`}
</code>
</div>
</div>
</div>
</div>
)
}
@@ -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
@@ -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'
}
@@ -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: [],
}
+14
View File
@@ -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,
},
},
},
})
+209
View File
@@ -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 35 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 ""
+50
View File
@@ -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;
}
}
}
+59
View File
@@ -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;
}
}
}
+122
View File
@@ -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 <backup.sql>"
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 <command>"
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 <file.sql>"
echo " update Pull and rebuild"
;;
esac