From ec5a01d12a1d797073168c1079bacc707cd3d2d5 Mon Sep 17 00:00:00 2001 From: owain Date: Sat, 6 Jun 2026 18:10:35 +0100 Subject: [PATCH] All tweaks added --- backend/Dockerfile | 2 +- backend/app/api/auth.py | 74 ++- backend/app/api/profile.py | 220 +++++++++ backend/app/api/routes.py | 44 +- backend/app/core/config.py | 2 +- backend/app/core/database.py | 2 +- backend/app/main.py | 5 +- backend/app/models/user.py | 38 +- backend/app/services/fit_parser.py | 250 +++++----- backend/app/services/wellness_parser.py | 2 +- backend/app/workers/celery_app.py | 2 +- backend/app/workers/tasks.py | 281 +++++------ backend/requirements.txt | 3 +- frontend/Dockerfile | 2 +- frontend/package.json | 5 +- frontend/src/App.jsx | 12 +- .../src/components/activity/ActivityMap.jsx | 108 ++--- frontend/src/components/activity/LapTable.jsx | 4 +- .../components/activity/MetricTimeline.jsx | 45 +- frontend/src/components/ui/Layout.jsx | 24 +- frontend/src/pages/ActivitiesPage.jsx | 2 +- frontend/src/pages/ActivityDetailPage.jsx | 99 ++-- frontend/src/pages/DashboardPage.jsx | 116 ++--- frontend/src/pages/HealthPage.jsx | 205 +++----- frontend/src/pages/ProfilePage.jsx | 266 +++++++++++ frontend/src/pages/RoutesPage.jsx | 147 +++--- frontend/src/utils/format.js | 18 +- milevault_export/.env.example | 34 ++ milevault_export/.gitea/workflows/build.yml | 83 ++++ milevault_export/.gitignore | 12 + milevault_export/README.md | 153 ++++++ milevault_export/backend/Dockerfile | 16 + milevault_export/backend/Dockerfile.worker | 14 + milevault_export/backend/app/__init__.py | 0 milevault_export/backend/app/api/__init__.py | 0 .../backend/app/api/activities.py | 213 +++++++++ milevault_export/backend/app/api/auth.py | 122 +++++ milevault_export/backend/app/api/health.py | 156 ++++++ milevault_export/backend/app/api/profile.py | 220 +++++++++ milevault_export/backend/app/api/records.py | 62 +++ milevault_export/backend/app/api/routes.py | 232 +++++++++ milevault_export/backend/app/api/upload.py | 134 ++++++ milevault_export/backend/app/core/__init__.py | 0 milevault_export/backend/app/core/config.py | 39 ++ milevault_export/backend/app/core/database.py | 47 ++ milevault_export/backend/app/core/security.py | 55 +++ milevault_export/backend/app/main.py | 105 ++++ .../backend/app/models/__init__.py | 0 milevault_export/backend/app/models/user.py | 227 +++++++++ .../backend/app/services/__init__.py | 0 .../backend/app/services/fit_parser.py | 307 ++++++++++++ .../backend/app/services/route_matcher.py | 190 ++++++++ .../backend/app/services/wellness_parser.py | 309 ++++++++++++ .../backend/app/workers/__init__.py | 0 .../backend/app/workers/celery_app.py | 7 + milevault_export/backend/app/workers/tasks.py | 451 ++++++++++++++++++ milevault_export/backend/requirements.txt | 26 + milevault_export/docker-compose.deploy.yml | 114 +++++ milevault_export/docker-compose.yml | 111 +++++ milevault_export/docker/init.sql | 7 + milevault_export/frontend/Dockerfile | 18 + milevault_export/frontend/index.html | 13 + milevault_export/frontend/nginx-spa.conf | 14 + milevault_export/frontend/package.json | 33 ++ milevault_export/frontend/postcss.config.js | 6 + milevault_export/frontend/src/App.jsx | 53 ++ .../src/components/activity/ActivityMap.jsx | 105 ++++ .../src/components/activity/HRZoneBar.jsx | 43 ++ .../src/components/activity/LapTable.jsx | 40 ++ .../components/activity/MetricTimeline.jsx | 147 ++++++ .../frontend/src/components/ui/Layout.jsx | 62 +++ .../frontend/src/components/ui/StatCard.jsx | 18 + .../frontend/src/hooks/useAuth.js | 41 ++ milevault_export/frontend/src/index.css | 33 ++ milevault_export/frontend/src/main.jsx | 22 + .../frontend/src/pages/ActivitiesPage.jsx | 147 ++++++ .../frontend/src/pages/ActivityDetailPage.jsx | 197 ++++++++ .../frontend/src/pages/DashboardPage.jsx | 171 +++++++ .../frontend/src/pages/HealthPage.jsx | 213 +++++++++ .../frontend/src/pages/LoginPage.jsx | 102 ++++ .../frontend/src/pages/ProfilePage.jsx | 266 +++++++++++ .../frontend/src/pages/RecordsPage.jsx | 177 +++++++ .../frontend/src/pages/RoutesPage.jsx | 186 ++++++++ .../frontend/src/pages/UploadPage.jsx | 178 +++++++ milevault_export/frontend/src/utils/api.js | 26 + milevault_export/frontend/src/utils/format.js | 94 ++++ milevault_export/frontend/tailwind.config.js | 18 + milevault_export/frontend/vite.config.js | 14 + milevault_export/install.sh | 209 ++++++++ milevault_export/nginx.conf | 50 ++ milevault_export/nginx/nginx.conf | 59 +++ milevault_export/scripts/manage.sh | 122 +++++ 92 files changed, 7517 insertions(+), 784 deletions(-) create mode 100644 backend/app/api/profile.py create mode 100644 frontend/src/pages/ProfilePage.jsx create mode 100644 milevault_export/.env.example create mode 100644 milevault_export/.gitea/workflows/build.yml create mode 100644 milevault_export/.gitignore create mode 100644 milevault_export/README.md create mode 100644 milevault_export/backend/Dockerfile create mode 100644 milevault_export/backend/Dockerfile.worker create mode 100644 milevault_export/backend/app/__init__.py create mode 100644 milevault_export/backend/app/api/__init__.py create mode 100644 milevault_export/backend/app/api/activities.py create mode 100644 milevault_export/backend/app/api/auth.py create mode 100644 milevault_export/backend/app/api/health.py create mode 100644 milevault_export/backend/app/api/profile.py create mode 100644 milevault_export/backend/app/api/records.py create mode 100644 milevault_export/backend/app/api/routes.py create mode 100644 milevault_export/backend/app/api/upload.py create mode 100644 milevault_export/backend/app/core/__init__.py create mode 100644 milevault_export/backend/app/core/config.py create mode 100644 milevault_export/backend/app/core/database.py create mode 100644 milevault_export/backend/app/core/security.py create mode 100644 milevault_export/backend/app/main.py create mode 100644 milevault_export/backend/app/models/__init__.py create mode 100644 milevault_export/backend/app/models/user.py create mode 100644 milevault_export/backend/app/services/__init__.py create mode 100644 milevault_export/backend/app/services/fit_parser.py create mode 100644 milevault_export/backend/app/services/route_matcher.py create mode 100644 milevault_export/backend/app/services/wellness_parser.py create mode 100644 milevault_export/backend/app/workers/__init__.py create mode 100644 milevault_export/backend/app/workers/celery_app.py create mode 100644 milevault_export/backend/app/workers/tasks.py create mode 100644 milevault_export/backend/requirements.txt create mode 100644 milevault_export/docker-compose.deploy.yml create mode 100644 milevault_export/docker-compose.yml create mode 100644 milevault_export/docker/init.sql create mode 100644 milevault_export/frontend/Dockerfile create mode 100644 milevault_export/frontend/index.html create mode 100644 milevault_export/frontend/nginx-spa.conf create mode 100644 milevault_export/frontend/package.json create mode 100644 milevault_export/frontend/postcss.config.js create mode 100644 milevault_export/frontend/src/App.jsx create mode 100644 milevault_export/frontend/src/components/activity/ActivityMap.jsx create mode 100644 milevault_export/frontend/src/components/activity/HRZoneBar.jsx create mode 100644 milevault_export/frontend/src/components/activity/LapTable.jsx create mode 100644 milevault_export/frontend/src/components/activity/MetricTimeline.jsx create mode 100644 milevault_export/frontend/src/components/ui/Layout.jsx create mode 100644 milevault_export/frontend/src/components/ui/StatCard.jsx create mode 100644 milevault_export/frontend/src/hooks/useAuth.js create mode 100644 milevault_export/frontend/src/index.css create mode 100644 milevault_export/frontend/src/main.jsx create mode 100644 milevault_export/frontend/src/pages/ActivitiesPage.jsx create mode 100644 milevault_export/frontend/src/pages/ActivityDetailPage.jsx create mode 100644 milevault_export/frontend/src/pages/DashboardPage.jsx create mode 100644 milevault_export/frontend/src/pages/HealthPage.jsx create mode 100644 milevault_export/frontend/src/pages/LoginPage.jsx create mode 100644 milevault_export/frontend/src/pages/ProfilePage.jsx create mode 100644 milevault_export/frontend/src/pages/RecordsPage.jsx create mode 100644 milevault_export/frontend/src/pages/RoutesPage.jsx create mode 100644 milevault_export/frontend/src/pages/UploadPage.jsx create mode 100644 milevault_export/frontend/src/utils/api.js create mode 100644 milevault_export/frontend/src/utils/format.js create mode 100644 milevault_export/frontend/tailwind.config.js create mode 100644 milevault_export/frontend/vite.config.js create mode 100755 milevault_export/install.sh create mode 100644 milevault_export/nginx.conf create mode 100644 milevault_export/nginx/nginx.conf create mode 100755 milevault_export/scripts/manage.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 28c262f..28e00d0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,4 +13,4 @@ COPY . . # Single worker avoids race condition during DB initialization. # For a personal app this is fine; async handles concurrent requests well. -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 033843b..c4536ae 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -7,13 +7,23 @@ from typing import Optional import httpx from app.core.database import get_db -from app.core.security import verify_password, create_access_token, hash_password, get_current_user +from app.core.security import verify_password, create_access_token, get_current_user from app.core.config import settings from app.models.user import User router = APIRouter() +async def _get_pocketid_config(db: AsyncSession): + """Get PocketID config from DB (admin user) falling back to env vars.""" + result = await db.execute(select(User).where(User.is_admin == True).limit(1)) + admin = result.scalar_one_or_none() + issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer + client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id + client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret + return issuer, client_id, client_secret + + class Token(BaseModel): access_token: str token_type: str @@ -37,24 +47,15 @@ async def login( form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db), ): - result = await db.execute( - select(User).where(User.username == form_data.username) - ) + result = await db.execute(select(User).where(User.username == form_data.username)) user = result.scalar_one_or_none() - if not user or not user.hashed_password: raise HTTPException(status_code=400, detail="Invalid credentials") if not verify_password(form_data.password, user.hashed_password): raise HTTPException(status_code=400, detail="Invalid credentials") - token = create_access_token({"sub": str(user.id)}) - return Token( - access_token=token, - token_type="bearer", - user_id=user.id, - username=user.username, - is_admin=user.is_admin, - ) + return Token(access_token=token, token_type="bearer", + user_id=user.id, username=user.username, is_admin=user.is_admin) @router.get("/me", response_model=UserOut) @@ -63,51 +64,44 @@ async def get_me(current_user: User = Depends(get_current_user)): @router.get("/pocketid/available") -async def pocketid_available(): - return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)} +async def pocketid_available(db: AsyncSession = Depends(get_db)): + issuer, client_id, _ = await _get_pocketid_config(db) + return {"available": bool(issuer and client_id)} @router.get("/pocketid/login-url") -async def pocketid_login_url(): - """Return the OIDC authorization URL for PocketID.""" - if not settings.pocketid_issuer: +async def pocketid_login_url(db: AsyncSession = Depends(get_db)): + issuer, client_id, _ = await _get_pocketid_config(db) + if not issuer or not client_id: raise HTTPException(status_code=404, detail="PocketID not configured") - + from urllib.parse import urlencode params = { - "client_id": settings.pocketid_client_id, + "client_id": client_id, "redirect_uri": "/api/auth/pocketid/callback", "response_type": "code", "scope": "openid profile email", } - from urllib.parse import urlencode - url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}" - return {"url": url} + return {"url": f"{issuer}/authorize?{urlencode(params)}"} @router.get("/pocketid/callback") async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): - """Exchange OIDC code for tokens and create/login user.""" - if not settings.pocketid_issuer: + issuer, client_id, client_secret = await _get_pocketid_config(db) + if not issuer: raise HTTPException(status_code=404, detail="PocketID not configured") - # Exchange code for tokens async with httpx.AsyncClient() as client: resp = await client.post( - f"{settings.pocketid_issuer}/token", - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "/api/auth/pocketid/callback", - "client_id": settings.pocketid_client_id, - "client_secret": settings.pocketid_client_secret, - }, + f"{issuer}/token", + data={"grant_type": "authorization_code", "code": code, + "redirect_uri": "/api/auth/pocketid/callback", + "client_id": client_id, "client_secret": client_secret}, ) if resp.status_code != 200: raise HTTPException(status_code=400, detail="Token exchange failed") - tokens = resp.json() userinfo_resp = await client.get( - f"{settings.pocketid_issuer}/userinfo", + f"{issuer}/userinfo", headers={"Authorization": f"Bearer {tokens['access_token']}"}, ) userinfo = userinfo_resp.json() @@ -118,17 +112,11 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.pocketid_sub == sub)) user = result.scalar_one_or_none() - if not user: - user = User( - username=preferred_username, - email=email, - pocketid_sub=sub, - ) + user = User(username=preferred_username, email=email, pocketid_sub=sub) db.add(user) await db.flush() token = create_access_token({"sub": str(user.id)}) - # Redirect to frontend with token from fastapi.responses import RedirectResponse return RedirectResponse(url=f"/?token={token}") diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py new file mode 100644 index 0000000..bcd2fdd --- /dev/null +++ b/backend/app/api/profile.py @@ -0,0 +1,220 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, date, timezone + +from app.core.database import get_db +from app.core.security import get_current_user, hash_password, verify_password +from app.models.user import User, WeightLog + +router = APIRouter() + + +# ── Profile ──────────────────────────────────────────────────────────────── + +class ProfileUpdate(BaseModel): + max_heart_rate: Optional[int] = None + resting_heart_rate: Optional[int] = None + birth_year: Optional[int] = None + height_cm: Optional[float] = None + + +class ProfileOut(BaseModel): + id: int + username: str + email: Optional[str] + max_heart_rate: Optional[int] + resting_heart_rate: Optional[int] + birth_year: Optional[int] + height_cm: Optional[float] + estimated_max_hr: Optional[int] + is_admin: bool + + class Config: + from_attributes = True + + +def _estimated_max_hr(user: User) -> Optional[int]: + if user.birth_year: + return 220 - (datetime.now().year - user.birth_year) + return None + + +@router.get("/", response_model=ProfileOut) +async def get_profile(current_user: User = Depends(get_current_user)): + return {**{c.name: getattr(current_user, c.name) + for c in User.__table__.columns}, + "estimated_max_hr": _estimated_max_hr(current_user)} + + +@router.patch("/", response_model=ProfileOut) +async def update_profile( + body: ProfileUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if body.max_heart_rate is not None: + if not (100 <= body.max_heart_rate <= 250): + raise HTTPException(400, "Max HR must be 100–250") + current_user.max_heart_rate = body.max_heart_rate + if body.resting_heart_rate is not None: + if not (20 <= body.resting_heart_rate <= 120): + raise HTTPException(400, "Resting HR must be 20–120") + current_user.resting_heart_rate = body.resting_heart_rate + if body.birth_year is not None: + if not (1920 <= body.birth_year <= 2010): + raise HTTPException(400, "Invalid birth year") + current_user.birth_year = body.birth_year + if body.height_cm is not None: + if not (50 <= body.height_cm <= 300): + raise HTTPException(400, "Height must be 50–300 cm") + current_user.height_cm = body.height_cm + + await db.commit() + await db.refresh(current_user) + return {**{c.name: getattr(current_user, c.name) + for c in User.__table__.columns}, + "estimated_max_hr": _estimated_max_hr(current_user)} + + +# ── Password change ──────────────────────────────────────────────────────── + +class PasswordChange(BaseModel): + current_password: str + new_password: str + + +@router.post("/change-password") +async def change_password( + body: PasswordChange, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not current_user.hashed_password: + raise HTTPException(400, "Account uses passkey login — no password to change") + if not verify_password(body.current_password, current_user.hashed_password): + raise HTTPException(400, "Current password is incorrect") + if len(body.new_password) < 8: + raise HTTPException(400, "New password must be at least 8 characters") + current_user.hashed_password = hash_password(body.new_password) + await db.commit() + return {"status": "ok"} + + +# ── PocketID configuration (admin only) ──────────────────────────────────── + +class PocketIDConfig(BaseModel): + issuer: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + + +@router.get("/pocketid-config") +async def get_pocketid_config(current_user: User = Depends(get_current_user)): + if not current_user.is_admin: + raise HTTPException(403, "Admin only") + from app.core.config import settings + # Show DB config if set, fall back to env + issuer = current_user.pocketid_issuer or settings.pocketid_issuer + client_id = current_user.pocketid_client_id or settings.pocketid_client_id + return { + "issuer": issuer or "", + "client_id": client_id or "", + "client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret), + "enabled": bool(issuer and client_id), + } + + +@router.post("/pocketid-config") +async def save_pocketid_config( + body: PocketIDConfig, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not current_user.is_admin: + raise HTTPException(403, "Admin only") + if body.issuer is not None: + current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None + if body.client_id is not None: + current_user.pocketid_client_id = body.client_id or None + if body.client_secret is not None: + current_user.pocketid_client_secret = body.client_secret or None + await db.commit() + return {"status": "ok"} + + +# ── Weight log ───────────────────────────────────────────────────────────── + +class WeightEntry(BaseModel): + date: datetime + weight_kg: float + body_fat_pct: Optional[float] = None + note: Optional[str] = None + + +class WeightOut(BaseModel): + id: int + date: datetime + weight_kg: float + body_fat_pct: Optional[float] + note: Optional[str] + + class Config: + from_attributes = True + + +@router.get("/weight", response_model=List[WeightOut]) +async def list_weight( + limit: int = 365, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(WeightLog) + .where(WeightLog.user_id == current_user.id) + .order_by(desc(WeightLog.date)) + .limit(limit) + ) + return result.scalars().all() + + +@router.post("/weight", response_model=WeightOut) +async def log_weight( + body: WeightEntry, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if not (20 <= body.weight_kg <= 500): + raise HTTPException(400, "Weight must be 20–500 kg") + entry = WeightLog( + user_id=current_user.id, + date=body.date, + weight_kg=body.weight_kg, + body_fat_pct=body.body_fat_pct, + note=body.note, + ) + db.add(entry) + await db.commit() + await db.refresh(entry) + return entry + + +@router.delete("/weight/{entry_id}", status_code=204) +async def delete_weight( + entry_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(WeightLog).where( + WeightLog.id == entry_id, + WeightLog.user_id == current_user.id, + ) + ) + entry = result.scalar_one_or_none() + if not entry: + raise HTTPException(404, "Not found") + await db.delete(entry) + await db.commit() diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 8c0757a..ce7c8f1 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from pydantic import BaseModel from typing import Optional, List -from datetime import datetime +from datetime import datetime, timedelta, timezone from app.core.database import get_db from app.core.security import get_current_user @@ -23,7 +23,7 @@ class RouteCreate(BaseModel): name: str description: Optional[str] = None sport_type: Optional[str] = None - activity_id: int # use this activity as the reference route + activity_id: int class RouteOut(BaseModel): @@ -34,6 +34,7 @@ class RouteOut(BaseModel): reference_polyline: Optional[str] bounding_box: Optional[dict] distance_m: Optional[float] + auto_detected: Optional[bool] created_at: datetime class Config: @@ -64,13 +65,44 @@ async def list_routes( return result.scalars().all() +@router.get("/recent-activities") +async def recent_activities_for_route( + days: int = Query(14, ge=1, le=90), + sport_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return recent activities for the route creation dropdown.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + q = select(Activity).where( + Activity.user_id == current_user.id, + Activity.start_time >= cutoff, + Activity.sport_type != "swimming", + ) + if sport_type: + q = q.where(Activity.sport_type == sport_type) + q = q.order_by(desc(Activity.start_time)).limit(50) + result = await db.execute(q) + activities = result.scalars().all() + return [ + { + "id": a.id, + "name": a.name, + "sport_type": a.sport_type, + "start_time": a.start_time, + "distance_m": a.distance_m, + "duration_s": a.duration_s, + } + for a in activities + ] + + @router.post("/", response_model=RouteOut) async def create_route( body: RouteCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - # Load the reference activity act_result = await db.execute( select(Activity).where( Activity.id == body.activity_id, @@ -89,11 +121,10 @@ async def create_route( reference_polyline=activity.polyline, bounding_box=activity.bounding_box, distance_m=activity.distance_m, + auto_detected=False, ) db.add(route) await db.flush() - - # Link this activity to the route activity.named_route_id = route.id await db.commit() await db.refresh(route) @@ -124,7 +155,6 @@ async def route_activities( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """All activities on this named route, ordered fastest first.""" result = await db.execute( select(Activity).where( Activity.named_route_id == route_id, @@ -153,7 +183,6 @@ async def assign_activity_to_route( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Manually assign an activity to a named route.""" activity_id = body.get("activity_id") act_result = await db.execute( select(Activity).where( @@ -164,7 +193,6 @@ async def assign_activity_to_route( activity = act_result.scalar_one_or_none() if not activity: raise HTTPException(status_code=404, detail="Activity not found") - activity.named_route_id = route_id await db.commit() return {"status": "ok"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e61e268..71778e8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -36,4 +36,4 @@ class Settings(BaseSettings): case_sensitive = False -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 726fb25..cd4242b 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -44,4 +44,4 @@ async def get_db(): await session.rollback() raise finally: - await session.close() \ No newline at end of file + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py index 97e142a..c1a9204 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ import asyncio from app.core.database import engine, AsyncSessionLocal, Base from app.core.config import settings -from app.api import auth, activities, routes, health, records, upload +from app.api import auth, activities, routes, health, records, upload, profile async def init_db(): @@ -97,8 +97,9 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"]) app.include_router(health.router, prefix="/api/health-metrics", tags=["health"]) app.include_router(records.router, prefix="/api/records", tags=["records"]) app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) +app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @app.get("/health") async def healthcheck(): - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 41d8c8e..8275b29 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -22,9 +22,39 @@ class User(Base): pocketid_sub = Column(String(256), unique=True, nullable=True) created_at = Column(DateTime(timezone=True), default=now_utc) + # Health profile + max_heart_rate = Column(Integer, nullable=True) + resting_heart_rate = Column(Integer, nullable=True) + birth_year = Column(Integer, nullable=True) + height_cm = Column(Float, nullable=True) + + # PocketID config (stored per-user so admin can set via UI) + pocketid_issuer = Column(String(512), nullable=True) + pocketid_client_id = Column(String(256), nullable=True) + pocketid_client_secret = Column(String(256), nullable=True) + activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan") health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan") + weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan") + + +class WeightLog(Base): + """Manual weight entries separate from health_metrics for easy tracking.""" + __tablename__ = "weight_logs" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + date = Column(DateTime(timezone=True), nullable=False) + weight_kg = Column(Float, nullable=False) + body_fat_pct = Column(Float, nullable=True) + note = Column(String(256), nullable=True) + + __table_args__ = ( + Index("ix_weight_user_date", "user_id", "date"), + ) + + user = relationship("User", back_populates="weight_logs") class Activity(Base): @@ -68,11 +98,6 @@ class Activity(Base): class ActivityDataPoint(Base): - """ - TimescaleDB hypertable - one row per second of activity data. - Composite primary key (activity_id, timestamp) satisfies TimescaleDB's - requirement that the partition column be part of the primary key. - """ __tablename__ = "activity_data_points" activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True) @@ -118,6 +143,7 @@ class NamedRoute(Base): reference_polyline = Column(Text, nullable=True) bounding_box = Column(JSON, nullable=True) distance_m = Column(Float, nullable=True) + auto_detected = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), default=now_utc) user = relationship("User", back_populates="named_routes") @@ -198,4 +224,4 @@ class HealthMetric(Base): Index("ix_health_user_date", "user_id", "date"), ) - user = relationship("User", back_populates="health_metrics") \ No newline at end of file + user = relationship("User", back_populates="health_metrics") diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py index a6d9fe9..13ac510 100644 --- a/backend/app/services/fit_parser.py +++ b/backend/app/services/fit_parser.py @@ -1,21 +1,24 @@ """ -Parses Garmin .fit files and GPX files into normalized activity data. -Handles full Strava and Garmin data export archives. +FIT and GPX file parser using: +- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files +- gpxpy for .gpx files + +The official SDK correctly handles scale/offset, component expansion, +semicircle-to-degree conversion, and HR message merging. """ -import os -import zipfile -import json import math from pathlib import Path -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Optional -import fitparse import gpxpy import polyline as polyline_lib +FIT_EPOCH_S = 631065600 + + def haversine_distance(lat1, lon1, lat2, lon2) -> float: - """Returns distance in metres between two GPS points.""" + """Distance in metres between two GPS points.""" R = 6371000 phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) @@ -24,106 +27,100 @@ def haversine_distance(lat1, lon1, lat2, lon2) -> float: return 2 * R * math.asin(math.sqrt(a)) -def semicircles_to_degrees(sc: int) -> float: - return sc * (180 / 2**31) +def _safe_float(val) -> Optional[float]: + try: + return float(val) if val is not None else None + except (TypeError, ValueError): + return None + + +def _bounding_box(coords: list) -> Optional[dict]: + if not coords: + return None + lats = [c[0] for c in coords] + lons = [c[1] for c in coords] + return {"min_lat": min(lats), "max_lat": max(lats), + "min_lon": min(lons), "max_lon": max(lons)} def parse_fit_file(filepath: str) -> dict: - """Parse a Garmin .fit file and return normalized activity dict.""" - fit = fitparse.FitFile(filepath) + """Parse a Garmin .fit activity file using the official Garmin SDK.""" + from garmin_fit_sdk import Decoder, Stream - data_points = [] - laps = [] session = {} + records = [] + laps = [] - for record in fit.get_messages(): - name = record.name + def listener(mesg_num: int, msg: dict): + nonlocal session + if mesg_num == 18: # session + session = msg + elif mesg_num == 20: # record + records.append(msg) + elif mesg_num == 19: # lap + laps.append(msg) - if name == "session": - for f in record: - session[f.name] = f.value + stream = Stream.from_file(filepath) + decoder = Decoder(stream) + decoder.read( + apply_scale_and_offset=True, + convert_datetimes_to_dates=True, + convert_types_to_strings=True, + enable_crc_check=False, + expand_sub_fields=True, + expand_components=True, + merge_heart_rates=True, + mesg_listener=listener, + ) - elif name == "lap": - lap = {} - for f in record: - lap[f.name] = f.value - laps.append(lap) - - elif name == "record": - point = {} - for f in record: - point[f.name] = f.value - if point: - # Convert semicircles to degrees - if "position_lat" in point and point["position_lat"] is not None: - point["position_lat"] = semicircles_to_degrees(point["position_lat"]) - if "position_long" in point and point["position_long"] is not None: - point["position_long"] = semicircles_to_degrees(point["position_long"]) - data_points.append(point) - - # Build normalized output + # Map sport type sport = str(session.get("sport", "generic")).lower() sport_map = { "running": "running", "cycling": "cycling", "swimming": "swimming", "hiking": "hiking", "walking": "walking", "generic": "other", "open_water_swimming": "swimming", "trail_running": "running", + "e_biking": "cycling", } sport_type = sport_map.get(sport, sport) start_time = session.get("start_time") - if start_time and start_time.tzinfo is None: + if isinstance(start_time, datetime) and start_time.tzinfo is None: start_time = start_time.replace(tzinfo=timezone.utc) - # Build GPS track for polyline + # Build GPS track coords = [ - (p["position_lat"], p["position_long"]) - for p in data_points - if p.get("position_lat") is not None and p.get("position_long") is not None + (r["position_lat"], r["position_long"]) + for r in records + if r.get("position_lat") is not None and r.get("position_long") is not None ] - encoded_polyline = polyline_lib.encode(coords) if coords else None bounding_box = _bounding_box(coords) - # Calculate cumulative distance if not in FIT - cumulative_dist = 0.0 - prev_lat, prev_lon = None, None + # Normalize data points normalized_points = [] - for p in data_points: - ts = p.get("timestamp") - if ts and ts.tzinfo is None: + for r in records: + ts = r.get("timestamp") + if isinstance(ts, datetime) and ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) - lat = p.get("position_lat") - lon = p.get("position_long") - - dist = p.get("distance") - if dist is None and lat and lon and prev_lat and prev_lon: - cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon) - dist = cumulative_dist - elif dist is not None: - cumulative_dist = float(dist) - - if lat and lon: - prev_lat, prev_lon = lat, lon - normalized_points.append({ "timestamp": ts.isoformat() if ts else None, - "latitude": lat, - "longitude": lon, - "altitude_m": p.get("altitude"), - "heart_rate": p.get("heart_rate"), - "cadence": p.get("cadence"), - "speed_ms": p.get("speed"), - "power": p.get("power"), - "temperature_c": p.get("temperature"), - "distance_m": dist, + "latitude": r.get("position_lat"), + "longitude": r.get("position_long"), + "altitude_m": r.get("altitude") or r.get("enhanced_altitude"), + "heart_rate": r.get("heart_rate"), + "cadence": r.get("cadence") or r.get("fractional_cadence"), + "speed_ms": r.get("speed") or r.get("enhanced_speed"), + "power": r.get("power"), + "temperature_c": r.get("temperature"), + "distance_m": r.get("distance"), }) - # Parse laps + # Normalize laps normalized_laps = [] for i, lap in enumerate(laps): ls = lap.get("start_time") - if ls and ls.tzinfo is None: + if isinstance(ls, datetime) and ls.tzinfo is None: ls = ls.replace(tzinfo=timezone.utc) normalized_laps.append({ "lap_number": i + 1, @@ -132,13 +129,17 @@ def parse_fit_file(filepath: str) -> dict: "distance_m": _safe_float(lap.get("total_distance")), "avg_heart_rate": _safe_float(lap.get("avg_heart_rate")), "avg_cadence": _safe_float(lap.get("avg_cadence")), - "avg_speed_ms": _safe_float(lap.get("avg_speed")), + "avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")), "avg_power": _safe_float(lap.get("avg_power")), }) + # Build activity name + name = session.get("sport", "Activity").title() + if start_time: + name += " " + start_time.strftime("%Y-%m-%d") + return { - "name": session.get("sport", "Activity").title() + " " + ( - start_time.strftime("%Y-%m-%d") if start_time else ""), + "name": name, "sport_type": sport_type, "start_time": start_time.isoformat() if start_time else None, "distance_m": _safe_float(session.get("total_distance")), @@ -150,12 +151,12 @@ def parse_fit_file(filepath: str) -> dict: "avg_cadence": _safe_float(session.get("avg_cadence")), "avg_power": _safe_float(session.get("avg_power")), "normalized_power": _safe_float(session.get("normalized_power")), - "avg_speed_ms": _safe_float(session.get("avg_speed")), - "max_speed_ms": _safe_float(session.get("max_speed")), + "avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")), + "max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")), "avg_temperature_c": _safe_float(session.get("avg_temperature")), "calories": _safe_float(session.get("total_calories")), "training_stress_score": _safe_float(session.get("training_stress_score")), - "vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device + "vo2max_estimate": _safe_float(session.get("total_training_effect")), "polyline": encoded_polyline, "bounding_box": bounding_box, "source_type": "fit", @@ -165,13 +166,12 @@ def parse_fit_file(filepath: str) -> dict: def parse_gpx_file(filepath: str) -> dict: - """Parse a GPX file into normalized activity dict.""" + """Parse a GPX file.""" with open(filepath) as f: gpx = gpxpy.parse(f) data_points = [] track = gpx.tracks[0] if gpx.tracks else None - if not track: raise ValueError("No tracks found in GPX file") @@ -204,7 +204,6 @@ def parse_gpx_file(filepath: str) -> dict: "distance_m": None, }) - # Calculate distance and elevation coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]] encoded_polyline = polyline_lib.encode(coords) if coords else None @@ -220,6 +219,7 @@ def parse_gpx_file(filepath: str) -> dict: prev = (p["latitude"], p["longitude"]) p["distance_m"] = total_dist + # Elevation gain/loss uphill, downhill = 0.0, 0.0 alts = [p["altitude_m"] for p in data_points if p["altitude_m"]] for i in range(1, len(alts)): @@ -235,7 +235,7 @@ def parse_gpx_file(filepath: str) -> dict: end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None - sport = "running" # GPX doesn't always include sport; default to running + sport = "running" if track.type: sport = track.type.lower() @@ -266,76 +266,42 @@ def parse_gpx_file(filepath: str) -> dict: } -def parse_strava_export(export_dir: str) -> list[dict]: +def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict: """ - Parse a full Strava data export directory. - Structure: activities.csv + activities/ folder with .gpx/.fit.gz files + Calculate % time in each HR zone using the user's configured max HR. + + Zones follow the standard 5-zone model as % of max HR: + Z1 Recovery: < 60% + Z2 Base: 60 - 70% + Z3 Tempo: 70 - 80% + Z4 Threshold: 80 - 90% + Z5 Max: > 90% + + user_max_hr should be the user's actual physiological max HR, NOT the + highest HR recorded in this activity. Using activity max shifts all zones + upward and makes easy runs look harder than they are. """ - activities = [] - activities_dir = Path(export_dir) / "activities" - - if not activities_dir.exists(): - return activities - - for fname in sorted(activities_dir.iterdir()): - if fname.suffix in (".fit", ".gpx"): - try: - if fname.suffix == ".fit": - act = parse_fit_file(str(fname)) - else: - act = parse_gpx_file(str(fname)) - act["source_type"] = "strava_" + fname.suffix[1:] - activities.append(act) - except Exception as e: - print(f"Error parsing {fname}: {e}") - - return activities - - -def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict: - """Calculate percentage of time spent in each HR zone.""" - if not max_hr: + if not user_max_hr or user_max_hr < 100: return {} - zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0} - zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01] + zone_keys = ["z1", "z2", "z3", "z4", "z5"] + zones = {k: 0 for k in zone_keys} total = 0 for p in data_points: hr = p.get("heart_rate") - if not hr: + if not hr or hr < 20: continue - pct = hr / max_hr + pct = hr / user_max_hr total += 1 - if pct < zone_bounds[1]: - zones["z1"] += 1 - elif pct < zone_bounds[2]: - zones["z2"] += 1 - elif pct < zone_bounds[3]: - zones["z3"] += 1 - elif pct < zone_bounds[4]: - zones["z4"] += 1 + for i, key in enumerate(zone_keys): + if zone_bounds[i] <= pct < zone_bounds[i+1]: + zones[key] += 1 + break else: - zones["z5"] += 1 + zones["z5"] += 1 # anything above 90% goes to z5 if total: return {k: round(v / total * 100, 1) for k, v in zones.items()} return {} - - -def _safe_float(val) -> Optional[float]: - try: - return float(val) if val is not None else None - except (TypeError, ValueError): - return None - - -def _bounding_box(coords: list[tuple]) -> Optional[dict]: - if not coords: - return None - lats = [c[0] for c in coords] - lons = [c[1] for c in coords] - return { - "min_lat": min(lats), "max_lat": max(lats), - "min_lon": min(lons), "max_lon": max(lons), - } diff --git a/backend/app/services/wellness_parser.py b/backend/app/services/wellness_parser.py index 62be2c0..6352429 100644 --- a/backend/app/services/wellness_parser.py +++ b/backend/app/services/wellness_parser.py @@ -306,4 +306,4 @@ def parse_wellness_fit(file_path: str) -> dict: "sleep_awake_s": sleep_awake_s, } - return {"days": result, "error": None} \ No newline at end of file + return {"days": result, "error": None} diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 3ba5108..451d397 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -4,4 +4,4 @@ can be started with: celery -A app.workers.celery_app worker """ from app.workers.tasks import celery_app -__all__ = ["celery_app"] \ No newline at end of file +__all__ = ["celery_app"] diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index f8e044d..5648dd3 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str): if existing: return {"activity_id": existing.id, "status": "duplicate"} + # Get user's configured max HR for accurate zone calculation + # Falls back to: user-set value → 220-age → activity max → 190 + from app.models.user import User as UserModel + user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none() + user_max_hr = None + if user_obj: + user_max_hr = user_obj.max_heart_rate + if not user_max_hr and user_obj.birth_year: + from datetime import date as _date + age = _date.today().year - user_obj.birth_year + user_max_hr = 220 - age + if not user_max_hr: + # Last resort: use activity max but warn this may shift zones + user_max_hr = parsed.get("max_heart_rate") or 190 + hr_zones = calculate_hr_zones( parsed.get("data_points", []), - parsed.get("max_heart_rate") or 190 + user_max_hr ) start_time = datetime.fromisoformat(parsed["start_time"]) @@ -169,6 +184,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str): activity_id = activity.id compute_personal_records.delay(activity_id, user_id, parsed) + # Auto route detection for running and cycling + if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"): + detect_route.delay(activity_id, user_id) return {"activity_id": activity_id, "status": "ok"} @@ -176,161 +194,156 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str): def parse_wellness_fit(file_path: str, user_id: int): """ Parse a Garmin wellness/metrics FIT file and upsert into health_metrics. - These files contain resting HR, HRV, sleep, stress, SpO2 etc. + Uses wellness_parser which handles standard FIT + Garmin proprietary messages. """ - import fitparse + from app.services.wellness_parser import parse_wellness_fit as _parse from app.core.database import SyncSessionLocal - from app.models.user import HealthMetric + from datetime import datetime, timezone from sqlalchemy import text - from datetime import datetime, timezone, date - try: - fit = fitparse.FitFile(file_path) - except Exception as e: - return {"status": "error", "error": str(e)} + result = _parse(file_path) + if result.get("error"): + return {"status": "error", "error": result["error"], "file": file_path} - # Collect all monitoring/daily summary records keyed by date - daily = {} # date -> dict of fields - - def get_or_create_day(d: date) -> dict: - if d not in daily: - daily[d] = {} - return daily[d] - - for record in fit.get_messages(): - name = record.name - fields = {f.name: f.value for f in record if f.value is not None} - - if name == "monitoring_info": - ts = fields.get("timestamp") or fields.get("local_timestamp") - if ts: - d = ts.date() if hasattr(ts, "date") else None - if d: - day = get_or_create_day(d) - day.setdefault("resting_hr", fields.get("resting_heart_rate")) - - elif name == "monitoring": - ts = fields.get("timestamp") or fields.get("local_timestamp") - if not ts: - continue - d = ts.date() if hasattr(ts, "date") else None - if not d: - continue - day = get_or_create_day(d) - # Accumulate steps (they're stored as increments) - if "steps" in fields: - day["steps"] = day.get("steps", 0) + int(fields["steps"]) - if "heart_rate" in fields: - hrs = day.setdefault("heart_rates", []) - hrs.append(int(fields["heart_rate"])) - if "stress_level_value" in fields: - stresses = day.setdefault("stress_values", []) - stresses.append(int(fields["stress_level_value"])) - - elif name == "hrv_status_summary": - ts = fields.get("timestamp") - if ts: - d = ts.date() if hasattr(ts, "date") else None - if d: - day = get_or_create_day(d) - day.setdefault("hrv_nightly_avg", fields.get("weekly_average")) - day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high")) - day.setdefault("hrv_status", str(fields.get("hrv_status", ""))) - - elif name == "sleep_level": - ts = fields.get("timestamp") - if ts: - d = ts.date() if hasattr(ts, "date") else None - if d: - day = get_or_create_day(d) - levels = day.setdefault("sleep_levels", []) - levels.append(fields.get("sleep_level")) - - elif name == "stress": - ts = fields.get("timestamp") - if ts: - d = ts.date() if hasattr(ts, "date") else None - if d: - day = get_or_create_day(d) - if "stress_level_value" in fields: - stresses = day.setdefault("stress_values", []) - stresses.append(int(fields["stress_level_value"])) - - elif name == "spo2_data": - ts = fields.get("timestamp") - if ts: - d = ts.date() if hasattr(ts, "date") else None - if d: - day = get_or_create_day(d) - readings = day.setdefault("spo2_readings", []) - if "spo2_percent" in fields: - readings.append(fields["spo2_percent"]) - - if not daily: + days = result.get("days", {}) + if not days: return {"status": "no_data", "file": file_path} - # Upsert into health_metrics using ON CONFLICT to handle concurrent workers with SyncSessionLocal() as db: - for day_date, data in daily.items(): - hrs = data.pop("heart_rates", []) - stresses = data.pop("stress_values", []) - spo2s = data.pop("spo2_readings", []) - sleep_levels = data.pop("sleep_levels", []) - - resting_hr = data.get("resting_hr") - avg_hr = (sum(hrs) / len(hrs)) if hrs else None - avg_stress = (sum(stresses) / len(stresses)) if stresses else None - spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None - - # Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem - sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None - sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None - sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None - sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None - sleep_duration_s = ( - (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) - ) or None - + for day_date, data in days.items(): date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc) - - # ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data db.execute(text(""" - INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress, - spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps, + INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day, + avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, + steps, floors_climbed, active_calories, total_calories, sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s) - VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress, - :spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps, + VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr, + :avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status, + :steps, :floors, :active_cal, :total_cal, :sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake) ON CONFLICT (user_id, date) DO UPDATE SET - resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr), - avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day), - avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress), - spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg), - hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg), - hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high), - hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status), - steps = COALESCE(EXCLUDED.steps, health_metrics.steps), + resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr), + avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day), + max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day), + avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress), + spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg), + hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg), + hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high), + hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status), + steps = COALESCE(EXCLUDED.steps, health_metrics.steps), + floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed), + active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories), + total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories), sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s), - sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s), - sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s), - sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s), - sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s) + sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s), + sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s), + sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s), + sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s) """), { "user_id": user_id, "date": date_dt, - "resting_hr": resting_hr, "avg_hr": avg_hr, - "avg_stress": avg_stress, "spo2_avg": spo2_avg, + "resting_hr": data.get("resting_hr"), + "avg_hr": data.get("avg_hr_day"), + "max_hr": data.get("max_hr_day"), + "avg_stress": data.get("avg_stress"), + "spo2_avg": data.get("spo2_avg"), "hrv_avg": data.get("hrv_nightly_avg"), "hrv_high": data.get("hrv_5min_high"), "hrv_status": data.get("hrv_status"), "steps": data.get("steps"), - "sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s, - "sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s, - "sleep_awake": sleep_awake_s, + "floors": data.get("floors_climbed"), + "active_cal": data.get("active_calories"), + "total_cal": data.get("total_calories"), + "sleep_dur": data.get("sleep_duration_s"), + "sleep_deep": data.get("sleep_deep_s"), + "sleep_light": data.get("sleep_light_s"), + "sleep_rem": data.get("sleep_rem_s"), + "sleep_awake": data.get("sleep_awake_s"), }) - db.commit() - return {"status": "ok", "days_processed": len(daily), "file": file_path} + return {"status": "ok", "days_processed": len(days), "file": file_path} + +@celery_app.task(name="detect_route") +def detect_route(activity_id: int, user_id: int): + """ + After importing an activity, check if it matches any existing named routes. + If two+ unassigned activities match each other, auto-create a named route. + """ + from app.services.route_matcher import routes_are_similar + from app.core.database import SyncSessionLocal + from app.models.user import Activity, NamedRoute + from sqlalchemy import select + + with SyncSessionLocal() as db: + # Get the new activity + new_act = db.execute( + select(Activity).where(Activity.id == activity_id) + ).scalar_one_or_none() + if not new_act or not new_act.polyline: + return {"status": "no_polyline"} + + # Already assigned to a route? + if new_act.named_route_id: + return {"status": "already_assigned"} + + # Check against existing named routes first + routes = db.execute( + select(NamedRoute).where( + NamedRoute.user_id == user_id, + NamedRoute.sport_type == new_act.sport_type, + ) + ).scalars().all() + + for route in routes: + if route.reference_polyline and routes_are_similar( + new_act.polyline, route.reference_polyline, + new_act.bounding_box, route.bounding_box, + ): + new_act.named_route_id = route.id + db.commit() + return {"status": "matched_existing", "route_id": route.id} + + # No existing route matched - check unassigned activities for a match + candidates = db.execute( + select(Activity).where( + Activity.user_id == user_id, + Activity.sport_type == new_act.sport_type, + Activity.named_route_id == None, + Activity.id != activity_id, + Activity.polyline != None, + # Within 20% distance + Activity.distance_m >= (new_act.distance_m or 0) * 0.8, + Activity.distance_m <= (new_act.distance_m or 0) * 1.2, + ) + ).scalars().all() + + for candidate in candidates: + if routes_are_similar( + new_act.polyline, candidate.polyline, + new_act.bounding_box, candidate.bounding_box, + ): + # Auto-create a route from the older activity + older = candidate if candidate.start_time < new_act.start_time else new_act + newer = new_act if candidate.start_time < new_act.start_time else candidate + + route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}" + new_route = NamedRoute( + user_id=user_id, + name=route_name, + sport_type=older.sport_type, + reference_polyline=older.polyline, + bounding_box=older.bounding_box, + distance_m=older.distance_m, + auto_detected=True, + ) + db.add(new_route) + db.flush() + older.named_route_id = new_route.id + newer.named_route_id = new_route.id + db.commit() + return {"status": "auto_created", "route_id": new_route.id} + + return {"status": "no_match"} @celery_app.task(name="compute_personal_records") @@ -435,4 +448,4 @@ def process_garmin_health_zip(zip_path: str, user_id: int): "spo2": data.get("avgSpo2"), }) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt index 2788a83..eed09f5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,7 @@ python-multipart==0.0.9 httpx==0.27.0 redis[hiredis]==5.0.4 celery[redis]==5.4.0 +garmin-fit-sdk==21.195.0 fitparse==1.2.0 gpxpy==1.6.2 numpy==1.26.4 @@ -22,4 +23,4 @@ Pillow==10.3.0 aiofiles==23.2.1 python-dateutil==2.9.0 pytz==2024.1 -psycopg2-binary==2.9.9 \ No newline at end of file +psycopg2-binary==2.9.9 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 13722c5..7649402 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . ARG VITE_API_URL=/api diff --git a/frontend/package.json b/frontend/package.json index 025339e..60487d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "zustand": "^4.5.2", "@tanstack/react-query": "^5.40.0", "axios": "^1.7.2", - "react-dropzone": "^14.2.3" + "react-dropzone": "^14.2.3", + "@polyline-codec/core": "^2.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", @@ -29,4 +30,4 @@ "postcss": "^8.4.38", "tailwindcss": "^3.4.4" } -} \ No newline at end of file +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4b1dc07..cb1a600 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import HealthPage from './pages/HealthPage' import RoutesPage from './pages/RoutesPage' import RecordsPage from './pages/RecordsPage' import UploadPage from './pages/UploadPage' +import ProfilePage from './pages/ProfilePage' function RequireAuth({ children }) { const token = useAuthStore((s) => s.token) @@ -24,7 +25,6 @@ export default function App() { if (token) fetchUser() }, [token]) - // Handle token from PocketID callback URL useEffect(() => { const params = new URLSearchParams(window.location.search) const urlToken = params.get('token') @@ -38,14 +38,7 @@ export default function App() { return ( } /> - - - - } - > + }> } /> } /> } /> @@ -53,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index 59610dd..0fe01c5 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react' import L from 'leaflet' import { sportColor } from '../../utils/format' -// Fix Leaflet default icon issue with bundlers delete L.Icon.Default.prototype._getIconUrl L.Icon.Default.mergeOptions({ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', @@ -10,103 +9,87 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', }) +const TILE_LAYERS = { + dark: { + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + attribution: '© OSM © CARTO', + }, + street: { + url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + attribution: '© OSM © CARTO', + }, + satellite: { + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + attribution: '© Esri', + }, +} + function decodePolyline(encoded) { - // Simple polyline decoder const coords = [] let index = 0, lat = 0, lng = 0 - while (index < encoded.length) { let b, shift = 0, result = 0 - do { - b = encoded.charCodeAt(index++) - 63 - result |= (b & 0x1f) << shift - shift += 5 - } while (b >= 0x20) + do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) lat += (result & 1) ? ~(result >> 1) : result >> 1 - shift = 0; result = 0 - do { - b = encoded.charCodeAt(index++) - 63 - result |= (b & 0x1f) << shift - shift += 5 - } while (b >= 0x20) + do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) lng += (result & 1) ? ~(result >> 1) : result >> 1 - coords.push([lat / 1e5, lng / 1e5]) } return coords } -export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) { +export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) const trackRef = useRef(null) + const tileLayerRef = useRef(null) useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return - - mapInstanceRef.current = L.map(mapRef.current, { - zoomControl: true, - attributionControl: true, - }) - - // Use CartoDB dark tiles (no API key needed) - L.tileLayer( - 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - { - attribution: '© OSM © CARTO', - maxZoom: 19, - } - ).addTo(mapInstanceRef.current) - - return () => { - mapInstanceRef.current?.remove() - mapInstanceRef.current = null - } + mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true }) + const tile = TILE_LAYERS['dark'] + tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) + .addTo(mapInstanceRef.current) + return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null } }, []) - // Draw route when polyline changes + // Switch tile layer when mapType changes + useEffect(() => { + if (!mapInstanceRef.current) return + const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark + if (tileLayerRef.current) { + tileLayerRef.current.remove() + } + tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) + .addTo(mapInstanceRef.current) + }, [mapType]) + + // Draw route useEffect(() => { if (!mapInstanceRef.current || !polyline) return - - if (trackRef.current) { - trackRef.current.remove() - } - + if (trackRef.current) trackRef.current.remove() const coords = decodePolyline(polyline) if (!coords.length) return - - trackRef.current = L.polyline(coords, { - color: sportColor(sportType), - weight: 3, - opacity: 0.9, - }).addTo(mapInstanceRef.current) - + trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }) + .addTo(mapInstanceRef.current) mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) - - // Start/end markers if (coords.length > 0) { - const startIcon = L.divIcon({ - html: '
', + const dot = (color) => L.divIcon({ + html: `
`, iconSize: [12, 12], iconAnchor: [6, 6], className: '', }) - const endIcon = L.divIcon({ - html: '
', - iconSize: [12, 12], iconAnchor: [6, 6], className: '', - }) - L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current) - L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current) + L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current) + L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current) } }, [polyline, sportType]) - // Move position marker when timeline is hovered + // Position marker on timeline hover useEffect(() => { if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return - const point = dataPoints.find(p => p.distance_m >= hoveredDistance) if (!point?.latitude || !point?.longitude) return - if (markerRef.current) { markerRef.current.setLatLng([point.latitude, point.longitude]) } else { @@ -114,8 +97,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo html: '
', iconSize: [14, 14], iconAnchor: [7, 7], className: '', }) - markerRef.current = L.marker([point.latitude, point.longitude], { icon }) - .addTo(mapInstanceRef.current) + markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current) } }, [hoveredDistance, dataPoints]) diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx index 5f70ac3..bdc0df6 100644 --- a/frontend/src/components/activity/LapTable.jsx +++ b/frontend/src/components/activity/LapTable.jsx @@ -1,4 +1,4 @@ -import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format' +import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format' export default function LapTable({ laps, sportType }) { return ( @@ -26,7 +26,7 @@ export default function LapTable({ laps, sportType }) { {formatHeartRate(lap.avg_heart_rate)} - {lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'} + {lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'} {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx index 268dbae..6b2b35d 100644 --- a/frontend/src/components/activity/MetricTimeline.jsx +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -1,9 +1,9 @@ -import { useMemo, useCallback } from 'react' +import { useMemo } from 'react' import { ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, - ResponsiveContainer, ReferenceLine, + ResponsiveContainer, } from 'recharts' -import { formatDuration, formatPace } from '../../utils/format' +import { formatPace, formatCadence } from '../../utils/format' function downsample(points, maxPoints = 500) { if (points.length <= maxPoints) return points @@ -17,7 +17,7 @@ function buildChartData(dataPoints, activeMetrics) { .map(p => { const row = { distance_m: p.distance_m ?? 0 } for (const key of activeMetrics) { - row[key] = p[key] ?? null + row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null } return row }) @@ -25,9 +25,7 @@ function buildChartData(dataPoints, activeMetrics) { const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { if (!active || !payload?.length) return null - if (onHover) onHover(label) - return (

{(label / 1000).toFixed(2)} km

@@ -37,7 +35,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) let display = entry.value.toFixed(1) if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType) else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm` - else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm` + else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType) else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W` else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C` else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m` @@ -61,7 +59,6 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) - // Build per-metric Y-axis domains const domains = useMemo(() => { const result = {} for (const m of activeMetricConfigs) { @@ -70,6 +67,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH const min = Math.min(...vals) const max = Math.max(...vals) const pad = (max - min) * 0.1 || 1 + // For elevation, don't start from 0 - show actual range result[m.key] = [min - pad, max + pad] } return result @@ -87,18 +85,14 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
{activeMetricConfigs.map((metric, idx) => { const domain = domains[metric.key] || ['auto', 'auto'] - const data = chartData.filter(p => p[metric.key] != null) - if (!data.length) return null + const hasData = chartData.some(p => p[metric.key] != null) + if (!hasData) return null return (
- - {metric.label} - - {metric.unit && ( - ({metric.unit}) - )} + {metric.label} + {metric.unit && ({metric.unit})}
@@ -118,20 +112,19 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} - width={36} + width={40} tickFormatter={v => { - if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}` + if (metric.key === 'speed_ms') { + if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}` + const spm = 1000 / v + return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}` + } + if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1)) return Math.round(v) }} /> - } + content={} isAnimationActive={false} /> ) })} - - {/* Shared distance axis label */}

Distance (km)

) diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index 71b9603..881172f 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -8,6 +8,7 @@ const nav = [ { to: '/routes', label: 'Routes', icon: '🗺️' }, { to: '/records', label: 'Records', icon: '🏆' }, { to: '/upload', label: 'Import', icon: '⬆️' }, + { to: '/profile', label: 'Profile', icon: '⚙️' }, ] export default function Layout() { @@ -21,51 +22,38 @@ export default function Layout() { return (
- {/* Sidebar */} - {/* Main content */}
diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index e173311..67efcf0 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -7,7 +7,7 @@ import { formatDate, sportIcon, sportColor, } from '../utils/format' -const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking'] +const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking'] export default function ActivitiesPage() { const [sport, setSport] = useState('all') diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 943117b..e8c92ea 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -9,14 +9,14 @@ import LapTable from '../components/activity/LapTable' import StatCard from '../components/ui/StatCard' import { formatDuration, formatDistance, formatPace, formatElevation, - formatHeartRate, formatDateTime, sportIcon, + formatHeartRate, formatDateTime, formatCadence, sportIcon, } from '../utils/format' const METRICS = [ { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, - { key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' }, + { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' }, { key: 'power', label: 'Power', unit: 'W', color: '#a855f7' }, { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' }, ] @@ -25,6 +25,8 @@ export default function ActivityDetailPage() { const { id } = useParams() const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [hoveredDistance, setHoveredDistance] = useState(null) + const [mapHeight, setMapHeight] = useState(420) + const [mapType, setMapType] = useState('dark') const { data: activity, isLoading } = useQuery({ queryKey: ['activity', id], @@ -49,19 +51,21 @@ export default function ActivityDetailPage() { ) } - if (isLoading) { - return ( -
-
Loading activity…
-
+ // Check which metrics have actual data + const availableMetrics = useMemo(() => { + if (!dataPoints?.length) return new Set() + return new Set( + METRICS + .filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0)) + .map(m => m.key) ) + }, [dataPoints]) + + if (isLoading) { + return
Loading activity…
} - if (!activity) return null - const speed = activity.avg_speed_ms - const pace = formatPace(speed, activity.sport_type) - return (
{/* Header */} @@ -75,12 +79,12 @@ export default function ActivityDetailPage() {
- {/* Summary stats */} + {/* Primary stats */}
- - + +
@@ -88,37 +92,71 @@ export default function ActivityDetailPage() { {/* Secondary stats */}
- + + -
- {/* Map */} -
- + {/* Map with controls */} +
+ {/* Map toolbar */} +
+
+ Map style: + {['dark', 'street', 'satellite'].map(t => ( + + ))} +
+
+ Height: + {[280, 420, 560].map(h => ( + + ))} +
+
+
+ +
{/* HR Zones */} - {activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && ( + {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (

Heart Rate Zones

)} - {/* Metric selector */} + {/* Metric timeline */}

Activity Timeline

- {METRICS.map(({ key, label, color }) => ( + {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
- - {dataPoints && ( + {dataPoints && dataPoints.length > 0 ? ( availableMetrics.has(m))} metrics={METRICS} onHoverDistance={setHoveredDistance} sportType={activity.sport_type} /> + ) : ( +

No timeline data available for this activity

)}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index e5aeecb..8d302ed 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' -import { format, subDays, startOfWeek } from 'date-fns' +import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns' import api from '../utils/api' import StatCard from '../components/ui/StatCard' import { @@ -10,18 +10,29 @@ import { } from '../utils/format' function WeeklyChart({ activities }) { - if (!activities?.length) return null + if (!activities?.length) return ( +
No activities yet
+ ) - // Build last 8 weeks of distance data - const weeks = {} - activities.forEach(a => { - const week = format(startOfWeek(new Date(a.start_time)), 'MMM d') - if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 } - weeks[week].km += (a.distance_m || 0) / 1000 - weeks[week].runs++ + // Build last 8 weeks in chronological order + const now = new Date() + const weeks = eachWeekOfInterval({ + start: subWeeks(startOfWeek(now), 7), + end: startOfWeek(now), }) - const data = Object.values(weeks).slice(-8) + const data = weeks.map(weekStart => { + const weekKey = format(weekStart, 'MMM d') + const weekEnd = new Date(weekStart) + weekEnd.setDate(weekEnd.getDate() + 7) + const km = activities + .filter(a => { + const t = new Date(a.start_time) + return t >= weekStart && t < weekEnd + }) + .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0) + return { week: weekKey, km: parseFloat(km.toFixed(2)) } + }) return ( @@ -30,10 +41,8 @@ function WeeklyChart({ activities }) { `${v.toFixed(0)}`} /> - [`${v.toFixed(1)} km`, 'Distance']} - /> + [`${v.toFixed(1)} km`, 'Distance']} /> @@ -50,10 +59,7 @@ export default function DashboardPage() { queryKey: ['activities-all-chart'], queryFn: () => api.get('/activities/', { - params: { - per_page: 100, - from_date: subDays(new Date(), 60).toISOString(), - }, + params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() }, }).then(r => r.data), }) @@ -68,64 +74,45 @@ export default function DashboardPage() { }) const latest = healthSummary?.latest - const totalActivities = recentActivities?.length ?? 0 const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0 return (

Dashboard

- - + Import data - + + Import data
- {/* Top stats */}
- - + +
- {/* Weekly distance chart */}

Weekly distance (km)

- {/* Health snapshot */}

Health today

{latest ? ( <> -
- HRV - {latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'} -
-
- Sleep score - {latest.sleep_score ? Math.round(latest.sleep_score) : '--'} -
-
- Steps - {latest.steps?.toLocaleString() ?? '--'} -
-
- VO2 Max - {latest.vo2max ? latest.vo2max.toFixed(1) : '--'} -
-
- Stress - {latest.avg_stress ? Math.round(latest.avg_stress) : '--'} -
- - View full health dashboard → - + {[ + ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], + ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], + ['Steps', latest.steps?.toLocaleString() ?? '--'], + ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], + ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} + View full health dashboard → ) : (

No health data. Import a Garmin export.

@@ -141,29 +128,17 @@ export default function DashboardPage() {
{recentActivities?.slice(0, 5).map(activity => ( - + {sportIcon(activity.sport_type)}

{activity.name}

{formatDate(activity.start_time)}

-
-

{formatDistance(activity.distance_m)}

-

dist

-
-
-

{formatDuration(activity.duration_s)}

-

time

-
-
-

{formatHeartRate(activity.avg_heart_rate)}

-

HR

-
+

{formatDistance(activity.distance_m)}

dist

+

{formatDuration(activity.duration_s)}

time

+

{formatHeartRate(activity.avg_heart_rate)}

HR

))} @@ -175,7 +150,6 @@ export default function DashboardPage() {
- {/* PRs snapshot */} {records?.length > 0 && (
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 3c18e53..6d91413 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { LineChart, Line, AreaChart, Area, BarChart, Bar, @@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard' import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' const RANGES = [ + { label: '1W', days: 7 }, { label: '2W', days: 14 }, { label: '1M', days: 30 }, { label: '3M', days: 90 }, @@ -17,7 +18,13 @@ const RANGES = [ { label: '1Y', days: 365 }, ] +const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 } + function MetricChart({ data, dataKey, color, formatter, height = 140 }) { + const vals = data.filter(d => d[dataKey] != null) + if (!vals.length) return ( +
No data
+ ) return ( @@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) { - format(new Date(d), 'MMM d')} - interval="preserveStartEnd" - /> - - format(new Date(d), 'MMM d, yyyy')} - formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} - /> - + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} /> + ) @@ -71,7 +56,8 @@ function SleepChart({ data }) { light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null, awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null, })) - + const hasData = chartData.some(d => d.deep || d.rem || d.light) + if (!hasData) return
No sleep data
return ( @@ -80,9 +66,8 @@ function SleepChart({ data }) { tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> `${v}h`} /> - format(new Date(d), 'MMM d, yyyy')} /> - + format(new Date(d), 'MMM d, yyyy')} /> + @@ -92,21 +77,22 @@ function SleepChart({ data }) { } export default function HealthPage() { - const [rangeDays, setRangeDays] = useState(30) + const [rangeDays, setRangeDays] = useState(7) // default 1 week - const fromDate = subDays(new Date(), rangeDays).toISOString() + const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays]) const { data: summary } = useQuery({ queryKey: ['health-summary'], queryFn: () => api.get('/health-metrics/summary').then(r => r.data), }) - const { data: metrics } = useQuery({ + const { data: metrics, isLoading } = useQuery({ queryKey: ['health-metrics', rangeDays], queryFn: () => api.get('/health-metrics/', { - params: { from_date: fromDate, limit: rangeDays }, - }).then(r => r.data.reverse()), + params: { from_date: fromDate, limit: rangeDays + 1 }, + }).then(r => r.data.slice().reverse()), // oldest first for charts + keepPreviousData: true, }) const latest = summary?.latest @@ -118,132 +104,75 @@ export default function HealthPage() { {/* Summary cards */}
- - - - - - - - + + + + + + + +
{/* Range selector */}
{RANGES.map(({ label, days }) => ( - ))}
- {metrics && metrics.length > 0 ? ( + {isLoading ? ( +
Loading…
+ ) : metrics && metrics.length > 0 ? (
- {/* Resting HR */}

Resting Heart Rate

- `${Math.round(v)} bpm`} - /> + `${Math.round(v)} bpm`} />
- {/* HRV */}

HRV (nightly avg)

- `${Math.round(v)} ms`} - /> + `${Math.round(v)} ms`} />
- {/* Sleep */}

Sleep Stages

- {[ - { label: 'Deep', color: '#6366f1' }, - { label: 'REM', color: '#8b5cf6' }, - { label: 'Light', color: '#a78bfa' }, - { label: 'Awake', color: '#374151' }, - ].map(({ label, color }) => ( -
-
- {label} + {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => ( +
+
+ {l}
))}
- {/* Weight */}

Weight

- `${v.toFixed(1)} kg`} - /> + `${v.toFixed(1)} kg`} />
- {/* VO2 Max */}

VO2 Max

- v.toFixed(1)} - /> + v.toFixed(1)} />
- {/* Steps */}

Daily Steps

@@ -253,18 +182,30 @@ export default function HealthPage() { tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> - format(new Date(d), 'MMM d, yyyy')} /> + format(new Date(d), 'MMM d, yyyy')} />
+ +
+

Avg Heart Rate (day)

+ `${Math.round(v)} bpm`} /> +
+ +
+

Stress Level

+ Math.round(v)} /> +
+
) : (

📊

-

No health data yet

-

Import a Garmin export to see your health trends

+

No health data for this period

+

Import a Garmin export or try a longer date range

)}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx new file mode 100644 index 0000000..0b168b1 --- /dev/null +++ b/frontend/src/pages/ProfilePage.jsx @@ -0,0 +1,266 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../utils/api' +import { useAuthStore } from '../hooks/useAuth' + +function Section({ title, children }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function Field({ label, hint, children }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function Input({ type = 'text', value, onChange, placeholder, min, max }) { + return ( + + ) +} + +function SaveButton({ onClick, loading, saved, label = 'Save' }) { + return ( +
+ + {saved && ✓ Saved} +
+ ) +} + +export default function ProfilePage() { + const qc = useQueryClient() + const { user } = useAuthStore() + + const { data: profile } = useQuery({ + queryKey: ['profile'], + queryFn: () => api.get('/profile/').then(r => r.data), + }) + + const { data: pocketidConfig } = useQuery({ + queryKey: ['pocketid-config'], + queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), + enabled: !!user?.is_admin, + }) + + // HR / measurements form + const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' }) + const [hrSaved, setHrSaved] = useState(false) + useEffect(() => { + if (profile) setHrForm({ + max_heart_rate: profile.max_heart_rate || '', + resting_heart_rate: profile.resting_heart_rate || '', + birth_year: profile.birth_year || '', + height_cm: profile.height_cm || '', + }) + }, [profile]) + + const updateProfile = useMutation({ + mutationFn: data => api.patch('/profile/', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) }, + }) + + // Weight log + const { data: weightLog } = useQuery({ + queryKey: ['weight-log'], + queryFn: () => api.get('/profile/weight').then(r => r.data), + }) + const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) }) + const [weightSaved, setWeightSaved] = useState(false) + const addWeight = useMutation({ + mutationFn: data => api.post('/profile/weight', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) }, + }) + const deleteWeight = useMutation({ + mutationFn: id => api.delete(`/profile/weight/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }), + }) + + // Password change + const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' }) + const [pwError, setPwError] = useState('') + const [pwSaved, setPwSaved] = useState(false) + const changePassword = useMutation({ + mutationFn: data => api.post('/profile/change-password', data).then(r => r.data), + onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) }, + onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'), + }) + + // PocketID config + const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) + const [pidSaved, setPidSaved] = useState(false) + useEffect(() => { + if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' }) + }, [pocketidConfig]) + const savePocketID = useMutation({ + mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) }, + }) + + const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr + + return ( +
+

Profile & Settings

+ + {/* HR & Measurements */} +
+
+ Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test. + {effectiveMaxHr && ( +
+ Effective max HR: {effectiveMaxHr} bpm + {!profile?.max_heart_rate && ' (estimated from age)'} + {' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)} +
+ )} +
+ +
+ + setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> + + + setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} /> + + + setHrForm(f => ({ ...f, birth_year: e.target.value }))} /> + + + setHrForm(f => ({ ...f, height_cm: e.target.value }))} /> + +
+ + updateProfile.mutate(Object.fromEntries( + Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) + ))} + loading={updateProfile.isPending} + saved={hrSaved} + /> +
+ + {/* Weight log */} +
+
+ + setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} /> + + + setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} /> + + + setWeightForm(f => ({ ...f, date: e.target.value }))} /> + +
+ addWeight.mutate({ + weight_kg: parseFloat(weightForm.weight_kg), + body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null, + date: new Date(weightForm.date).toISOString(), + })} + loading={addWeight.isPending} + saved={weightSaved} + label="Log weight" + /> + + {weightLog && weightLog.length > 0 && ( +
+

Recent entries

+
+ {weightLog.slice(0, 20).map(entry => ( +
+ {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + {entry.weight_kg.toFixed(1)} kg + {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat} + +
+ ))} +
+
+ )} +
+ + {/* Password change */} +
+
+ + { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} /> + + + setPwForm(f => ({ ...f, new_password: e.target.value }))} /> + + + setPwForm(f => ({ ...f, confirm: e.target.value }))} /> + + {pwError &&

{pwError}

} +
+ { + if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return } + changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password }) + }} + loading={changePassword.isPending} + saved={pwSaved} + label="Change password" + /> +
+ + {/* PocketID — admin only */} + {user?.is_admin && ( +
+

+ Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page. +

+
+ + setPidForm(f => ({ ...f, issuer: e.target.value }))} /> + + + setPidForm(f => ({ ...f, client_id: e.target.value }))} /> + + + setPidForm(f => ({ ...f, client_secret: e.target.value }))} /> + + {pocketidConfig?.enabled && ( +

✓ PocketID is currently active

+ )} +
+ savePocketID.mutate(pidForm)} + loading={savePocketID.isPending} + saved={pidSaved} + label="Save PocketID config" + /> +
+ )} +
+ ) +} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index 7975abc..75984e1 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' -import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format' +import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' export default function RoutesPage() { const [selected, setSelected] = useState(null) @@ -20,18 +20,19 @@ export default function RoutesPage() { enabled: !!selected, }) - const { data: segments } = useQuery({ - queryKey: ['route-segments', selected?.id], - queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data), - enabled: !!selected, + const { data: recentActivities } = useQuery({ + queryKey: ['recent-activities-for-route'], + queryFn: () => api.get('/routes/recent-activities').then(r => r.data), + enabled: showCreate, }) const createRoute = useMutation({ mutationFn: (data) => api.post('/routes/', data).then(r => r.data), - onSuccess: () => { + onSuccess: (route) => { qc.invalidateQueries({ queryKey: ['routes'] }) setShowCreate(false) setNewRoute({ name: '', activity_id: '' }) + setSelected(route) }, }) @@ -40,55 +41,62 @@ export default function RoutesPage() { return (
-

Named Routes

-
- {/* Create route modal */} + {/* Create route */} {showCreate && (

Create named route

- Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically. + Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.

-
+
- setNewRoute(r => ({ ...r, name: e.target.value }))} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="e.g. Morning park loop" - /> + placeholder="e.g. Morning park loop" />
- - setNewRoute(r => ({ ...r, activity_id: e.target.value }))} - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Activity ID" - /> + + {recentActivities?.length === 0 ? ( +

No recent activities found.

+ ) : ( + + )}
-
@@ -98,23 +106,24 @@ export default function RoutesPage() {
{/* Route list */}
- {routes?.length === 0 && ( + {routes?.length === 0 && !showCreate && (

🗺️

No named routes yet

+

Routes are created automatically when you repeat a run, or create one manually above.

)} {routes?.map(route => ( - +
+ + +
+ +
+
+ ) +} diff --git a/milevault_export/frontend/src/components/ui/StatCard.jsx b/milevault_export/frontend/src/components/ui/StatCard.jsx new file mode 100644 index 0000000..927dfd5 --- /dev/null +++ b/milevault_export/frontend/src/components/ui/StatCard.jsx @@ -0,0 +1,18 @@ +const accentColors = { + default: 'text-white', + red: 'text-red-400', + blue: 'text-blue-400', + green: 'text-green-400', + orange: 'text-orange-400', + purple: 'text-purple-400', +} + +export default function StatCard({ label, value, accent = 'default', sub }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ) +} diff --git a/milevault_export/frontend/src/hooks/useAuth.js b/milevault_export/frontend/src/hooks/useAuth.js new file mode 100644 index 0000000..2a01804 --- /dev/null +++ b/milevault_export/frontend/src/hooks/useAuth.js @@ -0,0 +1,41 @@ +import { create } from 'zustand' +import api from '../utils/api' + +export const useAuthStore = create((set) => ({ + token: localStorage.getItem('token'), + user: null, + isLoading: false, + + login: async (username, password) => { + set({ isLoading: true }) + try { + const params = new URLSearchParams() + params.append('username', username) + params.append('password', password) + const { data } = await api.post('/auth/token', params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + localStorage.setItem('token', data.access_token) + set({ token: data.access_token, user: data, isLoading: false }) + return true + } catch (e) { + set({ isLoading: false }) + throw e + } + }, + + logout: () => { + localStorage.removeItem('token') + set({ token: null, user: null }) + }, + + fetchUser: async () => { + try { + const { data } = await api.get('/auth/me') + set({ user: data }) + } catch { + set({ token: null, user: null }) + localStorage.removeItem('token') + } + }, +})) diff --git a/milevault_export/frontend/src/index.css b/milevault_export/frontend/src/index.css new file mode 100644 index 0000000..61398fa --- /dev/null +++ b/milevault_export/frontend/src/index.css @@ -0,0 +1,33 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-950 text-gray-100 antialiased; + font-family: system-ui, -apple-system, sans-serif; + } +} + +/* Leaflet dark mode fixes */ +.leaflet-container { + background: #1a1a2e; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } + +/* HR zone colours */ +.zone-1 { color: #60a5fa; } +.zone-2 { color: #34d399; } +.zone-3 { color: #fbbf24; } +.zone-4 { color: #f97316; } +.zone-5 { color: #f43f5e; } + +.zone-bg-1 { background-color: #1e3a5f; } +.zone-bg-2 { background-color: #065f46; } +.zone-bg-3 { background-color: #78350f; } +.zone-bg-4 { background-color: #7c2d12; } +.zone-bg-5 { background-color: #881337; } diff --git a/milevault_export/frontend/src/main.jsx b/milevault_export/frontend/src/main.jsx new file mode 100644 index 0000000..2f6b453 --- /dev/null +++ b/milevault_export/frontend/src/main.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 60_000, retry: 1 }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + +) diff --git a/milevault_export/frontend/src/pages/ActivitiesPage.jsx b/milevault_export/frontend/src/pages/ActivitiesPage.jsx new file mode 100644 index 0000000..67efcf0 --- /dev/null +++ b/milevault_export/frontend/src/pages/ActivitiesPage.jsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import api from '../utils/api' +import { + formatDuration, formatDistance, formatPace, formatHeartRate, + formatDate, sportIcon, sportColor, +} from '../utils/format' + +const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking'] + +export default function ActivitiesPage() { + const [sport, setSport] = useState('all') + const [page, setPage] = useState(1) + + const { data: activities, isLoading } = useQuery({ + queryKey: ['activities', sport, page], + queryFn: () => + api.get('/activities/', { + params: { + sport_type: sport === 'all' ? undefined : sport, + page, + per_page: 20, + }, + }).then(r => r.data), + }) + + return ( +
+
+

Activities

+ + + Import + +
+ + {/* Sport filter */} +
+ {SPORTS.map(s => ( + + ))} +
+ + {/* Activity list */} + {isLoading ? ( +
Loading…
+ ) : ( +
+ {activities?.map(activity => ( + + {/* Sport indicator */} +
+ {sportIcon(activity.sport_type)} +
+ + {/* Name + date */} +
+

+ {activity.name} +

+

{formatDate(activity.start_time)}

+
+ + {/* Metrics */} +
+
+

{formatDistance(activity.distance_m)}

+

distance

+
+
+

{formatDuration(activity.duration_s)}

+

time

+
+
+

{formatPace(activity.avg_speed_ms, activity.sport_type)}

+

pace

+
+
+

{formatHeartRate(activity.avg_heart_rate)}

+

avg HR

+
+
+

+ {activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'} +

+

elev

+
+
+ + + + ))} + + {activities?.length === 0 && ( +
+

🏃

+

No activities yet

+

+ Import your Garmin or Strava data to get started +

+
+ )} +
+ )} + + {/* Pagination */} + {activities?.length === 20 && ( +
+ + Page {page} + +
+ )} +
+ ) +} diff --git a/milevault_export/frontend/src/pages/ActivityDetailPage.jsx b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx new file mode 100644 index 0000000..e8c92ea --- /dev/null +++ b/milevault_export/frontend/src/pages/ActivityDetailPage.jsx @@ -0,0 +1,197 @@ +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { useState, useMemo } from 'react' +import api from '../utils/api' +import ActivityMap from '../components/activity/ActivityMap' +import MetricTimeline from '../components/activity/MetricTimeline' +import HRZoneBar from '../components/activity/HRZoneBar' +import LapTable from '../components/activity/LapTable' +import StatCard from '../components/ui/StatCard' +import { + formatDuration, formatDistance, formatPace, formatElevation, + formatHeartRate, formatDateTime, formatCadence, sportIcon, +} from '../utils/format' + +const METRICS = [ + { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, + { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, + { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, + { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' }, + { key: 'power', label: 'Power', unit: 'W', color: '#a855f7' }, + { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' }, +] + +export default function ActivityDetailPage() { + const { id } = useParams() + const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) + const [hoveredDistance, setHoveredDistance] = useState(null) + const [mapHeight, setMapHeight] = useState(420) + const [mapType, setMapType] = useState('dark') + + const { data: activity, isLoading } = useQuery({ + queryKey: ['activity', id], + queryFn: () => api.get(`/activities/${id}`).then(r => r.data), + }) + + const { data: dataPoints } = useQuery({ + queryKey: ['activity-points', id], + queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data), + enabled: !!activity, + }) + + const { data: laps } = useQuery({ + queryKey: ['activity-laps', id], + queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data), + enabled: !!activity, + }) + + const toggleMetric = (key) => { + setActiveMetrics(prev => + prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] + ) + } + + // Check which metrics have actual data + const availableMetrics = useMemo(() => { + if (!dataPoints?.length) return new Set() + return new Set( + METRICS + .filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0)) + .map(m => m.key) + ) + }, [dataPoints]) + + if (isLoading) { + return
Loading activity…
+ } + if (!activity) return null + + return ( +
+ {/* Header */} +
+
+
+ {sportIcon(activity.sport_type)} +

{activity.name}

+
+

{formatDateTime(activity.start_time)}

+
+
+ + {/* Primary stats */} +
+ + + + + + +
+ + {/* Secondary stats */} +
+ + + + + + +
+ + {/* Map with controls */} +
+ {/* Map toolbar */} +
+
+ Map style: + {['dark', 'street', 'satellite'].map(t => ( + + ))} +
+
+ Height: + {[280, 420, 560].map(h => ( + + ))} +
+
+
+ +
+
+ + {/* HR Zones */} + {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && ( +
+

Heart Rate Zones

+ +
+ )} + + {/* Metric timeline */} +
+
+

Activity Timeline

+
+ {METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => ( + + ))} +
+
+ {dataPoints && dataPoints.length > 0 ? ( + availableMetrics.has(m))} + metrics={METRICS} + onHoverDistance={setHoveredDistance} + sportType={activity.sport_type} + /> + ) : ( +

No timeline data available for this activity

+ )} +
+ + {/* Laps */} + {laps && laps.length > 0 && ( +
+

Laps

+ +
+ )} +
+ ) +} diff --git a/milevault_export/frontend/src/pages/DashboardPage.jsx b/milevault_export/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..8d302ed --- /dev/null +++ b/milevault_export/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,171 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns' +import api from '../utils/api' +import StatCard from '../components/ui/StatCard' +import { + formatDuration, formatDistance, formatPace, formatHeartRate, + formatDate, sportIcon, formatSleep, +} from '../utils/format' + +function WeeklyChart({ activities }) { + if (!activities?.length) return ( +
No activities yet
+ ) + + // Build last 8 weeks in chronological order + const now = new Date() + const weeks = eachWeekOfInterval({ + start: subWeeks(startOfWeek(now), 7), + end: startOfWeek(now), + }) + + const data = weeks.map(weekStart => { + const weekKey = format(weekStart, 'MMM d') + const weekEnd = new Date(weekStart) + weekEnd.setDate(weekEnd.getDate() + 7) + const km = activities + .filter(a => { + const t = new Date(a.start_time) + return t >= weekStart && t < weekEnd + }) + .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0) + return { week: weekKey, km: parseFloat(km.toFixed(2)) } + }) + + return ( + + + + + `${v.toFixed(0)}`} /> + [`${v.toFixed(1)} km`, 'Distance']} /> + + + + ) +} + +export default function DashboardPage() { + const { data: recentActivities } = useQuery({ + queryKey: ['activities-recent'], + queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data), + }) + + const { data: allActivities } = useQuery({ + queryKey: ['activities-all-chart'], + queryFn: () => + api.get('/activities/', { + params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() }, + }).then(r => r.data), + }) + + const { data: healthSummary } = useQuery({ + queryKey: ['health-summary'], + queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + }) + + const { data: records } = useQuery({ + queryKey: ['records-running'], + queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data), + }) + + const latest = healthSummary?.latest + const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0 + + return ( +
+
+

Dashboard

+ + Import data +
+ +
+ + + + +
+ +
+
+

Weekly distance (km)

+ +
+ +
+

Health today

+ {latest ? ( + <> + {[ + ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], + ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], + ['Steps', latest.steps?.toLocaleString() ?? '--'], + ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], + ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} + View full health dashboard → + + ) : ( +

No health data. Import a Garmin export.

+ )} +
+
+ + {/* Recent activities */} +
+
+

Recent activities

+ View all → +
+
+ {recentActivities?.slice(0, 5).map(activity => ( + + {sportIcon(activity.sport_type)} +
+

{activity.name}

+

{formatDate(activity.start_time)}

+
+
+

{formatDistance(activity.distance_m)}

dist

+

{formatDuration(activity.duration_s)}

time

+

{formatHeartRate(activity.avg_heart_rate)}

HR

+
+ + ))} + {!recentActivities?.length && ( +

+ No activities yet — import some data +

+ )} +
+
+ + {records?.length > 0 && ( +
+
+

Running PRs

+ View all → +
+
+ {records.slice(0, 5).map(rec => ( +
+

{rec.distance_label}

+

{formatDuration(rec.duration_s)}

+
+ ))} +
+
+ )} +
+ ) +} diff --git a/milevault_export/frontend/src/pages/HealthPage.jsx b/milevault_export/frontend/src/pages/HealthPage.jsx new file mode 100644 index 0000000..6d91413 --- /dev/null +++ b/milevault_export/frontend/src/pages/HealthPage.jsx @@ -0,0 +1,213 @@ +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + LineChart, Line, AreaChart, Area, BarChart, Bar, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { format, subDays } from 'date-fns' +import api from '../utils/api' +import StatCard from '../components/ui/StatCard' +import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' + +const RANGES = [ + { label: '1W', days: 7 }, + { label: '2W', days: 14 }, + { label: '1M', days: 30 }, + { label: '3M', days: 90 }, + { label: '6M', days: 180 }, + { label: '1Y', days: 365 }, +] + +const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 } + +function MetricChart({ data, dataKey, color, formatter, height = 140 }) { + const vals = data.filter(d => d[dataKey] != null) + if (!vals.length) return ( +
No data
+ ) + return ( + + + + + + + + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} /> + + + + ) +} + +function SleepChart({ data }) { + const chartData = data.map(d => ({ + date: d.date, + deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, + rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null, + light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null, + awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null, + })) + const hasData = chartData.some(d => d.deep || d.rem || d.light) + if (!hasData) return
No sleep data
+ return ( + + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + `${v}h`} /> + format(new Date(d), 'MMM d, yyyy')} /> + + + + + + + ) +} + +export default function HealthPage() { + const [rangeDays, setRangeDays] = useState(7) // default 1 week + + const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays]) + + const { data: summary } = useQuery({ + queryKey: ['health-summary'], + queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + }) + + const { data: metrics, isLoading } = useQuery({ + queryKey: ['health-metrics', rangeDays], + queryFn: () => + api.get('/health-metrics/', { + params: { from_date: fromDate, limit: rangeDays + 1 }, + }).then(r => r.data.slice().reverse()), // oldest first for charts + keepPreviousData: true, + }) + + const latest = summary?.latest + const avg30 = summary?.avg_30d + + return ( +
+

Health

+ + {/* Summary cards */} +
+ + + + + + + + +
+ + {/* Range selector */} +
+ {RANGES.map(({ label, days }) => ( + + ))} +
+ + {isLoading ? ( +
Loading…
+ ) : metrics && metrics.length > 0 ? ( +
+ +
+

Resting Heart Rate

+ `${Math.round(v)} bpm`} /> +
+ +
+

HRV (nightly avg)

+ `${Math.round(v)} ms`} /> +
+ +
+

Sleep Stages

+ +
+ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => ( +
+
+ {l} +
+ ))} +
+
+ +
+

Weight

+ `${v.toFixed(1)} kg`} /> +
+ +
+

VO2 Max

+ v.toFixed(1)} /> +
+ +
+

Daily Steps

+ + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> + format(new Date(d), 'MMM d, yyyy')} /> + + + +
+ +
+

Avg Heart Rate (day)

+ `${Math.round(v)} bpm`} /> +
+ +
+

Stress Level

+ Math.round(v)} /> +
+ +
+ ) : ( +
+

📊

+

No health data for this period

+

Import a Garmin export or try a longer date range

+
+ )} +
+ ) +} diff --git a/milevault_export/frontend/src/pages/LoginPage.jsx b/milevault_export/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..b566e7c --- /dev/null +++ b/milevault_export/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '../hooks/useAuth' +import { useQuery } from '@tanstack/react-query' +import api from '../utils/api' + +export default function LoginPage() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const { login, isLoading } = useAuthStore() + const navigate = useNavigate() + + const { data: pocketidData } = useQuery({ + queryKey: ['pocketid-available'], + queryFn: () => api.get('/auth/pocketid/available').then(r => r.data), + }) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + try { + await login(username, password) + navigate('/') + } catch (err) { + setError(err.response?.data?.detail || 'Login failed') + } + } + + const handlePocketID = async () => { + const { data } = await api.get('/auth/pocketid/login-url') + window.location.href = data.url + } + + return ( +
+
+
+

+ MileVault +

+

Your personal fitness dashboard

+
+ +
+
+
+ + setUsername(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + autoComplete="current-password" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+ + {pocketidData?.available && ( + <> +
+
+ or +
+
+ + + )} +
+
+
+ ) +} diff --git a/milevault_export/frontend/src/pages/ProfilePage.jsx b/milevault_export/frontend/src/pages/ProfilePage.jsx new file mode 100644 index 0000000..0b168b1 --- /dev/null +++ b/milevault_export/frontend/src/pages/ProfilePage.jsx @@ -0,0 +1,266 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../utils/api' +import { useAuthStore } from '../hooks/useAuth' + +function Section({ title, children }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function Field({ label, hint, children }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function Input({ type = 'text', value, onChange, placeholder, min, max }) { + return ( + + ) +} + +function SaveButton({ onClick, loading, saved, label = 'Save' }) { + return ( +
+ + {saved && ✓ Saved} +
+ ) +} + +export default function ProfilePage() { + const qc = useQueryClient() + const { user } = useAuthStore() + + const { data: profile } = useQuery({ + queryKey: ['profile'], + queryFn: () => api.get('/profile/').then(r => r.data), + }) + + const { data: pocketidConfig } = useQuery({ + queryKey: ['pocketid-config'], + queryFn: () => api.get('/profile/pocketid-config').then(r => r.data), + enabled: !!user?.is_admin, + }) + + // HR / measurements form + const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' }) + const [hrSaved, setHrSaved] = useState(false) + useEffect(() => { + if (profile) setHrForm({ + max_heart_rate: profile.max_heart_rate || '', + resting_heart_rate: profile.resting_heart_rate || '', + birth_year: profile.birth_year || '', + height_cm: profile.height_cm || '', + }) + }, [profile]) + + const updateProfile = useMutation({ + mutationFn: data => api.patch('/profile/', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) }, + }) + + // Weight log + const { data: weightLog } = useQuery({ + queryKey: ['weight-log'], + queryFn: () => api.get('/profile/weight').then(r => r.data), + }) + const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) }) + const [weightSaved, setWeightSaved] = useState(false) + const addWeight = useMutation({ + mutationFn: data => api.post('/profile/weight', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) }, + }) + const deleteWeight = useMutation({ + mutationFn: id => api.delete(`/profile/weight/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }), + }) + + // Password change + const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' }) + const [pwError, setPwError] = useState('') + const [pwSaved, setPwSaved] = useState(false) + const changePassword = useMutation({ + mutationFn: data => api.post('/profile/change-password', data).then(r => r.data), + onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) }, + onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'), + }) + + // PocketID config + const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) + const [pidSaved, setPidSaved] = useState(false) + useEffect(() => { + if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' }) + }, [pocketidConfig]) + const savePocketID = useMutation({ + mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) }, + }) + + const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr + + return ( +
+

Profile & Settings

+ + {/* HR & Measurements */} +
+
+ Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test. + {effectiveMaxHr && ( +
+ Effective max HR: {effectiveMaxHr} bpm + {!profile?.max_heart_rate && ' (estimated from age)'} + {' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)} +
+ )} +
+ +
+ + setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> + + + setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} /> + + + setHrForm(f => ({ ...f, birth_year: e.target.value }))} /> + + + setHrForm(f => ({ ...f, height_cm: e.target.value }))} /> + +
+ + updateProfile.mutate(Object.fromEntries( + Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) + ))} + loading={updateProfile.isPending} + saved={hrSaved} + /> +
+ + {/* Weight log */} +
+
+ + setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} /> + + + setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} /> + + + setWeightForm(f => ({ ...f, date: e.target.value }))} /> + +
+ addWeight.mutate({ + weight_kg: parseFloat(weightForm.weight_kg), + body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null, + date: new Date(weightForm.date).toISOString(), + })} + loading={addWeight.isPending} + saved={weightSaved} + label="Log weight" + /> + + {weightLog && weightLog.length > 0 && ( +
+

Recent entries

+
+ {weightLog.slice(0, 20).map(entry => ( +
+ {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} + {entry.weight_kg.toFixed(1)} kg + {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat} + +
+ ))} +
+
+ )} +
+ + {/* Password change */} +
+
+ + { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} /> + + + setPwForm(f => ({ ...f, new_password: e.target.value }))} /> + + + setPwForm(f => ({ ...f, confirm: e.target.value }))} /> + + {pwError &&

{pwError}

} +
+ { + if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return } + changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password }) + }} + loading={changePassword.isPending} + saved={pwSaved} + label="Change password" + /> +
+ + {/* PocketID — admin only */} + {user?.is_admin && ( +
+

+ Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page. +

+
+ + setPidForm(f => ({ ...f, issuer: e.target.value }))} /> + + + setPidForm(f => ({ ...f, client_id: e.target.value }))} /> + + + setPidForm(f => ({ ...f, client_secret: e.target.value }))} /> + + {pocketidConfig?.enabled && ( +

✓ PocketID is currently active

+ )} +
+ savePocketID.mutate(pidForm)} + loading={savePocketID.isPending} + saved={pidSaved} + label="Save PocketID config" + /> +
+ )} +
+ ) +} diff --git a/milevault_export/frontend/src/pages/RecordsPage.jsx b/milevault_export/frontend/src/pages/RecordsPage.jsx new file mode 100644 index 0000000..98e4c2e --- /dev/null +++ b/milevault_export/frontend/src/pages/RecordsPage.jsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { format } from 'date-fns' +import api from '../utils/api' +import { formatDuration, formatDate } from '../utils/format' + +const SPORTS = ['running', 'cycling', 'swimming'] + +const DISTANCE_ORDER = [ + '400m', '800m', '1k', '1 mile', '3k', '5k', '10k', + 'Half marathon', 'Marathon', '50k', '100k', +] + +export default function RecordsPage() { + const [sport, setSport] = useState('running') + const [selectedDistance, setSelectedDistance] = useState(null) + + const { data: records } = useQuery({ + queryKey: ['records', sport], + queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data), + }) + + const { data: history } = useQuery({ + queryKey: ['record-history', selectedDistance, sport], + queryFn: () => + api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, { + params: { sport_type: sport }, + }).then(r => r.data), + enabled: !!selectedDistance, + }) + + // Sort by standard distance order + const sortedRecords = records?.slice().sort((a, b) => { + const ai = DISTANCE_ORDER.indexOf(a.distance_label) + const bi = DISTANCE_ORDER.indexOf(b.distance_label) + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) + }) + + return ( +
+

Personal Records

+ + {/* Sport selector */} +
+ {SPORTS.map(s => ( + + ))} +
+ + {sortedRecords?.length === 0 && ( +
+

🏆

+

No records yet — import activities to track your best times

+
+ )} + +
+ {/* Records table */} +
+ + + + + + + + + + {sortedRecords?.map(rec => ( + setSelectedDistance(rec.distance_label)} + className={`border-b border-gray-800/50 cursor-pointer transition-colors ${ + selectedDistance === rec.distance_label + ? 'bg-blue-900/20' + : 'hover:bg-gray-800/40' + }`} + > + + + + + + ))} + +
DistanceBest timeDate +
{rec.distance_label} + {formatDuration(rec.duration_s)} + + {formatDate(rec.achieved_at)} + + e.stopPropagation()} + className="text-xs text-blue-400 hover:underline" + > + View → + +
+
+ + {/* Progress chart */} +
+ {selectedDistance && history ? ( + <> +

+ {selectedDistance} progression +

+

Lower is faster

+ {history.length > 1 ? ( + + ({ + date: h.achieved_at, + time: h.duration_s, + }))} + margin={{ top: 4, right: 4, bottom: 4, left: 8 }} + > + + format(new Date(d), 'MMM yy')} + /> + + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [formatDuration(v), 'Time']} + /> + + + + ) : ( +
+ Only one record — complete more activities to see progression +
+ )} + + ) : ( +
+ Select a distance to see your progression +
+ )} +
+
+
+ ) +} diff --git a/milevault_export/frontend/src/pages/RoutesPage.jsx b/milevault_export/frontend/src/pages/RoutesPage.jsx new file mode 100644 index 0000000..75984e1 --- /dev/null +++ b/milevault_export/frontend/src/pages/RoutesPage.jsx @@ -0,0 +1,186 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../utils/api' +import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' + +export default function RoutesPage() { + const [selected, setSelected] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) + const qc = useQueryClient() + + const { data: routes } = useQuery({ + queryKey: ['routes'], + queryFn: () => api.get('/routes/').then(r => r.data), + }) + + const { data: routeActivities } = useQuery({ + queryKey: ['route-activities', selected?.id], + queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), + enabled: !!selected, + }) + + const { data: recentActivities } = useQuery({ + queryKey: ['recent-activities-for-route'], + queryFn: () => api.get('/routes/recent-activities').then(r => r.data), + enabled: showCreate, + }) + + const createRoute = useMutation({ + mutationFn: (data) => api.post('/routes/', data).then(r => r.data), + onSuccess: (route) => { + qc.invalidateQueries({ queryKey: ['routes'] }) + setShowCreate(false) + setNewRoute({ name: '', activity_id: '' }) + setSelected(route) + }, + }) + + const fastest = routeActivities?.[0] + + return ( +
+
+
+

Named Routes

+

+ Routes are auto-detected when you run the same path twice. You can also create them manually. +

+
+ +
+ + {/* Create route */} + {showCreate && ( +
+

Create named route

+

+ Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically. +

+
+
+ + setNewRoute(r => ({ ...r, name: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="e.g. Morning park loop" /> +
+
+ + {recentActivities?.length === 0 ? ( +

No recent activities found.

+ ) : ( + + )} +
+
+
+ + +
+
+ )} + +
+ {/* Route list */} +
+ {routes?.length === 0 && !showCreate && ( +
+

🗺️

+

No named routes yet

+

Routes are created automatically when you repeat a run, or create one manually above.

+
+ )} + {routes?.map(route => ( + + ))} +
+ + {/* Route detail */} + {selected && ( +
+
+
+

{selected.name}

+ {selected.auto_detected && ( + + Auto-detected + + )} +
+ + {fastest && ( +
+

Course record 🏆

+
+ {formatDuration(fastest.duration_s)} + + {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)} + +
+
+ )} + +

+ All runs ({routeActivities?.length ?? 0}) +

+
+ {routeActivities?.map((act, i) => ( +
+ {i + 1} + {formatDate(act.start_time)} + {formatDuration(act.duration_s)} + {formatPace(act.avg_speed_ms, selected.sport_type)} + {act.avg_heart_rate && ( + {Math.round(act.avg_heart_rate)} bpm + )} + {i === 0 && ( + CR + )} +
+ ))} +
+
+
+ )} +
+
+ ) +} diff --git a/milevault_export/frontend/src/pages/UploadPage.jsx b/milevault_export/frontend/src/pages/UploadPage.jsx new file mode 100644 index 0000000..62dc1c5 --- /dev/null +++ b/milevault_export/frontend/src/pages/UploadPage.jsx @@ -0,0 +1,178 @@ +import { useState, useCallback } from 'react' +import { useDropzone } from 'react-dropzone' +import { useMutation } from '@tanstack/react-query' +import api from '../utils/api' + +function UploadZone({ title, description, accept, endpoint, icon }) { + const [tasks, setTasks] = useState([]) + + const upload = useMutation({ + mutationFn: async (file) => { + const form = new FormData() + form.append('file', file) + const { data } = await api.post(endpoint, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return { file: file.name, ...data } + }, + onSuccess: (data) => { + setTasks(t => [...t, { ...data, status: 'queued' }]) + }, + }) + + const onDrop = useCallback((accepted) => { + accepted.forEach(file => upload.mutate(file)) + }, [upload]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept, + multiple: true, + }) + + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+
+ +
+ + {isDragActive ? ( +

Drop files here…

+ ) : ( +
+

Drag & drop files here, or click to browse

+

+ {Object.values(accept).flat().join(', ')} +

+
+ )} +
+ + {upload.isPending && ( +

Uploading…

+ )} + + {tasks.length > 0 && ( +
+ {tasks.map((task, i) => ( +
+ {task.file} + {task.activity_tasks !== undefined && ( + {task.activity_tasks} activities queued + )} + ✓ Queued +
+ ))} +
+ )} +
+ ) +} + +export default function UploadPage() { + return ( +
+
+

Import Data

+

+ Import activities from Garmin or Strava. Large exports are processed in the background. +

+
+ + {/* How to export guides */} +
+
+

📥 How to export from Garmin Connect

+
    +
  1. Go to Garmin Connect → Profile → Account
  2. +
  3. Scroll to Data Management → Export Your Data
  4. +
  5. Request export and wait for the email
  6. +
  7. Download and upload the ZIP file below
  8. +
+
+
+

📥 How to export from Strava

+
    +
  1. Go to strava.com → Settings → My Account
  2. +
  3. Scroll to Download or Delete Your Account
  4. +
  5. Click "Request Your Archive"
  6. +
  7. Download and upload the ZIP file below
  8. +
+
+
+ +
+ {/* Single FIT/GPX */} + + + {/* Garmin full export */} + + + {/* Strava export */} + + + {/* Ongoing FIT files */} +
+
+ 🔄 +
+

Ongoing sync

+

Automatically import new Garmin watch files

+
+
+
+

After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:

+ + GARMIN/Activity/*.fit + +

Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:

+ +{`# Example: auto-upload new FIT files +inotifywait -m ~/Garmin/Activity/ -e create \\ + --format '%f' | while read file; do + curl -X POST /api/upload/activity \\ + -H "Authorization: Bearer TOKEN" \\ + -F "file=@$file" + done`} + +
+
+
+
+ ) +} diff --git a/milevault_export/frontend/src/utils/api.js b/milevault_export/frontend/src/utils/api.js new file mode 100644 index 0000000..502fb07 --- /dev/null +++ b/milevault_export/frontend/src/utils/api.js @@ -0,0 +1,26 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '/api', +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export default api diff --git a/milevault_export/frontend/src/utils/format.js b/milevault_export/frontend/src/utils/format.js new file mode 100644 index 0000000..6f03b35 --- /dev/null +++ b/milevault_export/frontend/src/utils/format.js @@ -0,0 +1,94 @@ +export function formatDuration(seconds) { + if (!seconds) return '--' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${m}:${String(s).padStart(2, '0')}` +} + +export function formatPace(speedMs, sportType = 'running') { + if (!speedMs || speedMs <= 0) return '--' + if (sportType === 'cycling') { + return `${(speedMs * 3.6).toFixed(1)} km/h` + } + const secsPerKm = 1000 / speedMs + const mins = Math.floor(secsPerKm / 60) + const secs = Math.floor(secsPerKm % 60) + return `${mins}:${String(secs).padStart(2, '0')} /km` +} + +export function formatDistance(metres) { + if (!metres) return '--' + if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km` + return `${Math.round(metres)} m` +} + +export function formatElevation(metres) { + if (metres == null) return '--' + return `${Math.round(metres)} m` +} + +export function formatHeartRate(bpm) { + if (!bpm) return '--' + return `${Math.round(bpm)} bpm` +} + +export function formatSleep(seconds) { + if (!seconds) return '--' + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return `${h}h ${m}m` +} + +export function formatWeight(kg) { + if (!kg) return '--' + return `${kg.toFixed(1)} kg` +} + +export function formatDate(dateStr) { + if (!dateStr) return '--' + return new Date(dateStr).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', + }) +} + +export function formatDateTime(dateStr) { + if (!dateStr) return '--' + return new Date(dateStr).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +export function formatCadence(value, sportType) { + if (!value) return '--' + // Garmin stores running cadence as steps per minute / 2 (one foot) + // We need to double it to get total steps per minute (both feet) + if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') { + return `${Math.round(value * 2)} spm` + } + // Cycling is already in rpm + return `${Math.round(value)} rpm` +} + +export function hrZoneColor(zone) { + const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' } + return colors[zone] || '#9ca3af' +} + +export function sportIcon(sportType) { + const icons = { + running: '🏃', cycling: '🚴', hiking: '🥾', + walking: '🚶', other: '⚡', + } + return icons[sportType?.toLowerCase()] || '⚡' +} + +export function sportColor(sportType) { + const colors = { + running: '#3b82f6', cycling: '#f97316', + hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280', + } + return colors[sportType?.toLowerCase()] || '#6b7280' +} diff --git a/milevault_export/frontend/tailwind.config.js b/milevault_export/frontend/tailwind.config.js new file mode 100644 index 0000000..53c34f8 --- /dev/null +++ b/milevault_export/frontend/tailwind.config.js @@ -0,0 +1,18 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + }, + }, + }, + plugins: [], +} diff --git a/milevault_export/frontend/vite.config.js b/milevault_export/frontend/vite.config.js new file mode 100644 index 0000000..d379536 --- /dev/null +++ b/milevault_export/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/milevault_export/install.sh b/milevault_export/install.sh new file mode 100755 index 0000000..335bf68 --- /dev/null +++ b/milevault_export/install.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# MileVault installer +# Usage: curl -fsSL https://raw.githubusercontent.com/you/milevault/main/install.sh | bash +# Or: bash install.sh +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +BOLD='\033[1m' + +info() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}!${NC} $*"; } +error() { echo -e "${RED}✗ $*${NC}"; exit 1; } +step() { echo -e "\n${CYAN}${BOLD}── $* ──${NC}"; } + +echo -e "${BOLD}" +echo " ███████╗██╗████████╗████████╗██████╗ █████╗ ██████╗██╗ ██╗███████╗██████╗ " +echo " ██╔════╝██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗" +echo " █████╗ ██║ ██║ ██║ ██████╔╝███████║██║ █████╔╝ █████╗ ██████╔╝" +echo " ██╔══╝ ██║ ██║ ██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗" +echo " ██║ ██║ ██║ ██║ ██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║" +echo " ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝" +echo -e "${NC}" +echo " Self-hosted fitness tracking — Garmin & Strava" +echo "" + +# ── Preflight checks ────────────────────────────────────────────────────────── + +step "Checking requirements" + +command -v docker >/dev/null 2>&1 || error "Docker is not installed. Install from https://docs.docker.com/get-docker/" +info "Docker found: $(docker --version | head -1)" + +# Check docker compose (v2 plugin or v1 standalone) +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +else + error "Docker Compose not found. Install from https://docs.docker.com/compose/install/" +fi +info "Docker Compose found: $($COMPOSE_CMD version | head -1)" + +# Check Docker daemon is running +docker info >/dev/null 2>&1 || error "Docker daemon is not running. Start Docker and retry." +info "Docker daemon is running" + +# ── Install directory ───────────────────────────────────────────────────────── + +step "Setting up install directory" + +INSTALL_DIR="${FITTRACKER_DIR:-$HOME/milevault}" + +if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + warn "Directory $INSTALL_DIR already exists." + read -rp " Continue and update existing install? [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } +fi + +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" +info "Install directory: $INSTALL_DIR" + +# ── Download project files ──────────────────────────────────────────────────── + +step "Downloading MileVault" + +# If we're already inside the repo (files exist), skip download +if [ -f "docker-compose.yml" ]; then + info "Project files already present — skipping download" +else + # Try git first, fall back to curl + if command -v git >/dev/null 2>&1; then + git clone --depth 1 https://github.com/yourusername/milevault.git . 2>/dev/null || { + warn "Git clone failed — copying bundled files instead" + } + fi + + # Fallback: if running this script from inside a downloaded zip, the files are next to it + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ "$SCRIPT_DIR" != "$INSTALL_DIR" ] && [ -f "$SCRIPT_DIR/docker-compose.yml" ]; then + cp -r "$SCRIPT_DIR"/. "$INSTALL_DIR/" + info "Copied project files from $SCRIPT_DIR" + fi +fi + +[ -f "docker-compose.yml" ] || error "docker-compose.yml not found. Place install.sh inside the project directory." +info "Project files ready" + +# ── Generate .env ───────────────────────────────────────────────────────────── + +step "Configuring environment" + +if [ -f ".env" ]; then + warn ".env already exists — skipping generation (delete it to regenerate)" +else + # Generate secure random values + if command -v openssl >/dev/null 2>&1; then + SECRET_KEY=$(openssl rand -hex 32) + DB_PASSWORD=$(openssl rand -base64 18 | tr -d '/+=') + REDIS_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=') + ADMIN_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=') + else + # Fallback if openssl not available + SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 64) + DB_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 18) + REDIS_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12) + ADMIN_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12) + fi + + ADMIN_USERNAME="${FITTRACKER_ADMIN:-admin}" + PORT="${FITTRACKER_PORT:-80}" + + cat > .env << ENV +# MileVault configuration — generated $(date) +# Edit this file to change settings, then run: docker compose up -d + +# Admin login +ADMIN_USERNAME=${ADMIN_USERNAME} +ADMIN_PASSWORD=${ADMIN_PASSWORD} + +# Secrets (auto-generated — do not share) +SECRET_KEY=${SECRET_KEY} +DB_PASSWORD=${DB_PASSWORD} +DB_USER=milevault +REDIS_PASSWORD=${REDIS_PASSWORD} + +# Server +HTTP_PORT=${PORT} +ENVIRONMENT=production + +# Optional: Mapbox token for satellite map tiles (free at mapbox.com) +VITE_MAPBOX_TOKEN= + +# Optional: PocketID passkey authentication +# POCKETID_ISSUER=https://your-pocketid.example.com +# POCKETID_CLIENT_ID=milevault +# POCKETID_CLIENT_SECRET= +ENV + + info ".env created with secure random secrets" + + # Save credentials for display at end + SHOW_CREDS=true +fi + +source .env + +# ── Build & start ───────────────────────────────────────────────────────────── + +step "Building and starting containers" +echo " This takes 3–5 minutes on first run (building images)..." +echo "" + +$COMPOSE_CMD up -d --build + +# ── Wait for healthy ────────────────────────────────────────────────────────── + +step "Waiting for services to be ready" + +TIMEOUT=120 +ELAPSED=0 +printf " Waiting" +while ! docker inspect milevault_backend 2>/dev/null | grep -q '"healthy"' ; do + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "" + warn "Backend taking longer than expected. Check logs: docker compose logs backend" + break + fi + printf "." + sleep 3 + ELAPSED=$((ELAPSED + 3)) +done +echo "" +info "All services are up" + +# ── Done ────────────────────────────────────────────────────────────────────── + +PORT="${HTTP_PORT:-80}" +URL="http://localhost${PORT:+:${PORT}}" +[[ "$PORT" == "80" ]] && URL="http://localhost" + +echo "" +echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}${BOLD}║ MileVault is ready! ║${NC}" +echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════╝${NC}" +echo "" +echo -e " 🌐 Open: ${CYAN}${URL}${NC}" +echo -e " 👤 Username: ${BOLD}${ADMIN_USERNAME:-admin}${NC}" + +if [ "${SHOW_CREDS:-false}" = "true" ]; then + echo -e " 🔑 Password: ${BOLD}${ADMIN_PASSWORD}${NC}" + echo "" + warn "Save this password — it won't be shown again." + warn "It's also stored in: ${INSTALL_DIR}/.env" +else + echo -e " 🔑 Password: (see ${INSTALL_DIR}/.env — ADMIN_PASSWORD)" +fi + +echo "" +echo " Useful commands:" +echo " docker compose logs -f # View live logs" +echo " docker compose logs backend # Backend logs only" +echo " docker compose down # Stop everything" +echo " docker compose up -d # Start again" +echo " docker compose pull && docker compose up -d --build # Update" +echo "" +echo " Import your data:" +echo " Go to ${URL} → Import → upload your Garmin export ZIP or Strava ZIP" +echo "" diff --git a/milevault_export/nginx.conf b/milevault_export/nginx.conf new file mode 100644 index 0000000..e7095b1 --- /dev/null +++ b/milevault_export/nginx.conf @@ -0,0 +1,50 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 512M; + + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m; + + upstream backend { server backend:8000; keepalive 32; } + upstream frontend { server frontend:80; } + + server { + listen 80; + server_name _; + + location /api/upload/ { + limit_req zone=upload burst=5 nodelay; + proxy_pass http://backend/api/upload/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 512M; + } + + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://backend/health; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + } + } +} diff --git a/milevault_export/nginx/nginx.conf b/milevault_export/nginx/nginx.conf new file mode 100644 index 0000000..c764c66 --- /dev/null +++ b/milevault_export/nginx/nginx.conf @@ -0,0 +1,59 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 512M; + + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m; + + upstream backend { + server backend:8000; + keepalive 32; + } + + upstream frontend { + server frontend:80; + } + + server { + listen 80; + server_name _; + + location /api/upload/ { + limit_req zone=upload burst=5 nodelay; + proxy_pass http://backend/api/upload/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 512M; + } + + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://backend/health; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/milevault_export/scripts/manage.sh b/milevault_export/scripts/manage.sh new file mode 100755 index 0000000..46b4c52 --- /dev/null +++ b/milevault_export/scripts/manage.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +info() { echo -e "${GREEN}[milevault]${NC} $*"; } +warn() { echo -e "${YELLOW}[milevault]${NC} $*"; } +error() { echo -e "${RED}[milevault]${NC} $*"; exit 1; } + +check_env() { + if [ ! -f .env ]; then + warn ".env not found — copying from .env.example" + cp .env.example .env + warn "Please edit .env and set required secrets, then re-run this script." + exit 1 + fi + + source .env + [ -z "${DB_PASSWORD:-}" ] && error "DB_PASSWORD must be set in .env" + [ -z "${SECRET_KEY:-}" ] && error "SECRET_KEY must be set in .env (use: openssl rand -hex 32)" + [ -z "${ADMIN_PASSWORD:-}" ] && error "ADMIN_PASSWORD must be set in .env" + info "Environment looks good" +} + +generate_secrets() { + info "Generating .env from template..." + cp .env.example .env + SECRET=$(openssl rand -hex 32) + DB_PASS=$(openssl rand -base64 16 | tr -d '/+=') + ADMIN_PASS=$(openssl rand -base64 12 | tr -d '/+=') + REDIS_PASS=$(openssl rand -base64 12 | tr -d '/+=') + + sed -i "s/changeme_generate_with_openssl_rand_hex_32/$SECRET/" .env + sed -i "s/changeme_strong_password/$DB_PASS/" .env + sed -i "s/changeme_admin_password/$ADMIN_PASS/" .env + sed -i "s/redispass/$REDIS_PASS/" .env + + echo "" + echo -e "${GREEN}Generated secrets:${NC}" + echo " Admin username: admin" + echo " Admin password: $ADMIN_PASS" + echo " (saved to .env)" + echo "" + warn "Save these credentials! The admin password won't be shown again." +} + +cmd_start() { + check_env + info "Starting MileVault..." + docker compose up -d --build + info "Started! Visit http://localhost:${HTTP_PORT:-80}" +} + +cmd_stop() { + info "Stopping MileVault..." + docker compose down +} + +cmd_logs() { + docker compose logs -f "${1:-}" +} + +cmd_setup() { + info "First-time setup" + generate_secrets + cmd_start +} + +cmd_backup() { + source .env + BACKUP_FILE="milevault_backup_$(date +%Y%m%d_%H%M%S).sql" + info "Backing up database to $BACKUP_FILE..." + docker compose exec -T db pg_dump \ + -U "${DB_USER:-milevault}" milevault > "$BACKUP_FILE" + info "Backup saved: $BACKUP_FILE" +} + +cmd_restore() { + [ -z "${1:-}" ] && error "Usage: $0 restore " + source .env + info "Restoring from $1..." + docker compose exec -T db psql \ + -U "${DB_USER:-milevault}" milevault < "$1" + info "Restore complete" +} + +cmd_update() { + info "Pulling latest and rebuilding..." + git pull + docker compose build --no-cache + docker compose up -d + info "Update complete" +} + +case "${1:-help}" in + setup) cmd_setup ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + logs) cmd_logs "${2:-}" ;; + backup) cmd_backup ;; + restore) cmd_restore "${2:-}" ;; + update) cmd_update ;; + *) + echo "MileVault management script" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " setup First-time setup (generates secrets, starts containers)" + echo " start Start all containers" + echo " stop Stop all containers" + echo " restart Restart all containers" + echo " logs Follow logs (optionally: logs backend)" + echo " backup Backup PostgreSQL database" + echo " restore Restore from backup: restore " + echo " update Pull and rebuild" + ;; +esac