Multi-user via PocketID: account linking, group gating, admin user management

PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the
data layer was already fully user-scoped. This adds the missing pieces for
running real multi-user:

- auth.py callback: link by email to an existing un-linked account (so the
  admin keeps their data when first signing in by passkey), collision-safe
  username generation, and request the `groups` scope.
- Group gating: optional pocketid_allowed_group (admin-config or
  POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the
  callback and redirected to /login?auth_error=not_authorized.
- New admin users API (app/api/users.py): list users, promote/demote admin
  (guards against demoting/locking out the last admin or yourself), and delete
  a user with ordered bulk deletes of all their data + on-disk files.
- ProfilePage: allowed-group field; LoginPage: rejected-login message;
  Layout: admin-only Users nav; new UsersPage.

Resync milevault_export to current source (it had drifted many features behind
— missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own
CI) and add POCKETID_ALLOWED_GROUP to .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
+2
View File
@@ -32,3 +32,5 @@ VITE_MAPBOX_TOKEN=
POCKETID_ISSUER=
POCKETID_CLIENT_ID=
POCKETID_CLIENT_SECRET=
# Restrict sign-in to members of this PocketID group (leave blank to allow all)
POCKETID_ALLOWED_GROUP=
+60 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy import select, func, desc, delete
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
from_attributes = True
@router.get("/stats/ytd")
async def ytd_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return year-to-date distance totals grouped by sport type."""
from datetime import date, timezone
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
result = await db.execute(
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
.group_by(Activity.sport_type)
)
rows = result.all()
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
return {
"running_km": round(totals.get("running", 0), 2),
"cycling_km": round(totals.get("cycling", 0), 2),
"hiking_km": round(totals.get("hiking", 0), 2),
"walking_km": round(totals.get("walking", 0), 2),
"total_km": round(sum(totals.values()), 2),
}
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
@@ -126,7 +150,6 @@ async def get_data_points(
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,
@@ -211,3 +234,38 @@ async def delete_activity(
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(activity)
await db.commit()
@router.post("/{activity_id}/reprocess")
async def reprocess_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Re-parse the source FIT file and update polyline, data points etc."""
import os
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")
if not activity.source_file:
raise HTTPException(status_code=400, detail="No source file stored for this activity")
if not os.path.exists(activity.source_file):
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
source_file = activity.source_file
source_type = activity.source_type or "fit"
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
await db.delete(activity)
await db.commit()
from app.workers.tasks import process_activity_file
task = process_activity_file.delay(source_file, current_user.id, source_type)
return {"task_id": task.id, "status": "queued"}
+55 -7
View File
@@ -24,6 +24,27 @@ async def _get_pocketid_config(db: AsyncSession):
return issuer, client_id, client_secret
async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
admin = result.scalar_one_or_none()
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None
async def _unique_username(db: AsyncSession, base: str) -> str:
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
base = (base or "user").strip() or "user"
candidate = base
n = 1
while True:
existing = await db.execute(select(User).where(User.username == candidate))
if existing.scalar_one_or_none() is None:
return candidate
n += 1
candidate = f"{base}-{n}"
class Token(BaseModel):
access_token: str
token_type: str
@@ -77,9 +98,9 @@ async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
"scope": "openid profile email groups",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@@ -92,31 +113,58 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{issuer}/token",
f"{issuer}/api/oidc/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"redirect_uri": f"{settings.base_url}/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",
f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
# Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db)
if allowed_group:
groups = userinfo.get("groups") or []
if allowed_group not in groups:
return RedirectResponse(url="/login?auth_error=not_authorized")
# 1) Existing passkey identity → use it.
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
# 2) No passkey identity yet, but an account with this email exists and is
# not already linked to a different passkey → link them (preserves data).
if not user and email:
result = await db.execute(select(User).where(User.email == email))
existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub
user = existing
# 3) Otherwise provision a new account with a collision-safe username.
if not user:
user = User(username=preferred_username, email=email, pocketid_sub=sub)
base = preferred_username or (email.split("@")[0] if email else "user")
username = await _unique_username(db, base)
# Only set email if no other account already claims it (unique column).
email_taken = False
if email:
dup = await db.execute(select(User).where(User.email == email))
email_taken = dup.scalar_one_or_none() is not None
user = User(username=username, email=None if email_taken else 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}")
@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
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, GarminConnectConfig
router = APIRouter()
class GarminConfigIn(BaseModel):
email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
sync_enabled: bool = True
sync_activities: bool = True
sync_wellness: bool = True
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
class GarminConfigOut(BaseModel):
email: str
sync_enabled: bool
sync_activities: bool
sync_wellness: bool
sync_lookback_days: int
last_sync_at: Optional[datetime]
last_sync_status: Optional[str]
connected: bool
class Config:
from_attributes = True
@router.get("/config", response_model=GarminConfigOut)
async def get_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg:
return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30,
last_sync_at=None, last_sync_status=None, connected=False,
)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.put("/config", response_model=GarminConfigOut)
async def save_config(
body: GarminConfigIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Save Garmin Connect settings. If a password is provided, re-authenticates and
refreshes the stored OAuth token. If no password is provided, only updates the
non-credential settings (toggles, lookback days) without re-logging in.
"""
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if body.password:
# Credentials update — test-login before saving
enc = encrypt_password(body.password)
try:
garmin, token_store = authenticate_garmin(body.email, enc, None)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
if cfg:
cfg.email = body.email
cfg.password_enc = enc
cfg.token_store = token_store
cfg.last_sync_status = "Credentials updated"
else:
cfg = GarminConnectConfig(
user_id=current_user.id,
email=body.email,
password_enc=enc,
token_store=token_store,
last_sync_status="Connected",
)
db.add(cfg)
else:
# Settings-only update — password unchanged
if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness
cfg.sync_lookback_days = body.sync_lookback_days
await db.commit()
await db.refresh(cfg)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.delete("/config")
async def delete_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
await db.delete(cfg)
await db.commit()
return {"status": "ok"}
@router.post("/trigger")
async def trigger_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Enqueue an immediate Garmin Connect sync for this user."""
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id)
return {"task_id": task.id, "status": "queued"}
+52 -21
View File
@@ -1,9 +1,9 @@
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 pydantic import BaseModel, model_validator
from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
@model_validator(mode='after')
def _strip_bb_values(self):
if isinstance(self.body_battery, dict):
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
return self
class Config:
from_attributes = True
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
limit: int = Query(365, ge=1, le=2000),
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)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@@ -73,8 +83,6 @@ 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)
@@ -83,9 +91,7 @@ async def health_summary(
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
@@ -97,7 +103,7 @@ async def health_summary(
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
@@ -116,23 +122,48 @@ async def health_summary(
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
"sleep_stages": metric.sleep_stages if metric else None,
}
@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
from fastapi import HTTPException
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,
@@ -153,4 +184,4 @@ async def add_manual_metric(
db.add(metric)
await db.commit()
return {"status": "ok"}
return {"status": "ok"}
@@ -19,6 +19,7 @@ class ProfileUpdate(BaseModel):
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
biological_sex: Optional[str] = None
class ProfileOut(BaseModel):
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
biological_sex: Optional[str]
estimated_max_hr: Optional[int]
is_admin: bool
@@ -55,6 +57,7 @@ async def update_profile(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
old_max_hr = current_user.max_heart_rate
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
@@ -71,9 +74,18 @@ async def update_profile(
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
if body.biological_sex is not None:
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
await db.commit()
await db.refresh(current_user)
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
from app.workers.tasks import recalculate_hr_zones_for_user
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@@ -109,6 +121,7 @@ class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
allowed_group: Optional[str] = None
@router.get("/pocketid-config")
@@ -119,10 +132,12 @@ async def get_pocketid_config(current_user: User = Depends(get_current_user)):
# 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
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"allowed_group": allowed_group or "",
"enabled": bool(issuer and client_id),
}
@@ -141,6 +156,8 @@ async def save_pocketid_config(
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
if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit()
return {"status": "ok"}
@@ -44,6 +44,36 @@ async def list_records(
return result.scalars().all()
@router.get("/routes")
async def route_records(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest activity per named route (course records)."""
from sqlalchemy import text
rows = await db.execute(
text("""
SELECT DISTINCT ON (nr.id)
nr.id AS route_id,
nr.name AS route_name,
nr.sport_type,
nr.distance_m,
nr.reference_polyline,
a.id AS activity_id,
a.name AS activity_name,
a.duration_s,
a.start_time,
a.avg_speed_ms
FROM named_routes nr
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
ORDER BY nr.id, a.duration_s ASC
"""),
{"uid": current_user.id},
)
return [dict(r._mapping) for r in rows]
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
+343 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
class Config:
from_attributes = True
@@ -47,22 +48,51 @@ class SegmentOut(BaseModel):
start_distance_m: float
end_distance_m: float
description: Optional[str]
auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config:
from_attributes = True
class AutoGenerateRequest(BaseModel):
type: str # "1km" | "turns" | "hills"
gradient_pct: float = 5.0
turn_angle_deg: float = 45.0
class SegmentTimeEntry(BaseModel):
activity_id: int
date: datetime
name: str
duration_s: float
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute)
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
@@ -176,6 +206,61 @@ async def route_activities(
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
@@ -198,12 +283,23 @@ async def assign_activity_to_route(
return {"status": "ok"}
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
result = await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == 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}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
@@ -219,14 +315,258 @@ async def create_segment(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
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,
auto_generated=False,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
async def delete_segment(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
await db.delete(seg)
await db.commit()
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
async def auto_generate_segments(
route_id: int,
body: AutoGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Auto-generate segments: 1km splits, turns, or hills."""
from app.services.route_matcher import (
generate_1km_segments, generate_turn_segments, generate_hill_segments,
)
from sqlalchemy import delete as sql_delete
route = await _get_owned_route(route_id, current_user.id, db)
if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
raw_segments: list[tuple[str, float, float]] = []
if body.type == "1km":
if not route.distance_m:
raise HTTPException(status_code=400, detail="Route has no distance recorded")
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
elif body.type == "turns":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
elif body.type == "hills":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
# Find most recent matched activity for elevation data
act_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(1)
)
act = act_result.scalar_one_or_none()
if not act:
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
from app.models.user import ActivityDataPoint
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
new_segments = []
for name, start_m, end_m in raw_segments:
seg = RouteSegment(
route_id=route_id,
name=name,
start_distance_m=start_m,
end_distance_m=end_m,
auto_generated=True,
auto_generated_type=body.type,
)
db.add(seg)
new_segments.append(seg)
await db.commit()
for seg in new_segments:
await db.refresh(seg)
return new_segments
class SegmentBestOut(BaseModel):
segment_id: int
name: str
start_distance_m: float
end_distance_m: float
auto_generated: bool
best_s: Optional[float]
best_activity_id: Optional[int]
count: int
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
async def get_segment_bests(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return best time per segment across all matched activities for a route."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
from collections import defaultdict
await _get_owned_route(route_id, current_user.id, db)
segs_result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
segments = segs_result.scalars().all()
if not segments:
return []
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(20)
)
activities = acts_result.scalars().all()
if not activities:
return [
SegmentBestOut(
segment_id=s.id, name=s.name,
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
)
for s in segments
]
act_ids = [a.id for a in activities]
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id.in_(act_ids))
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
)
all_dps = dp_result.scalars().all()
# Group data points by activity_id
dp_by_act = defaultdict(list)
for dp in all_dps:
if dp.distance_m is not None:
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
bests = []
for seg in segments:
best_s = None
best_act_id = None
count = 0
for act_id in act_ids:
dp_list = dp_by_act.get(act_id, [])
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration is not None:
count += 1
if best_s is None or duration < best_s:
best_s = duration
best_act_id = act_id
bests.append(SegmentBestOut(
segment_id=seg.id, name=seg.name,
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
auto_generated=bool(seg.auto_generated),
best_s=best_s, best_activity_id=best_act_id, count=count,
))
return bests
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
async def get_segment_times(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the last 10 times this segment was traversed across matched activities."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
await _get_owned_route(route_id, current_user.id, db)
seg_result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = seg_result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(10)
)
activities = acts_result.scalars().all()
times = []
for act in activities:
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [
{"distance_m": p.distance_m, "timestamp": p.timestamp}
for p in dps
if p.distance_m is not None
]
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration:
times.append(SegmentTimeEntry(
activity_id=act.id,
date=act.start_time,
name=act.name,
duration_s=duration,
))
return times
+18 -1
View File
@@ -75,6 +75,22 @@ async def upload_garmin_export(
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
elif lower.endswith(".zip"):
# Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_zip_path = extract_dir / name
nested_extract = nested_zip_path.parent / nested_zip_path.stem
nested_extract.mkdir(exist_ok=True)
try:
with zipfile.ZipFile(nested_zip_path) as nzf:
nzf.extractall(nested_extract)
for nested_name in nzf.namelist():
if nested_name.lower().endswith(".fit"):
fit_path = nested_extract / nested_name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
except zipfile.BadZipFile:
pass
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
@@ -82,7 +98,7 @@ async def upload_garmin_export(
return {
"status": "queued",
"activity_tasks": len(task_ids),
"health_task": health_task.id,
"task_id": health_task.id,
}
@@ -116,6 +132,7 @@ async def upload_strava_export(
return {
"status": "queued",
"activity_tasks": len(task_ids),
"task_id": task_ids[-1] if task_ids else None,
}
+142
View File
@@ -0,0 +1,142 @@
"""
Admin-only user management: list provisioned users, promote/demote admin,
and delete a user together with all of their data.
New users are normally provisioned just-in-time on first PocketID login
(see app/api/auth.py). This router is the in-app surface for managing them.
"""
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from pydantic import BaseModel
from typing import Optional
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, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
)
router = APIRouter()
def _require_admin(current_user: User):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
async def _admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
return result.scalar_one()
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool
activity_count: int
created_at: Optional[str]
class AdminUpdate(BaseModel):
is_admin: bool
@router.get("/")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
# activity counts per user in one grouped query
counts = dict(
(await db.execute(
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
)).all()
)
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
UserOut(
id=u.id,
username=u.username,
email=u.email,
is_admin=u.is_admin,
has_passkey=u.pocketid_sub is not None,
activity_count=counts.get(u.id, 0),
created_at=u.created_at.isoformat() if u.created_at else None,
)
for u in users
]
@router.patch("/{user_id}")
async def set_admin(
user_id: int,
body: AdminUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot change your own admin status")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
# Demoting the last remaining admin would lock everyone out.
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot demote the last admin")
user.is_admin = body.is_admin
await db.commit()
return {"status": "ok", "is_admin": user.is_admin}
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
if user.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot delete the last admin")
# Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
# Remove the user's uploaded files from disk (best-effort).
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
return {"status": "ok"}
+5 -9
View File
@@ -6,28 +6,24 @@ 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 account
admin_username: str = Field("admin", env="ADMIN_USERNAME")
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
# Base URL - used for OAuth callbacks
base_url: str = Field("https://milevault.jarrett.eu", env="BASE_URL")
# 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")
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
# Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment
environment: str = Field("production", env="ENVIRONMENT")
@@ -36,4 +32,4 @@ class Settings(BaseSettings):
case_sensitive = False
settings = Settings()
settings = Settings()
+128 -1
View File
@@ -6,7 +6,7 @@ import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload, profile
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
async def init_db():
@@ -40,6 +40,131 @@ async def init_db():
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Add columns that were introduced after initial table creation (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE garmin_connect_configs "
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
))
except Exception as e:
print(f"Column migration skipped: {e}")
# health_metrics columns added after initial creation
try:
async with engine.begin() as conn:
for stmt in [
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
]:
await conn.execute(text(stmt))
except Exception as e:
print(f"health_metrics column migration skipped: {e}")
# biological_sex column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
))
except Exception as e:
print(f"users.biological_sex column migration skipped: {e}")
# pocketid_allowed_group column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
))
except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {e}")
# route_segments auto_generated column added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
))
except Exception as e:
print(f"route_segments column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
await conn.execute(text("""
UPDATE health_metrics SET
avg_hr_day = sub.avg_hr,
max_hr_day = sub.max_hr
FROM (
SELECT id,
AVG((elem->>1)::float) AS avg_hr,
MAX((elem->>1)::float) AS max_hr
FROM health_metrics,
json_array_elements(intraday_hr) AS elem
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
AND intraday_hr IS NOT NULL
AND (elem->>1)::float > 0
GROUP BY id
) sub
WHERE health_metrics.id = sub.id
"""))
except Exception as e:
print(f"avg_hr_day backfill skipped: {e}")
# Replace the all-columns unique constraint on personal_records with a partial
# index (only current records must be unique per user/sport/distance).
# The old constraint also covered is_current_record=False rows, causing
# UniqueViolation crashes when multiple workers deactivate the same PR.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE personal_records "
"DROP CONSTRAINT IF EXISTS uq_pr_current"
))
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
"ON personal_records (user_id, sport_type, distance_m) "
"WHERE is_current_record = true"
))
except Exception as e:
print(f"PR constraint migration skipped: {e}")
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
# without first manually unlinking every activity.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities "
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
))
await conn.execute(text(
"ALTER TABLE activities "
"ADD CONSTRAINT activities_named_route_id_fkey "
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
))
except Exception as e:
print(f"FK migration skipped: {e}")
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
await conn.execute(text(
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
except Exception as e:
print(f"avg_speed_ms fix skipped: {e}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
@@ -98,6 +223,8 @@ 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.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
@app.get("/health")
+38 -4
View File
@@ -1,6 +1,6 @@
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint
ForeignKey, Text, JSON, Index, UniqueConstraint, text
)
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
@@ -27,16 +27,40 @@ class User(Base):
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
# 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)
# Only PocketID users in this group may sign in. Null/blank = allow all.
pocketid_allowed_group = Column(String(128), 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")
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
class GarminConnectConfig(Base):
"""Per-user Garmin Connect credentials and sync state."""
__tablename__ = "garmin_connect_configs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
email = Column(String(256), nullable=False)
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
sync_enabled = Column(Boolean, default=True)
sync_activities = Column(Boolean, default=True)
sync_wellness = Column(Boolean, default=True)
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="garmin_connect_config")
class WeightLog(Base):
@@ -81,7 +105,7 @@ class Activity(Base):
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)
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
@@ -160,6 +184,8 @@ class RouteSegment(Base):
start_distance_m = Column(Float, nullable=False)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
auto_generated = Column(Boolean, default=False)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
route = relationship("NamedRoute", back_populates="segments")
@@ -178,8 +204,12 @@ class PersonalRecord(Base):
is_current_record = Column(Boolean, default=True)
__table_args__ = (
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
name="uq_pr_current"),
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
# (created in init_db), which only covers is_current_record=true rows.
# The old all-columns UniqueConstraint was dropped because it incorrectly
# constrained is_current_record=false rows too, causing multi-worker races.
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
postgresql_where=text("is_current_record = true"), unique=True),
)
@@ -218,6 +248,10 @@ class HealthMetric(Base):
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
@@ -1,30 +1,30 @@
"""
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.
FIT and GPX file parser.
Parses FIT files directly using the Garmin SDK but applies manual
scale conversion for fields where the SDK doesn't auto-convert.
"""
import math
from pathlib import Path
from datetime import datetime, timezone, timedelta
import struct
from datetime import datetime, timezone
from typing import Optional
import gpxpy
import polyline as polyline_lib
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
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 _semicircles_to_deg(val):
if val is None:
return None
try:
result = float(val) * SEMICIRCLES_TO_DEG
if -90 <= result <= 90 or -180 <= result <= 180:
return result
except (TypeError, ValueError):
pass
return None
def _safe_float(val) -> Optional[float]:
@@ -34,7 +34,17 @@ def _safe_float(val) -> Optional[float]:
return None
def _bounding_box(coords: list) -> Optional[dict]:
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
fv = _safe_float(val)
if fv is None or fv >= 65.0:
if dist_m and dur_s and float(dur_s) > 0:
return float(dist_m) / float(dur_s)
return None
return fv
def _bounding_box(coords):
if not coords:
return None
lats = [c[0] for c in coords]
@@ -43,18 +53,35 @@ def _bounding_box(coords: list) -> Optional[dict]:
"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
def _to_dt(val) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
try:
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return None
return None
session = {}
def _is_valid_lat(v):
return v is not None and -90 <= v <= 90
def _is_valid_lon(v):
return v is not None and -180 <= v <= 180
def parse_fit_file(filepath: str) -> dict:
session_data = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
session_data.update(msg)
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
@@ -73,68 +100,113 @@ def parse_fit_file(filepath: str) -> dict:
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)
# The SDK may return field names in camelCase or snake_case depending on version.
# Try both. Also handle raw timestamp integers for start_time.
def get(d, *keys):
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
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)
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
sport_map = {
"running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking",
"generic": "other", "trail_running": "running",
"e_biking": "cycling", "open_water_swimming": "other",
}
sport_type = sport_map.get(sport_raw, sport_raw)
# start_time — SDK may return datetime or raw int
start_time_raw = get(session_data, "startTime", "start_time")
start_time = _to_dt(start_time_raw)
# Position fields — the SDK may or may not convert semicircles.
# Check if values look like semicircles (>= 90 for lat) and convert if so.
def get_lat(d):
v = get(d, "positionLat", "position_lat")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
# If absolute value > 90, it's semicircles
if abs(fv) > 90:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lat(fv) else None
def get_lon(d):
v = get(d, "positionLong", "position_long")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
if abs(fv) > 180:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lon(fv) else None
# 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
]
coords = []
for r in records:
lat = get_lat(r)
lon = get_lon(r)
if lat is not None and lon is not None:
coords.append((lat, lon))
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)
ts = _to_dt(get(r, "timestamp"))
lat = get_lat(r)
lon = get_lon(r)
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
hr = get(r, "heartRate", "heart_rate")
cadence = get(r, "cadence")
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
power = get(r, "power")
temp = get(r, "temperature")
distance = get(r, "distance")
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"),
"latitude": _safe_float(lat),
"longitude": _safe_float(lon),
"altitude_m": _safe_float(altitude),
"heart_rate": _safe_float(hr),
"cadence": _safe_float(cadence),
"speed_ms": _safe_float(speed),
"power": _safe_float(power),
"temperature_c": _safe_float(temp),
"distance_m": _safe_float(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)
ls = _to_dt(get(lap, "startTime", "start_time"))
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
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")),
"duration_s": lap_dur,
"distance_m": lap_dist,
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
"avg_speed_ms": _sanitize_speed(
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=lap_dist, dur_s=lap_dur,
),
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
})
# Build activity name
name = session.get("sport", "Activity").title()
name = sport_type.title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
@@ -142,21 +214,28 @@ def parse_fit_file(filepath: str) -> dict:
"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")),
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
"training_stress_score")),
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
"total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
@@ -166,7 +245,6 @@ def parse_fit_file(filepath: str) -> dict:
def parse_gpx_file(filepath: str) -> dict:
"""Parse a GPX file."""
with open(filepath) as f:
gpx = gpxpy.parse(f)
@@ -180,7 +258,6 @@ def parse_gpx_file(filepath: str) -> dict:
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:
@@ -190,11 +267,9 @@ def parse_gpx_file(filepath: str) -> dict:
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,
"latitude": pt.latitude, "longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
@@ -204,91 +279,61 @@ def parse_gpx_file(filepath: str) -> dict:
"distance_m": None,
})
coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]]
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"])
R = 6371000
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
dphi = math.radians(p["latitude"] - prev[0])
dlam = math.radians(p["longitude"] - prev[1])
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
total_dist += 2 * R * math.asin(math.sqrt(a))
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)
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()
sport = track.type.lower() if track.type else "running"
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,
"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_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": [],
"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:
@@ -300,8 +345,7 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
zones[key] += 1
break
else:
zones["z5"] += 1 # anything above 90% goes to z5
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
return {}
@@ -524,7 +524,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
if stats:
_set(row, "resting_hr", stats.get("restingHeartRate"))
_set(row, "avg_hr_day", stats.get("averageHeartRate"))
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
_set(row, "max_hr_day", stats.get("maxHeartRate"))
_set(row, "steps", stats.get("totalSteps"))
_set(row, "floors_climbed", stats.get("floorsAscended"))
@@ -63,11 +63,21 @@ def routes_are_similar(
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
dist1: Optional[float] = None,
dist2: Optional[float] = None,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
When dist1/dist2 are provided:
- Rejects if distance differs by more than 2.5%
- Uses 3% of route distance as the DTW threshold (capped at 300m)
"""
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
return False
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
@@ -164,6 +174,154 @@ def find_best_split_time(
return best
def _bearing(p1: tuple, p2: tuple) -> float:
"""Compass bearing in degrees (0-360) from p1 to p2."""
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return math.degrees(math.atan2(x, y)) % 360
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
if not encoded_polyline:
return []
km_count = int(total_dist_m / 1000)
segments = []
for i in range(km_count):
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
remainder = total_dist_m - km_count * 1000
if remainder >= 200:
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
return segments
def generate_turn_segments(
encoded_polyline: str,
turn_angle_deg: float = 45.0,
) -> list[tuple[str, float, float]]:
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
coords = decode_polyline_to_coords(encoded_polyline)
if len(coords) < 3:
return []
cum_dists = [0.0]
for i in range(1, len(coords)):
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
total = cum_dists[-1]
HALF_WINDOW = 100.0 # metres either side of candidate turn point
turn_centers: list[float] = []
for i in range(1, len(coords) - 1):
# Find index ~HALF_WINDOW before and after
start_i = i
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
start_i -= 1
end_i = i
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
end_i += 1
if start_i == i or end_i == i:
continue
b1 = _bearing(coords[start_i], coords[i])
b2 = _bearing(coords[i], coords[end_i])
diff = abs(b2 - b1) % 360
if diff > 180:
diff = 360 - diff
if diff >= turn_angle_deg:
turn_centers.append(cum_dists[i])
if not turn_centers:
return []
# Cluster turns within 150 m of each other → one segment per cluster
clusters: list[list[float]] = [[turn_centers[0]]]
for d in turn_centers[1:]:
if d - clusters[-1][-1] < 150:
clusters[-1].append(d)
else:
clusters.append([d])
segments = []
for cluster in clusters:
center = sum(cluster) / len(cluster)
start = max(0.0, center - HALF_WINDOW)
end = min(total, center + HALF_WINDOW)
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
return segments
def generate_hill_segments(
data_points: list[dict],
gradient_pct: float = 5.0,
) -> list[tuple[str, float, float]]:
"""
Detect uphill sections using activity data points (with altitude_m + distance_m).
Returns list of (name, start_m, end_m).
"""
pts = [
(p["distance_m"], p["altitude_m"])
for p in data_points
if p.get("distance_m") is not None and p.get("altitude_m") is not None
]
if len(pts) < 10:
return []
pts.sort(key=lambda x: x[0])
dists = [p[0] for p in pts]
alts = [p[1] for p in pts]
# Smooth altitude with a sliding window to reduce GPS noise
SMOOTH = 10
smooth_alts = []
for i in range(len(alts)):
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
grad_threshold = gradient_pct / 100.0
MIN_HILL_M = 200.0
in_hill = False
hill_start_idx = 0
segments = []
for i in range(1, len(dists)):
d_dist = dists[i] - dists[i - 1]
if d_dist <= 0:
continue
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
if grad >= grad_threshold and not in_hill:
in_hill = True
hill_start_idx = i - 1
elif grad < grad_threshold and in_hill:
length = dists[i - 1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[i - 1],
))
in_hill = False
if in_hill:
length = dists[-1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[-1],
))
return segments
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
@@ -1,56 +1,61 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The SDK with convert_types_to_strings=True returns snake_case field names.
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)
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
and a stage name. Duration of each stage = gap to the next stage's timestamp.
The sleep session stop time (from event message 21, event_type='stop') closes
the last stage.
"""
from datetime import datetime, timezone, timedelta, date
from datetime import datetime, timezone, date
from typing import Optional
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
FIT_EPOCH_S = 631065600
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
def fit_ts(seconds) -> Optional[datetime]:
"""Convert FIT timestamp to UTC datetime."""
if seconds is None:
def _fit_ts(raw) -> Optional[datetime]:
if raw is None:
return None
try:
s = int(seconds)
if s == 0 or s == 0xFFFFFFFF:
s = int(raw)
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 _to_date(val) -> Optional[date]:
if val is None:
return None
if isinstance(val, datetime):
if val.tzinfo is None:
val = val.replace(tzinfo=timezone.utc)
return val.date()
if isinstance(val, (int, float)):
dt = _fit_ts(val)
return dt.date() if dt else None
return None
def _to_dt(val) -> Optional[datetime]:
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
return _fit_ts(val)
return None
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
Parse a Garmin wellness/monitoring FIT file.
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
daily = {}
last_date_seen = [None]
def ensure_day(d: date) -> dict:
if d not in daily:
@@ -58,195 +63,213 @@ def parse_wellness_fit(file_path: str) -> dict:
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
"sleep_levels": [],
# Each entry: (datetime, level_int) — duration computed from gaps
"sleep_epochs": [],
"sleep_start": None,
"sleep_end": None,
"steps": None,
"floors_climbed": None,
"active_calories": None,
"total_calories": None,
"bmr": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
"sleep_score": 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 _add_sleep_epoch(ts: datetime, level_raw):
d = _to_date(ts)
if not d:
return
last_date_seen[0] = d
if isinstance(level_raw, str):
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
else:
level = level_raw
if level is not None:
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
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")
# ── monitoring_info (147) - older firmware ─────────────────────────
if mesg_num == 147:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
rhr = msg.get("resting_heart_rate")
if d and rhr and 20 < rhr < 120:
last_date_seen[0] = d
ensure_day(d)["resting_hr"] = int(rhr)
# ── monitoring (148) - older firmware ──────────────────────────────
elif mesg_num == 148:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
if not d:
return
last_date_seen[0] = d
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")
# ── monitoring (55) - modern, per-interval running totals ──────────
elif mesg_num == 55:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
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")
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
active_cal = msg.get("active_calories")
if active_cal and active_cal > 0:
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
ascent = msg.get("ascent")
if ascent and ascent > 0:
# Garmin counts 1 floor ≈ 3 m of ascent
floors = max(1, round(float(ascent) / 3))
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
# ── monitoring_info (103) - calibration; carries BMR ───────────────
elif mesg_num == 103:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
bmr = msg.get("resting_metabolic_rate")
if bmr and bmr > 0:
ensure_day(d)["bmr"] = int(bmr)
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
elif mesg_num == 370:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hrv_avg = msg.get("last_night_average")
if hrv_avg and hrv_avg > 0:
entry["hrv_nightly_avg"] = float(hrv_avg)
hrv_high = msg.get("last_night_5_min_high")
if hrv_high and hrv_high > 0:
entry["hrv_5min_high"] = float(hrv_high)
status = msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── Standard: stress_level (132) ──────────────────────────────────
elif mesg_num == 132:
d = get_date(msg, "stress_level_time", "timestamp")
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
elif mesg_num == 275:
sleep_level = msg.get("sleep_level")
ts = _to_dt(msg.get("timestamp"))
if sleep_level is not None and ts:
_add_sleep_epoch(ts, sleep_level)
elif ts:
# Older firmware: HRV summary in message 275
d = _to_date(ts)
if d:
last_date_seen[0] = d
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v and v > 0:
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") or msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── sleep_level (269) - older firmware sleep epochs ────────────────
elif mesg_num == 269:
ts = _to_dt(msg.get("timestamp"))
level = msg.get("sleep_level")
if ts and level is not None:
_add_sleep_epoch(ts, level)
# ── event (21) - sleep session start / stop ────────────────────────
elif mesg_num == 21:
ts = _to_dt(msg.get("timestamp"))
if not ts:
return
d = _to_date(ts)
if not d:
return
event_type = msg.get("event_type")
if event_type == "start":
last_date_seen[0] = d
ensure_day(d)["sleep_start"] = ts
elif event_type == "stop":
last_date_seen[0] = d
ensure_day(d)["sleep_end"] = ts
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
elif mesg_num == 346:
d = last_date_seen[0]
if not d:
return
score = msg.get("overall_sleep_score")
if score and score > 0:
ensure_day(d)["sleep_score"] = int(score)
# ── stress_level (132) ─────────────────────────────────────────────
elif mesg_num == 132:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
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) ─────────────────────────────────────
# ── spo2_data (258) ────────────────────────────────────────────────
elif mesg_num == 258:
d = get_date(msg, "timestamp")
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
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")
# ── per-minute stress + HR (227) proprietary ───────────────────────
elif mesg_num == 227:
d = _to_date(msg.get("stress_level_time") or msg.get("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())
last_date_seen[0] = d
entry = ensure_day(d)
hr_raw = msg.get(2)
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
stress = msg.get("stress_level_value")
if stress is None:
stress = msg.get(0)
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
entry["stress_values"].append(int(stress))
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 ─────────────────────
# ── daily resting HR (211) proprietary ─────────────────────────────
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:
d = _to_date(msg.get("timestamp"))
if not d:
return
entry = ensure_day(ts.date())
rhr = msg.get(0)
last_date_seen[0] = d
entry = ensure_day(d)
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
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)
@@ -254,37 +277,57 @@ def parse_wellness_fit(file_path: str) -> dict:
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
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
merge_heart_rates=False,
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", [])
sleep_epochs = data.pop("sleep_epochs", [])
sleep_end_ts = data.pop("sleep_end", None)
sleep_start_ts = data.pop("sleep_start", None)
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
# Compute sleep stage durations from epoch timestamps
if sleep_epochs:
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
for i, (ts, level) in enumerate(epochs_sorted):
if i + 1 < len(epochs_sorted):
next_ts = epochs_sorted[i + 1][0]
elif sleep_end_ts:
next_ts = sleep_end_ts
else:
continue
dur = (next_ts - ts).total_seconds()
if level in level_secs and dur > 0:
level_secs[level] += dur
sleep_deep_s = level_secs[3] or None
sleep_light_s = level_secs[2] or None
sleep_rem_s = level_secs[4] or None
sleep_awake_s = level_secs[1] or None
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
sleep_stages = None
active_cal = data.get("active_calories")
bmr = data.get("bmr")
# Require active_cal so we don't store BMR-only as "total" calories
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
@@ -297,13 +340,17 @@ def parse_wellness_fit(file_path: str) -> dict:
"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"),
"active_calories": active_cal,
"total_calories": total_cal,
"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,
"sleep_score": data.get("sleep_score"),
"sleep_start": sleep_start_ts,
"sleep_end": sleep_end_ts,
"sleep_stages": sleep_stages,
}
return {"days": result, "error": None}
+257 -83
View File
@@ -22,18 +22,27 @@ celery_app.conf.update(
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
beat_schedule={
"sync-garmin-connect": {
"task": "sync_all_garmin_connect",
"schedule": 1800.0, # every 30 minutes
},
},
)
# Garmin FIT file suffixes that are health/wellness data, not activities
WELLNESS_SUFFIXES = (
"_METRICS.fit",
"_WELLNESS.fit",
"_SLEEP.fit",
"_SLEEP_DATA.fit",
"_STRESS.fit",
"_SPO2.fit",
"_HRV.fit",
"_HRV_STATUS.fit",
"_MONITORING.fit",
"_MONITORING_B.fit",
"_RESPIRATION.fit",
"_PULSE_OX.fit",
)
@@ -43,10 +52,10 @@ def is_wellness_file(file_path: str) -> bool:
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
garmin_activity_id: str = None):
"""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}
@@ -54,7 +63,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
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 sqlalchemy import select, func
from datetime import datetime
self.update_state(state="PROGRESS", meta={"step": "parsing"})
@@ -67,25 +76,37 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
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"}
start_time = datetime.fromisoformat(parsed["start_time"])
# Get user's configured max HR for accurate zone calculation
# Falls back to: user-set value → 220-age → activity max → 190
# Deduplicate: same user + sport_type + start_time within ±60s
from datetime import timedelta
existing = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == parsed["sport_type"],
Activity.start_time >= start_time - timedelta(seconds=60),
Activity.start_time <= start_time + timedelta(seconds=60),
)
).scalars().first()
if existing:
# Stamp garmin_activity_id if this came from a Garmin Connect sync
# so future syncs skip the fast-path dedup and don't re-download.
if garmin_activity_id and not existing.garmin_activity_id:
existing.garmin_activity_id = garmin_activity_id
db.commit()
return {"activity_id": existing.id, "status": "duplicate"}
# Get user max HR for zone calculation
from app.models.user import User as UserModel
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
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
@@ -94,20 +115,15 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
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"])
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
activity = Activity(
user_id=user_id,
name=parsed["name"],
sport_type=parsed["sport_type"],
garmin_activity_id=garmin_activity_id,
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
@@ -132,11 +148,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
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:
for p in parsed.get("data_points", []):
if not p.get("timestamp"):
continue
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
@@ -165,7 +179,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
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(
@@ -184,7 +197,6 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed)
# Auto route detection for running and cycling
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@@ -192,16 +204,17 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
@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.
"""
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
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)
try:
result = _parse(file_path)
except Exception as e:
return {"status": "error", "error": str(e), "file": file_path}
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
@@ -216,11 +229,13 @@ def parse_wellness_fit(file_path: str, user_id: int):
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)
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s,
sleep_score, sleep_start, sleep_end, sleep_stages)
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)
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake,
:sleep_score, :sleep_start, :sleep_end, :sleep_stages::json)
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),
@@ -233,12 +248,16 @@ def parse_wellness_fit(file_path: str, user_id: int):
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),
total_calories = GREATEST(EXCLUDED.total_calories, health_metrics.total_calories),
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s),
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start),
sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end),
sleep_stages = COALESCE(EXCLUDED.sleep_stages, health_metrics.sleep_stages)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": data.get("resting_hr"),
@@ -258,35 +277,35 @@ def parse_wellness_fit(file_path: str, user_id: int):
"sleep_light": data.get("sleep_light_s"),
"sleep_rem": data.get("sleep_rem_s"),
"sleep_awake": data.get("sleep_awake_s"),
"sleep_score": data.get("sleep_score"),
"sleep_start": data.get("sleep_start"),
"sleep_end": data.get("sleep_end"),
"sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None,
})
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.
"""
"""Auto-detect and link activities to named routes."""
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,
@@ -298,12 +317,12 @@ def detect_route(activity_id: int, user_id: int):
if route.reference_polyline and routes_are_similar(
new_act.polyline, route.reference_polyline,
new_act.bounding_box, route.bounding_box,
dist1=new_act.distance_m, dist2=route.distance_m,
):
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,
@@ -311,9 +330,8 @@ def detect_route(activity_id: int, user_id: int):
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,
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
)
).scalars().all()
@@ -321,8 +339,8 @@ def detect_route(activity_id: int, user_id: int):
if routes_are_similar(
new_act.polyline, candidate.polyline,
new_act.bounding_box, candidate.bounding_box,
dist1=new_act.distance_m, dist2=candidate.distance_m,
):
# 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
@@ -400,52 +418,208 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
import zipfile
import json
from app.core.database import SyncSessionLocal
from app.models.user import HealthMetric
from datetime import datetime, timezone
from sqlalchemy import text
INSERT_SQL = text("""
INSERT INTO health_metrics (user_id, date, resting_hr, max_hr_day, steps,
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
VALUES (:user_id, :date, :resting_hr, :max_hr_day, :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),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
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)
""")
def _extract_stress(item):
stress_data = item.get("allDayStress")
if not stress_data or not isinstance(stress_data, dict):
return item.get("averageStressLevel")
for agg in stress_data.get("aggregatorList", []):
if agg.get("type") == "TOTAL":
return agg.get("averageStressLevel")
return None
def _floors_from_item(item):
# UDS format reports meters; 1 floor = 3.048 m
meters = item.get("floorsAscendedInMeters")
if meters is not None:
return round(meters / 3.048)
return item.get("floorsAscended")
def _process_record(db, item):
date_str = item.get("calendarDate") or item.get("date")
if not date_str:
return
try:
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
except ValueError:
return
db.execute(INSERT_SQL, {
"user_id": user_id, "date": date_dt,
"resting_hr": item.get("restingHeartRate"),
"max_hr_day": item.get("maxHeartRate"),
"steps": item.get("totalSteps"),
"floors": _floors_from_item(item),
"active_cal": item.get("activeKilocalories"),
"total_cal": item.get("totalKilocalories"),
"stress": _extract_stress(item),
"spo2": item.get("avgSpo2"),
})
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"):
if not name.endswith(".json"):
continue
# Garmin Connect export stores daily summaries in UDSFile_*.json
# (DI-Connect-Aggregator). Older/alternative exports may use DailyMetrics.
is_uds = "UDSFile" in name
is_legacy = "DailyMetrics" in name
if not (is_uds or is_legacy):
continue
with zf.open(name) as f:
try:
data = json.load(f)
except Exception:
continue
# UDS files are lists of daily records; legacy format is a single object
records = data if isinstance(data, list) else [data]
for item in records:
if isinstance(item, dict):
_process_record(db, item)
db.commit()
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
@celery_app.task(name="sync_garmin_connect_user")
def sync_garmin_connect_user(user_id: int):
"""Sync Garmin Connect data (activities + wellness) for one user."""
from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness
from app.core.database import SyncSessionLocal
from app.models.user import GarminConnectConfig
from app.core.config import settings
from sqlalchemy import select
from datetime import datetime, timezone
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"),
})
with SyncSessionLocal() as db:
cfg = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
).scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
return {"status": "skipped"}
# Snapshot config values before any intermediate commits (commits expire ORM attrs)
email = cfg.email
password_enc = cfg.password_enc
token_store = cfg.token_store
last_sync_at = cfg.last_sync_at
sync_acts = cfg.sync_activities
sync_well = cfg.sync_wellness
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
cfg.last_sync_status = "Connecting to Garmin..."
db.commit()
try:
garmin, new_token = authenticate_garmin(email, password_enc, token_store)
except Exception as exc:
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = f"Auth error: {exc}"
db.commit()
return {"status": "auth_error", "error": str(exc)}
if new_token:
cfg.token_store = new_token
db.commit()
activities_queued = 0
wellness_days = 0
errors = []
def _set_status(text):
cfg.last_sync_status = text
db.commit()
if sync_acts:
_set_status("Syncing activities...")
try:
activities_queued = sync_activities(
garmin, user_id, last_sync_at, db, settings.file_store_path,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"activities: {exc}")
if sync_well:
_set_status("Syncing wellness...")
try:
wellness_days = sync_wellness(
garmin, user_id, last_sync_at, db,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = (
f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced"
if not errors else
f"Partial — {'; '.join(errors)}"
)
db.commit()
return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days}
@celery_app.task(name="sync_all_garmin_connect")
def sync_all_garmin_connect():
"""Hourly beat task: dispatch per-user sync for all enabled configs."""
from app.core.database import SyncSessionLocal
from app.models.user import GarminConnectConfig
from sqlalchemy import select
with SyncSessionLocal() as db:
configs = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True)
).scalars().all()
user_ids = [c.user_id for c in configs]
for uid in user_ids:
sync_garmin_connect_user.delay(uid)
return {"dispatched": len(user_ids)}
@celery_app.task(name="recalculate_hr_zones_for_user")
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
from app.services.fit_parser import calculate_hr_zones
from app.core.database import SyncSessionLocal
from app.models.user import Activity, ActivityDataPoint
from sqlalchemy import select
with SyncSessionLocal() as db:
activities = db.execute(
select(Activity).where(Activity.user_id == user_id)
).scalars().all()
for activity in activities:
data_points = db.execute(
select(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity.id)
).scalars().all()
points_dicts = [{"heart_rate": dp.heart_rate} for dp in data_points]
new_zones = calculate_hr_zones(points_dicts, new_max_hr)
if new_zones:
activity.hr_zones = new_zones
db.commit()
+2 -1
View File
@@ -13,7 +13,6 @@ 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
@@ -24,3 +23,5 @@ aiofiles==23.2.1
python-dateutil==2.9.0
pytz==2024.1
psycopg2-binary==2.9.9
garminconnect==0.2.24
cryptography==42.0.8
+3 -3
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY package.json ./
RUN npm install
COPY . .
ARG VITE_API_URL=/api
@@ -15,4 +15,4 @@ 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
EXPOSE 80
+2 -3
View File
@@ -20,8 +20,7 @@
"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"
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
@@ -30,4 +29,4 @@
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4"
}
}
}
+4 -10
View File
@@ -8,9 +8,11 @@ import ActivitiesPage from './pages/ActivitiesPage'
import ActivityDetailPage from './pages/ActivityDetailPage'
import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage'
import SegmentsPage from './pages/SegmentsPage'
import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage'
import UsersPage from './pages/UsersPage'
function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token)
@@ -25,16 +27,6 @@ export default function App() {
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 />} />
@@ -44,9 +36,11 @@ export default function App() {
<Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
)
@@ -39,55 +39,82 @@ function decodePolyline(encoded) {
return coords
}
function drawRoute(map, polyline, sportType, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, {
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(map)
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
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(map)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map)
}
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)
const polylineRef = useRef(polyline)
const sportTypeRef = useRef(sportType)
useEffect(() => { polylineRef.current = polyline }, [polyline])
useEffect(() => { sportTypeRef.current = sportType }, [sportType])
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 }
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)
if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, {
attribution: tile.attribution,
maxZoom: 19,
}).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
}, [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)
}
if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
}, [polyline, sportType])
// Position marker on timeline hover
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return
if (markerRef.current) {
@@ -97,9 +124,10 @@ 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>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
.addTo(mapInstanceRef.current)
}
}, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
}
}
@@ -62,16 +62,21 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
const domains = useMemo(() => {
const result = {}
for (const m of activeMetricConfigs) {
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
let vals = chartData.map(p => p[m.key]).filter(v => v != null)
if (!vals.length) continue
// Clamp GPS speed outliers (spikes cause absurd pace labels like 0:01/km)
if (m.key === 'speed_ms') {
const speedCap = sportType === 'cycling' ? 25 : 12
vals = vals.filter(v => v > 0 && v <= speedCap)
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])
}, [chartData, activeMetricConfigs, sportType])
if (!chartData.length) {
return (
@@ -95,7 +100,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
{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 }}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
@@ -115,6 +120,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
width={40}
tickFormatter={v => {
if (metric.key === 'speed_ms') {
if (v <= 0 || v > 25) return ''
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')}`
@@ -6,9 +6,11 @@ const nav = [
{ to: '/activities', label: 'Activities', icon: '🏃' },
{ to: '/health', label: 'Health', icon: '❤️' },
{ to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/segments', label: 'Segments', icon: '📏' },
{ to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' },
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
]
export default function Layout() {
@@ -31,7 +33,7 @@ export default function Layout() {
</div>
<nav className="flex-1 py-4 overflow-y-auto">
{nav.map(({ to, label, icon, exact }) => (
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).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 ${
@@ -0,0 +1,133 @@
import { useMemo } from 'react'
import { sportColor } from '../../utils/format'
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
}
function haversineDist([lat1, lng1], [lat2, lng2]) {
const R = 6371000
const dLat = (lat2 - lat1) * Math.PI / 180
const dLng = (lng2 - lng1) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it
const VW = 100
const VH = 80
const PAD = 6
function buildPaths(polyline, segStartM, segEndM) {
if (!polyline) return null
const coords = decodePolyline(polyline)
if (coords.length < 2) return null
const lats = coords.map(c => c[0])
const lngs = coords.map(c => c[1])
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
const latRange = maxLat - minLat || 0.001
const lngRange = maxLng - minLng || 0.001
const drawW = VW - PAD * 2
const drawH = VH - PAD * 2
const scale = Math.min(drawW / lngRange, drawH / latRange)
const offX = PAD + (drawW - lngRange * scale) / 2
const offY = PAD + (drawH - latRange * scale) / 2
const toXY = ([lat, lng]) => [
offX + (lng - minLng) * scale,
offY + (maxLat - lat) * scale,
]
const fullPath = coords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
if (segStartM == null || segEndM == null) return { fullPath, segPath: null }
// Compute cumulative distances to find segment slice
const cumDist = [0]
for (let i = 1; i < coords.length; i++) {
cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i]))
}
const totalDist = cumDist[cumDist.length - 1] || 1
// Interpolate a point at a given distance along the route
const interpAt = (targetM) => {
for (let i = 1; i < cumDist.length; i++) {
if (cumDist[i] >= targetM || i === cumDist.length - 1) {
const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1])
const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0])
const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1])
return [lat, lng]
}
}
return coords[coords.length - 1]
}
const clampedStart = Math.max(0, Math.min(segStartM, totalDist))
const clampedEnd = Math.max(0, Math.min(segEndM, totalDist))
// Collect segment points: interpolated start + all interior coords + interpolated end
const segCoords = [interpAt(clampedStart)]
for (let i = 0; i < coords.length; i++) {
if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) {
segCoords.push(coords[i])
}
}
segCoords.push(interpAt(clampedEnd))
const segPath = segCoords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
return { fullPath, segPath }
}
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) {
const paths = useMemo(
() => buildPaths(polyline, segmentStartM, segmentEndM),
[polyline, segmentStartM, segmentEndM],
)
const svgProps = {
viewBox: `0 0 ${VW} ${VH}`,
preserveAspectRatio: 'xMidYMid meet',
className: 'rounded overflow-hidden block',
style: { background: '#111827', width, height },
}
if (!paths) return (
<svg {...svgProps}>
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10"></text>
</svg>
)
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
return (
<svg {...svgProps}>
<path d={paths.fullPath} fill="none" stroke={baseColor} strokeWidth={paths.segPath ? 1.5 : 2}
strokeLinejoin="round" strokeLinecap="round" />
{paths.segPath && (
<path d={paths.segPath} fill="none" stroke="#f97316" strokeWidth="3"
strokeLinejoin="round" strokeLinecap="round" />
)}
</svg>
)
}
+13 -5
View File
@@ -1,11 +1,21 @@
import { create } from 'zustand'
import api from '../utils/api'
// Read token from URL params synchronously at module load time,
// before any component renders. This handles PocketID OAuth callbacks.
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
localStorage.setItem('token', urlToken)
window.history.replaceState({}, '', '/')
}
const initialToken = urlToken || localStorage.getItem('token')
export const useAuthStore = create((set) => ({
token: localStorage.getItem('token'),
token: initialToken,
user: null,
isLoading: false,
login: async (username, password) => {
set({ isLoading: true })
try {
@@ -23,12 +33,10 @@ export const useAuthStore = create((set) => ({
throw e
}
},
logout: () => {
localStorage.removeItem('token')
set({ token: null, user: null })
},
fetchUser: async () => {
try {
const { data } = await api.get('/auth/me')
@@ -38,4 +46,4 @@ export const useAuthStore = create((set) => ({
localStorage.removeItem('token')
}
},
}))
}))
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import {
formatDuration, formatDistance, formatPace, formatHeartRate,
@@ -10,24 +11,38 @@ import {
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
export default function ActivitiesPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [sport, setSport] = useState('all')
const [page, setPage] = useState(1)
const fromParam = searchParams.get('from')
const toParam = searchParams.get('to')
const { data: activities, isLoading } = useQuery({
queryKey: ['activities', sport, page],
queryKey: ['activities', sport, page, fromParam, toParam],
queryFn: () =>
api.get('/activities/', {
params: {
sport_type: sport === 'all' ? undefined : sport,
page,
per_page: 20,
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
},
}).then(r => r.data),
})
const { data: ytdStats } = useQuery({
queryKey: ['ytd-stats'],
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
})
const clearDateFilter = () => navigate('/activities')
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Activities</h1>
<Link
to="/upload"
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
</Link>
</div>
{/* YTD stats */}
{ytdStats && (
<div className="flex gap-4 mb-4 text-sm">
{ytdStats.running_km > 0 && (
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
)}
{ytdStats.cycling_km > 0 && (
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
)}
</div>
)}
{/* Date filter chip */}
{fromParam && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
</span>
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors"> Clear</button>
</div>
)}
{/* Sport filter */}
<div className="flex gap-2 mb-6 flex-wrap">
{SPORTS.map(s => (
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom'
import { Link, useNavigate } 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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import {
@@ -9,7 +9,69 @@ import {
formatDate, sportIcon, formatSleep,
} from '../utils/format'
function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
if (level >= 50) return '#22c55e'
if (level >= 25) return '#f59e0b'
return '#ef4444'
}
function MiniBodyBattery({ bb }) {
if (!bb?.end_level && !bb?.charged) return null
const { charged, drained, start_level, end_level, values } = bb
const color = bbLevelColor(end_level)
const sparkData = Array.isArray(values)
? values.map(([ts, level]) => ({ ts, level }))
: []
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
<Link to="/health" className="text-xs text-blue-400 hover:underline">View </Link>
</div>
<div className="flex items-baseline gap-3 flex-wrap">
{end_level != null && (
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
)}
{charged != null && (
<span className="text-sm font-semibold text-green-400">+{charged}</span>
)}
{drained != null && (
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
)}
</div>
{start_level != null && end_level != null && (
<p className="text-xs text-gray-500 mt-1">{start_level} {end_level} today</p>
)}
{sparkData.length >= 2 && (
<div className="mt-3">
<ResponsiveContainer width="100%" height={60}>
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="bbGrad" 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>
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)}`, 'Battery']}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}
function WeeklyChart({ activities }) {
const navigate = useNavigate()
if (!activities?.length) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
)
@@ -23,26 +85,39 @@ function WeeklyChart({ activities }) {
const data = weeks.map(weekStart => {
const weekKey = format(weekStart, 'MMM d')
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 7)
const weekEnd = addDays(weekStart, 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 {
week: weekKey,
km: parseFloat(km.toFixed(2)),
weekStartISO: format(weekStart, 'yyyy-MM-dd'),
weekEndISO: format(weekEnd, 'yyyy-MM-dd'),
}
})
const handleBarClick = (entry) => {
if (entry?.activePayload?.[0]?.payload) {
const { weekStartISO, weekEndISO } = entry.activePayload[0].payload
navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`)
}
}
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
onClick={handleBarClick} style={{ cursor: 'pointer' }}>
<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']} />
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']}
cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
@@ -73,8 +148,12 @@ export default function DashboardPage() {
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
})
const { data: ytdStats } = useQuery({
queryKey: ['ytd-stats'],
queryFn: () => api.get('/activities/stats/ytd').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">
@@ -84,19 +163,23 @@ export default function DashboardPage() {
</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="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
<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="grid grid-cols-1 lg:grid-cols-4 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">
<div className="lg:col-span-1">
<MiniBodyBattery bb={latest?.body_battery} />
</div>
<div className="lg:col-span-1 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 ? (
<>
@@ -9,14 +9,135 @@ import api from '../utils/api'
import { formatSleep, sportIcon } 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 },
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
{ label: '3Y', days: 1095 },
{ label: '5Y', days: 1825 },
]
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
// Garmin/Cooper Institute VO2 max thresholds
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
// value < fair_min → Poor; >= superior_min → Superior
const VO2_MALE = [
[29, [41.7, 45.4, 51.1, 55.4]],
[39, [40.5, 44.0, 48.3, 54.0]],
[49, [38.5, 42.4, 46.4, 52.5]],
[59, [35.6, 39.2, 43.4, 48.9]],
[69, [32.3, 35.5, 39.5, 45.7]],
[Infinity, [29.4, 32.3, 36.7, 42.1]],
]
const VO2_FEMALE = [
[29, [36.1, 39.5, 43.9, 49.6]],
[39, [34.4, 37.8, 42.4, 47.4]],
[49, [33.0, 36.3, 39.7, 45.3]],
[59, [30.1, 33.0, 36.7, 41.1]],
[69, [27.5, 30.0, 33.0, 37.8]],
[Infinity, [25.9, 28.1, 30.9, 36.7]],
]
const VO2_CATEGORIES = [
{ label: 'Poor', color: '#ef4444' },
{ label: 'Fair', color: '#f97316' },
{ label: 'Good', color: '#22c55e' },
{ label: 'Excellent', color: '#3b82f6' },
{ label: 'Superior', color: '#a855f7' },
]
function getVo2Category(value, age, sex) {
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
const thresholds = row[1]
// thresholds are lower-bounds: count how many the value meets or exceeds
const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0)
return VO2_CATEGORIES[idx]
}
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
const MIN = 30, MAX = 70
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
const cx = 70, cy = 74, r = 50, sw = 11
const age = birthYear ? new Date().getFullYear() - birthYear : 40
// Standard-math angle: PI = left (VO2 30), 0 = right (VO2 70)
const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN))))
// SVG coordinates for a VO2 value at a given radius from centre
const toXY = (v, radius = r) => {
const a = toAngle(v)
return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)]
}
// Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG
const arc = (v1, v2, radius = r) => {
const [x1, y1] = toXY(v1, radius)
const [x2, y2] = toXY(v2, radius)
const large = 0 // gauge spans 180°, so no segment ever exceeds 180°
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
}
// ACSM category boundaries for this user's age/sex
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
const thresholds = row[1]
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
// White arrow: tip lands exactly at the arc centre-line at the value's angle;
// base extends outside the track — unambiguously marks the precise position.
const arrowPts = value != null ? (() => {
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
const tipR = r // tip at centre of the coloured track
const baseR = r + sw / 2 + 9 // base well outside the outer edge
const s = 0.09 // half-spread ≈ 5° — narrow for precision
const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a)
const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s)
const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * Math.sin(a - s)
return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
})() : null
return (
<div className="flex flex-col items-center">
<svg width="140" height="92" viewBox="0 0 140 92">
{/* Dark background track, slightly wider than the colour bands */}
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
{/* Full-brightness ACSM colour bands */}
{VO2_CATEGORIES.map((c, i) => {
const v1 = Math.max(bounds[i], MIN)
const v2 = Math.min(bounds[i + 1], MAX)
if (v2 <= v1) return null
return (
<path key={i} d={arc(v1, v2)}
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
)
})}
{/* White arrow: tip at exact value position on arc, base pointing outward */}
{arrowPts && <polygon points={arrowPts} fill="white" />}
{/* VO2 number, coloured by category */}
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
{value != null ? value.toFixed(1) : '--'}
</text>
{/* Category label */}
<text x={cx} y={cy + 11} textAnchor="middle" dominantBaseline="middle"
fontSize="9" fill="#9ca3af">
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
</text>
</svg>
</div>
)
}
const tooltipStyle = {
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
}
@@ -304,7 +425,7 @@ function NavArrow({ onClick, disabled, children }) {
)
}
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) {
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -341,9 +462,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
</div>
{/* Sleep (wide) + Heart / HRV */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
{day.sleep_score != null && (
@@ -396,56 +517,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
) : null}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold text-rose-400">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold text-rose-400">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
</div>
{avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)}
{day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span>
: null}
</p>
)}
</div>
{avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)} bpm
{day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span>
: null}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400">
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
<HrvBadge status={day.hrv_status} />
<div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400">
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
</div>
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
</div>
</div>
{day.avg_hr_day && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
<span className="text-xl font-semibold text-orange-400">
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
</span>
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
</div>
</div>
)}
{day.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-semibold text-emerald-400">{day.weight_kg.toFixed(1)}</span>
<span className="text-xs text-gray-500">kg</span>
<span className="text-xl font-semibold text-emerald-400">
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
</span>
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
</div>
</div>
)}
</div>
</div>
</div>
@@ -519,14 +642,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
</div>
<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 flex flex-col">
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
</span>
<div className="flex-1 flex items-center justify-center">
<Vo2MaxGauge
value={(day.vo2max ?? latestVo2max) ?? null}
birthYear={birthYear}
biologicalSex={biologicalSex}
/>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
</div>
</div>
</div>
@@ -637,12 +762,17 @@ export default function HealthPage() {
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
// Full history for snapshot navigation.
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
const { data: allDays } = useQuery({
queryKey: ['health-metrics', 'all'],
queryFn: () =>
api.get('/health-metrics/', { params: { limit: 365 } })
api.get('/health-metrics/', { params: { limit: 2000 } })
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
})
@@ -721,6 +851,8 @@ export default function HealthPage() {
sleepStages={intradayData?.sleep_stages}
activities={dayActivities}
latestVo2max={latestVo2max}
birthYear={profile?.birth_year}
biologicalSex={profile?.biological_sex}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
@@ -857,6 +989,7 @@ export default function HealthPage() {
<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)}
connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
)}
@@ -7,7 +7,12 @@ import api from '../utils/api'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const authError = new URLSearchParams(window.location.search).get('auth_error')
const [error, setError] = useState(
authError === 'not_authorized'
? "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group."
: ''
)
const { login, isLoading } = useAuthStore()
const navigate = useNavigate()
@@ -74,7 +74,7 @@ export default function ProfilePage() {
}, [recentMetrics])
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' })
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
@@ -83,6 +83,7 @@ export default function ProfilePage() {
max_heart_rate: profile.max_heart_rate || '',
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '',
})
}, [profile])
@@ -204,10 +205,10 @@ export default function ProfilePage() {
}
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
const [pidSaved, setPidSaved] = useState(false)
useEffect(() => {
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
}, [pocketidConfig])
const savePocketID = useMutation({
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
@@ -246,6 +247,21 @@ export default function ProfilePage() {
<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>
<Field label="Biological sex" hint="Used for VO2 max fitness category thresholds">
<div className="flex gap-2">
{['male', 'female'].map(s => (
<button key={s} type="button"
onClick={() => setHrForm(f => ({ ...f, biological_sex: f.biological_sex === s ? '' : s }))}
className={`flex-1 py-2 rounded-lg text-sm border transition-colors capitalize ${
hrForm.biological_sex === s
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{s}
</button>
))}
</div>
</Field>
</div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
@@ -268,7 +284,7 @@ export default function ProfilePage() {
<SaveButton
onClick={() => {
const data = Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, k === 'biological_sex' ? v : parseFloat(v)])
)
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
updateProfile.mutate(data)
@@ -455,6 +471,10 @@ export default function ProfilePage() {
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
@@ -0,0 +1,365 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
function formatSegmentDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
const [expanded, setExpanded] = useState(false)
const queryClient = useQueryClient()
const { data: times, isLoading: timesLoading } = useQuery({
queryKey: ['segment-times', routeId, seg.id],
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
})
const deleteMut = useMutation({
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
})
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
const lastTime = times?.[0]?.duration_s ?? null
return (
<div className="border border-gray-800 rounded-lg overflow-hidden">
{/* Main row */}
<div className="flex items-center gap-3 p-3">
{/* Segment mini-map */}
<div className="flex-shrink-0">
<RouteMiniMap
polyline={routePolyline}
sportType={sportType}
width={72}
height={56}
segmentStartM={seg.start_distance_m}
segmentEndM={seg.end_distance_m}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
{seg.auto_generated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
{seg.auto_generated_type || 'auto'}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{formatSegmentDist(seg.start_distance_m)} {formatSegmentDist(seg.end_distance_m)}
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
</p>
{/* Times preview row */}
{!timesLoading && (
<div className="flex items-center gap-3 mt-1">
{bestTime && (
<span className="text-xs font-mono text-yellow-400">
Best {formatDuration(bestTime)}
</span>
)}
{lastTime && lastTime !== bestTime && (
<span className="text-xs font-mono text-gray-400">
Last {formatDuration(lastTime)}
</span>
)}
{times?.length > 0 && (
<span className="text-xs text-gray-600">
{times.length} run{times.length !== 1 ? 's' : ''}
</span>
)}
{times?.length === 0 && (
<span className="text-xs text-gray-600">No times yet</span>
)}
</div>
)}
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
>
{expanded ? 'Hide' : 'All'}
</button>
)}
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
title="Delete segment"
>
</button>
</div>
</div>
{/* Expanded times list */}
{expanded && times?.length > 0 && (
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
{times.map((t, i) => (
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
{formatDuration(t.duration_s)}
</span>
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
{t.name}
</Link>
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
</div>
))}
</div>
)}
</div>
)
}
function NewSegmentForm({ routeId, onCreated }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [startKm, setStartKm] = useState('')
const [endKm, setEndKm] = useState('')
const [open, setOpen] = useState(false)
const mut = useMutation({
mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
setName(''); setStartKm(''); setEndKm(''); setOpen(false)
if (onCreated) onCreated()
},
})
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
>
+ Add segment manually
</button>
)
}
const handleSubmit = (e) => {
e.preventDefault()
const start = parseFloat(startKm) * 1000
const end = parseFloat(endKm) * 1000
if (!name || isNaN(start) || isNaN(end) || end <= start) return
mut.mutate({ name, start_distance_m: start, end_distance_m: end })
}
return (
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
<p className="text-xs text-gray-400 font-medium">New segment</p>
<input
type="text" placeholder="Name (e.g. The big hill)"
value={name} onChange={e => setName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<div className="flex gap-2">
<input
type="number" placeholder="Start (km)" step="0.01" min="0"
value={startKm} onChange={e => setStartKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<input
type="number" placeholder="End (km)" step="0.01" min="0"
value={endKm} onChange={e => setEndKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mut.isPending}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
{mut.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => setOpen(false)}
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
Cancel
</button>
</div>
</form>
)
}
export default function SegmentsPage() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const [autoGenLoading, setAutoGenLoading] = useState(null)
const [hillGradient, setHillGradient] = useState(5)
const queryClient = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const selectedRoute = routes?.find(r => r.id === selectedRouteId)
const { data: segments, isLoading: segsLoading } = useQuery({
queryKey: ['segments', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data),
enabled: !!selectedRouteId,
})
const autoGenMut = useMutation({
mutationFn: ({ type, opts }) =>
api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] })
setAutoGenLoading(null)
},
onError: (err) => {
alert(err?.response?.data?.detail || 'Auto-generate failed')
setAutoGenLoading(null)
},
})
const handleAutoGen = (type, opts = {}) => {
setAutoGenLoading(type)
autoGenMut.mutate({ type, opts })
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Segments</h1>
</div>
{/* Route tile grid */}
{!routes?.length ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
<div className="flex items-center justify-between mt-0.5">
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
{r.activity_count > 0 && (
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
)}
</div>
</button>
))}
</div>
)}
{selectedRoute && (
<div className="space-y-4">
{/* Route info */}
<div className="flex items-center gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
<p className="text-xs text-gray-500">
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
</p>
</div>
</div>
{/* Auto-generate controls */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={() => handleAutoGen('1km')}
disabled={autoGenLoading === '1km'}
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
</button>
<button
onClick={() => handleAutoGen('turns')}
disabled={autoGenLoading === 'turns'}
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
disabled={autoGenLoading === 'hills'}
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
</button>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<input
type="number" min="1" max="30" step="1"
value={hillGradient}
onChange={e => setHillGradient(parseInt(e.target.value) || 5)}
className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-xs text-gray-500">%</span>
</div>
</div>
</div>
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
</div>
{/* Segments list */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
{segments?.length > 0 && (
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
)}
</div>
{segsLoading && <p className="text-sm text-gray-600">Loading</p>}
{!segsLoading && !segments?.length && (
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
)}
{segments?.map(seg => (
<SegmentRow
key={seg.id}
seg={seg}
routeId={selectedRouteId}
routePolyline={selectedRoute.reference_polyline}
sportType={selectedRoute.sport_type}
/>
))}
<NewSegmentForm routeId={selectedRouteId} />
</div>
</div>
)}
</div>
)
}
@@ -1,10 +1,38 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
function UploadZone({ title, description, accept, endpoint, icon }) {
const [tasks, setTasks] = useState([])
const queryClient = useQueryClient()
const intervalsRef = useRef({})
const pollTask = useCallback((taskId) => {
if (intervalsRef.current[taskId]) return
const intervalId = setInterval(async () => {
try {
const { data } = await api.get(`/upload/task/${taskId}`)
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId])
delete intervalsRef.current[taskId]
setTasks(ts => ts.map(t =>
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
))
if (data.status === 'SUCCESS') {
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
}
}
} catch { /* ignore transient poll errors */ }
}, 2000)
intervalsRef.current[taskId] = intervalId
}, [queryClient])
useEffect(() => {
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
}, [])
const upload = useMutation({
mutationFn: async (file) => {
@@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
return { file: file.name, ...data }
},
onSuccess: (data) => {
setTasks(t => [...t, { ...data, status: 'queued' }])
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
setTasks(t => [...t, task])
if (data.task_id) {
pollTask(data.task_id)
}
},
})
@@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
multiple: true,
})
function StatusBadge({ status }) {
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span>
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{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>
<StatusBadge status={task.status} />
</div>
))}
</div>
@@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
export default function UsersPage() {
const qc = useQueryClient()
const { user: me } = useAuthStore()
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users/').then(r => r.data),
})
const setAdmin = useMutation({
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
})
const deleteUser = useMutation({
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
})
const handleDelete = u => {
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
deleteUser.mutate(u.id)
}
}
return (
<div className="p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-xs text-gray-500 mt-1">
New users are created in PocketID and provisioned automatically on first passkey sign-in.
Each user's data is fully separate.
</p>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{isLoading ? (
<p className="p-5 text-sm text-gray-500">Loading…</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Sign-in</th>
<th className="px-4 py-3 font-medium text-right">Activities</th>
<th className="px-4 py-3 font-medium text-center">Admin</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{users?.map(u => {
const isMe = u.id === me?.id
return (
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
<td className="px-4 py-3">
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
</td>
<td className="px-4 py-3 text-gray-400">
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
</td>
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={u.is_admin}
disabled={isMe || setAdmin.isPending}
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
title={isMe ? "You can't change your own admin status" : ''}
/>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(u)}
disabled={isMe || deleteUser.isPending}
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
title={isMe ? "You can't delete your own account" : ''}
>
Delete
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}