All tweaks added
This commit is contained in:
+30
-42
@@ -7,13 +7,23 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
|
from app.core.security import verify_password, create_access_token, get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_pocketid_config(db: AsyncSession):
|
||||||
|
"""Get PocketID config from DB (admin user) falling back to env vars."""
|
||||||
|
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
|
||||||
|
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
|
||||||
|
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
|
||||||
|
return issuer, client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -37,24 +47,15 @@ async def login(
|
|||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||||
select(User).where(User.username == form_data.username)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user or not user.hashed_password:
|
if not user or not user.hashed_password:
|
||||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||||
if not verify_password(form_data.password, user.hashed_password):
|
if not verify_password(form_data.password, user.hashed_password):
|
||||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||||
|
|
||||||
token = create_access_token({"sub": str(user.id)})
|
token = create_access_token({"sub": str(user.id)})
|
||||||
return Token(
|
return Token(access_token=token, token_type="bearer",
|
||||||
access_token=token,
|
user_id=user.id, username=user.username, is_admin=user.is_admin)
|
||||||
token_type="bearer",
|
|
||||||
user_id=user.id,
|
|
||||||
username=user.username,
|
|
||||||
is_admin=user.is_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
@@ -63,51 +64,44 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/pocketid/available")
|
@router.get("/pocketid/available")
|
||||||
async def pocketid_available():
|
async def pocketid_available(db: AsyncSession = Depends(get_db)):
|
||||||
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
|
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||||
|
return {"available": bool(issuer and client_id)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pocketid/login-url")
|
@router.get("/pocketid/login-url")
|
||||||
async def pocketid_login_url():
|
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||||
"""Return the OIDC authorization URL for PocketID."""
|
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||||
if not settings.pocketid_issuer:
|
if not issuer or not client_id:
|
||||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||||
|
from urllib.parse import urlencode
|
||||||
params = {
|
params = {
|
||||||
"client_id": settings.pocketid_client_id,
|
"client_id": client_id,
|
||||||
"redirect_uri": "/api/auth/pocketid/callback",
|
"redirect_uri": "/api/auth/pocketid/callback",
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "openid profile email",
|
"scope": "openid profile email",
|
||||||
}
|
}
|
||||||
from urllib.parse import urlencode
|
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||||
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
|
|
||||||
return {"url": url}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pocketid/callback")
|
@router.get("/pocketid/callback")
|
||||||
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Exchange OIDC code for tokens and create/login user."""
|
issuer, client_id, client_secret = await _get_pocketid_config(db)
|
||||||
if not settings.pocketid_issuer:
|
if not issuer:
|
||||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||||
|
|
||||||
# Exchange code for tokens
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{settings.pocketid_issuer}/token",
|
f"{issuer}/token",
|
||||||
data={
|
data={"grant_type": "authorization_code", "code": code,
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"redirect_uri": "/api/auth/pocketid/callback",
|
"redirect_uri": "/api/auth/pocketid/callback",
|
||||||
"client_id": settings.pocketid_client_id,
|
"client_id": client_id, "client_secret": client_secret},
|
||||||
"client_secret": settings.pocketid_client_secret,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||||
|
|
||||||
tokens = resp.json()
|
tokens = resp.json()
|
||||||
userinfo_resp = await client.get(
|
userinfo_resp = await client.get(
|
||||||
f"{settings.pocketid_issuer}/userinfo",
|
f"{issuer}/userinfo",
|
||||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||||
)
|
)
|
||||||
userinfo = userinfo_resp.json()
|
userinfo = userinfo_resp.json()
|
||||||
@@ -118,17 +112,11 @@ async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
user = User(
|
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||||
username=preferred_username,
|
|
||||||
email=email,
|
|
||||||
pocketid_sub=sub,
|
|
||||||
)
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
token = create_access_token({"sub": str(user.id)})
|
token = create_access_token({"sub": str(user.id)})
|
||||||
# Redirect to frontend with token
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
return RedirectResponse(url=f"/?token={token}")
|
return RedirectResponse(url=f"/?token={token}")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
@@ -23,7 +23,7 @@ class RouteCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
sport_type: Optional[str] = None
|
sport_type: Optional[str] = None
|
||||||
activity_id: int # use this activity as the reference route
|
activity_id: int
|
||||||
|
|
||||||
|
|
||||||
class RouteOut(BaseModel):
|
class RouteOut(BaseModel):
|
||||||
@@ -34,6 +34,7 @@ class RouteOut(BaseModel):
|
|||||||
reference_polyline: Optional[str]
|
reference_polyline: Optional[str]
|
||||||
bounding_box: Optional[dict]
|
bounding_box: Optional[dict]
|
||||||
distance_m: Optional[float]
|
distance_m: Optional[float]
|
||||||
|
auto_detected: Optional[bool]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -64,13 +65,44 @@ async def list_routes(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activities")
|
||||||
|
async def recent_activities_for_route(
|
||||||
|
days: int = Query(14, ge=1, le=90),
|
||||||
|
sport_type: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return recent activities for the route creation dropdown."""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
q = select(Activity).where(
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
Activity.start_time >= cutoff,
|
||||||
|
Activity.sport_type != "swimming",
|
||||||
|
)
|
||||||
|
if sport_type:
|
||||||
|
q = q.where(Activity.sport_type == sport_type)
|
||||||
|
q = q.order_by(desc(Activity.start_time)).limit(50)
|
||||||
|
result = await db.execute(q)
|
||||||
|
activities = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"name": a.name,
|
||||||
|
"sport_type": a.sport_type,
|
||||||
|
"start_time": a.start_time,
|
||||||
|
"distance_m": a.distance_m,
|
||||||
|
"duration_s": a.duration_s,
|
||||||
|
}
|
||||||
|
for a in activities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=RouteOut)
|
@router.post("/", response_model=RouteOut)
|
||||||
async def create_route(
|
async def create_route(
|
||||||
body: RouteCreate,
|
body: RouteCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Load the reference activity
|
|
||||||
act_result = await db.execute(
|
act_result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.id == body.activity_id,
|
Activity.id == body.activity_id,
|
||||||
@@ -89,11 +121,10 @@ async def create_route(
|
|||||||
reference_polyline=activity.polyline,
|
reference_polyline=activity.polyline,
|
||||||
bounding_box=activity.bounding_box,
|
bounding_box=activity.bounding_box,
|
||||||
distance_m=activity.distance_m,
|
distance_m=activity.distance_m,
|
||||||
|
auto_detected=False,
|
||||||
)
|
)
|
||||||
db.add(route)
|
db.add(route)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Link this activity to the route
|
|
||||||
activity.named_route_id = route.id
|
activity.named_route_id = route.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(route)
|
await db.refresh(route)
|
||||||
@@ -124,7 +155,6 @@ async def route_activities(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""All activities on this named route, ordered fastest first."""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
Activity.named_route_id == route_id,
|
Activity.named_route_id == route_id,
|
||||||
@@ -153,7 +183,6 @@ async def assign_activity_to_route(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Manually assign an activity to a named route."""
|
|
||||||
activity_id = body.get("activity_id")
|
activity_id = body.get("activity_id")
|
||||||
act_result = await db.execute(
|
act_result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).where(
|
||||||
@@ -164,7 +193,6 @@ async def assign_activity_to_route(
|
|||||||
activity = act_result.scalar_one_or_none()
|
activity = act_result.scalar_one_or_none()
|
||||||
if not activity:
|
if not activity:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
activity.named_route_id = route_id
|
activity.named_route_id = route_id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
|
|
||||||
from app.core.database import engine, AsyncSessionLocal, Base
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api import auth, activities, routes, health, records, upload
|
from app.api import auth, activities, routes, health, records, upload, profile
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
@@ -97,6 +97,7 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
|||||||
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -22,9 +22,39 @@ class User(Base):
|
|||||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
# Health profile
|
||||||
|
max_heart_rate = Column(Integer, nullable=True)
|
||||||
|
resting_heart_rate = Column(Integer, nullable=True)
|
||||||
|
birth_year = Column(Integer, nullable=True)
|
||||||
|
height_cm = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
# PocketID config (stored per-user so admin can set via UI)
|
||||||
|
pocketid_issuer = Column(String(512), nullable=True)
|
||||||
|
pocketid_client_id = Column(String(256), nullable=True)
|
||||||
|
pocketid_client_secret = Column(String(256), nullable=True)
|
||||||
|
|
||||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class WeightLog(Base):
|
||||||
|
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||||
|
__tablename__ = "weight_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
weight_kg = Column(Float, nullable=False)
|
||||||
|
body_fat_pct = Column(Float, nullable=True)
|
||||||
|
note = Column(String(256), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_weight_user_date", "user_id", "date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="weight_logs")
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
@@ -68,11 +98,6 @@ class Activity(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ActivityDataPoint(Base):
|
class ActivityDataPoint(Base):
|
||||||
"""
|
|
||||||
TimescaleDB hypertable - one row per second of activity data.
|
|
||||||
Composite primary key (activity_id, timestamp) satisfies TimescaleDB's
|
|
||||||
requirement that the partition column be part of the primary key.
|
|
||||||
"""
|
|
||||||
__tablename__ = "activity_data_points"
|
__tablename__ = "activity_data_points"
|
||||||
|
|
||||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||||
@@ -118,6 +143,7 @@ class NamedRoute(Base):
|
|||||||
reference_polyline = Column(Text, nullable=True)
|
reference_polyline = Column(Text, nullable=True)
|
||||||
bounding_box = Column(JSON, nullable=True)
|
bounding_box = Column(JSON, nullable=True)
|
||||||
distance_m = Column(Float, nullable=True)
|
distance_m = Column(Float, nullable=True)
|
||||||
|
auto_detected = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
user = relationship("User", back_populates="named_routes")
|
user = relationship("User", back_populates="named_routes")
|
||||||
|
|||||||
+108
-142
@@ -1,21 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Parses Garmin .fit files and GPX files into normalized activity data.
|
FIT and GPX file parser using:
|
||||||
Handles full Strava and Garmin data export archives.
|
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
|
||||||
|
- gpxpy for .gpx files
|
||||||
|
|
||||||
|
The official SDK correctly handles scale/offset, component expansion,
|
||||||
|
semicircle-to-degree conversion, and HR message merging.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import fitparse
|
|
||||||
import gpxpy
|
import gpxpy
|
||||||
import polyline as polyline_lib
|
import polyline as polyline_lib
|
||||||
|
|
||||||
|
|
||||||
|
FIT_EPOCH_S = 631065600
|
||||||
|
|
||||||
|
|
||||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||||
"""Returns distance in metres between two GPS points."""
|
"""Distance in metres between two GPS points."""
|
||||||
R = 6371000
|
R = 6371000
|
||||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
dphi = math.radians(lat2 - lat1)
|
dphi = math.radians(lat2 - lat1)
|
||||||
@@ -24,106 +27,100 @@ def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
|||||||
return 2 * R * math.asin(math.sqrt(a))
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
def semicircles_to_degrees(sc: int) -> float:
|
def _safe_float(val) -> Optional[float]:
|
||||||
return sc * (180 / 2**31)
|
try:
|
||||||
|
return float(val) if val is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bounding_box(coords: list) -> Optional[dict]:
|
||||||
|
if not coords:
|
||||||
|
return None
|
||||||
|
lats = [c[0] for c in coords]
|
||||||
|
lons = [c[1] for c in coords]
|
||||||
|
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||||
|
"min_lon": min(lons), "max_lon": max(lons)}
|
||||||
|
|
||||||
|
|
||||||
def parse_fit_file(filepath: str) -> dict:
|
def parse_fit_file(filepath: str) -> dict:
|
||||||
"""Parse a Garmin .fit file and return normalized activity dict."""
|
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
||||||
fit = fitparse.FitFile(filepath)
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
|
||||||
data_points = []
|
|
||||||
laps = []
|
|
||||||
session = {}
|
session = {}
|
||||||
|
records = []
|
||||||
|
laps = []
|
||||||
|
|
||||||
for record in fit.get_messages():
|
def listener(mesg_num: int, msg: dict):
|
||||||
name = record.name
|
nonlocal session
|
||||||
|
if mesg_num == 18: # session
|
||||||
|
session = msg
|
||||||
|
elif mesg_num == 20: # record
|
||||||
|
records.append(msg)
|
||||||
|
elif mesg_num == 19: # lap
|
||||||
|
laps.append(msg)
|
||||||
|
|
||||||
if name == "session":
|
stream = Stream.from_file(filepath)
|
||||||
for f in record:
|
decoder = Decoder(stream)
|
||||||
session[f.name] = f.value
|
decoder.read(
|
||||||
|
apply_scale_and_offset=True,
|
||||||
|
convert_datetimes_to_dates=True,
|
||||||
|
convert_types_to_strings=True,
|
||||||
|
enable_crc_check=False,
|
||||||
|
expand_sub_fields=True,
|
||||||
|
expand_components=True,
|
||||||
|
merge_heart_rates=True,
|
||||||
|
mesg_listener=listener,
|
||||||
|
)
|
||||||
|
|
||||||
elif name == "lap":
|
# Map sport type
|
||||||
lap = {}
|
|
||||||
for f in record:
|
|
||||||
lap[f.name] = f.value
|
|
||||||
laps.append(lap)
|
|
||||||
|
|
||||||
elif name == "record":
|
|
||||||
point = {}
|
|
||||||
for f in record:
|
|
||||||
point[f.name] = f.value
|
|
||||||
if point:
|
|
||||||
# Convert semicircles to degrees
|
|
||||||
if "position_lat" in point and point["position_lat"] is not None:
|
|
||||||
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
|
|
||||||
if "position_long" in point and point["position_long"] is not None:
|
|
||||||
point["position_long"] = semicircles_to_degrees(point["position_long"])
|
|
||||||
data_points.append(point)
|
|
||||||
|
|
||||||
# Build normalized output
|
|
||||||
sport = str(session.get("sport", "generic")).lower()
|
sport = str(session.get("sport", "generic")).lower()
|
||||||
sport_map = {
|
sport_map = {
|
||||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||||
"open_water_swimming": "swimming", "trail_running": "running",
|
"open_water_swimming": "swimming", "trail_running": "running",
|
||||||
|
"e_biking": "cycling",
|
||||||
}
|
}
|
||||||
sport_type = sport_map.get(sport, sport)
|
sport_type = sport_map.get(sport, sport)
|
||||||
|
|
||||||
start_time = session.get("start_time")
|
start_time = session.get("start_time")
|
||||||
if start_time and start_time.tzinfo is None:
|
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
||||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
# Build GPS track for polyline
|
# Build GPS track
|
||||||
coords = [
|
coords = [
|
||||||
(p["position_lat"], p["position_long"])
|
(r["position_lat"], r["position_long"])
|
||||||
for p in data_points
|
for r in records
|
||||||
if p.get("position_lat") is not None and p.get("position_long") is not None
|
if r.get("position_lat") is not None and r.get("position_long") is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
bounding_box = _bounding_box(coords)
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
# Calculate cumulative distance if not in FIT
|
# Normalize data points
|
||||||
cumulative_dist = 0.0
|
|
||||||
prev_lat, prev_lon = None, None
|
|
||||||
normalized_points = []
|
normalized_points = []
|
||||||
for p in data_points:
|
for r in records:
|
||||||
ts = p.get("timestamp")
|
ts = r.get("timestamp")
|
||||||
if ts and ts.tzinfo is None:
|
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
lat = p.get("position_lat")
|
|
||||||
lon = p.get("position_long")
|
|
||||||
|
|
||||||
dist = p.get("distance")
|
|
||||||
if dist is None and lat and lon and prev_lat and prev_lon:
|
|
||||||
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
|
|
||||||
dist = cumulative_dist
|
|
||||||
elif dist is not None:
|
|
||||||
cumulative_dist = float(dist)
|
|
||||||
|
|
||||||
if lat and lon:
|
|
||||||
prev_lat, prev_lon = lat, lon
|
|
||||||
|
|
||||||
normalized_points.append({
|
normalized_points.append({
|
||||||
"timestamp": ts.isoformat() if ts else None,
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
"latitude": lat,
|
"latitude": r.get("position_lat"),
|
||||||
"longitude": lon,
|
"longitude": r.get("position_long"),
|
||||||
"altitude_m": p.get("altitude"),
|
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
|
||||||
"heart_rate": p.get("heart_rate"),
|
"heart_rate": r.get("heart_rate"),
|
||||||
"cadence": p.get("cadence"),
|
"cadence": r.get("cadence") or r.get("fractional_cadence"),
|
||||||
"speed_ms": p.get("speed"),
|
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
|
||||||
"power": p.get("power"),
|
"power": r.get("power"),
|
||||||
"temperature_c": p.get("temperature"),
|
"temperature_c": r.get("temperature"),
|
||||||
"distance_m": dist,
|
"distance_m": r.get("distance"),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Parse laps
|
# Normalize laps
|
||||||
normalized_laps = []
|
normalized_laps = []
|
||||||
for i, lap in enumerate(laps):
|
for i, lap in enumerate(laps):
|
||||||
ls = lap.get("start_time")
|
ls = lap.get("start_time")
|
||||||
if ls and ls.tzinfo is None:
|
if isinstance(ls, datetime) and ls.tzinfo is None:
|
||||||
ls = ls.replace(tzinfo=timezone.utc)
|
ls = ls.replace(tzinfo=timezone.utc)
|
||||||
normalized_laps.append({
|
normalized_laps.append({
|
||||||
"lap_number": i + 1,
|
"lap_number": i + 1,
|
||||||
@@ -132,13 +129,17 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"distance_m": _safe_float(lap.get("total_distance")),
|
"distance_m": _safe_float(lap.get("total_distance")),
|
||||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||||
"avg_speed_ms": _safe_float(lap.get("avg_speed")),
|
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
||||||
"avg_power": _safe_float(lap.get("avg_power")),
|
"avg_power": _safe_float(lap.get("avg_power")),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Build activity name
|
||||||
|
name = session.get("sport", "Activity").title()
|
||||||
|
if start_time:
|
||||||
|
name += " " + start_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": session.get("sport", "Activity").title() + " " + (
|
"name": name,
|
||||||
start_time.strftime("%Y-%m-%d") if start_time else ""),
|
|
||||||
"sport_type": sport_type,
|
"sport_type": sport_type,
|
||||||
"start_time": start_time.isoformat() if start_time else None,
|
"start_time": start_time.isoformat() if start_time else None,
|
||||||
"distance_m": _safe_float(session.get("total_distance")),
|
"distance_m": _safe_float(session.get("total_distance")),
|
||||||
@@ -150,12 +151,12 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||||
"avg_power": _safe_float(session.get("avg_power")),
|
"avg_power": _safe_float(session.get("avg_power")),
|
||||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||||
"avg_speed_ms": _safe_float(session.get("avg_speed")),
|
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
||||||
"max_speed_ms": _safe_float(session.get("max_speed")),
|
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
||||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||||
"calories": _safe_float(session.get("total_calories")),
|
"calories": _safe_float(session.get("total_calories")),
|
||||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||||
"vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device
|
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
||||||
"polyline": encoded_polyline,
|
"polyline": encoded_polyline,
|
||||||
"bounding_box": bounding_box,
|
"bounding_box": bounding_box,
|
||||||
"source_type": "fit",
|
"source_type": "fit",
|
||||||
@@ -165,13 +166,12 @@ def parse_fit_file(filepath: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def parse_gpx_file(filepath: str) -> dict:
|
def parse_gpx_file(filepath: str) -> dict:
|
||||||
"""Parse a GPX file into normalized activity dict."""
|
"""Parse a GPX file."""
|
||||||
with open(filepath) as f:
|
with open(filepath) as f:
|
||||||
gpx = gpxpy.parse(f)
|
gpx = gpxpy.parse(f)
|
||||||
|
|
||||||
data_points = []
|
data_points = []
|
||||||
track = gpx.tracks[0] if gpx.tracks else None
|
track = gpx.tracks[0] if gpx.tracks else None
|
||||||
|
|
||||||
if not track:
|
if not track:
|
||||||
raise ValueError("No tracks found in GPX file")
|
raise ValueError("No tracks found in GPX file")
|
||||||
|
|
||||||
@@ -204,7 +204,6 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
"distance_m": None,
|
"distance_m": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Calculate distance and elevation
|
|
||||||
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||||
if p["latitude"] and p["longitude"]]
|
if p["latitude"] and p["longitude"]]
|
||||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
@@ -220,6 +219,7 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
prev = (p["latitude"], p["longitude"])
|
prev = (p["latitude"], p["longitude"])
|
||||||
p["distance_m"] = total_dist
|
p["distance_m"] = total_dist
|
||||||
|
|
||||||
|
# Elevation gain/loss
|
||||||
uphill, downhill = 0.0, 0.0
|
uphill, downhill = 0.0, 0.0
|
||||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||||
for i in range(1, len(alts)):
|
for i in range(1, len(alts)):
|
||||||
@@ -235,7 +235,7 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||||
|
|
||||||
sport = "running" # GPX doesn't always include sport; default to running
|
sport = "running"
|
||||||
if track.type:
|
if track.type:
|
||||||
sport = track.type.lower()
|
sport = track.type.lower()
|
||||||
|
|
||||||
@@ -266,76 +266,42 @@ def parse_gpx_file(filepath: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_strava_export(export_dir: str) -> list[dict]:
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
"""
|
"""
|
||||||
Parse a full Strava data export directory.
|
Calculate % time in each HR zone using the user's configured max HR.
|
||||||
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
|
|
||||||
|
Zones follow the standard 5-zone model as % of max HR:
|
||||||
|
Z1 Recovery: < 60%
|
||||||
|
Z2 Base: 60 - 70%
|
||||||
|
Z3 Tempo: 70 - 80%
|
||||||
|
Z4 Threshold: 80 - 90%
|
||||||
|
Z5 Max: > 90%
|
||||||
|
|
||||||
|
user_max_hr should be the user's actual physiological max HR, NOT the
|
||||||
|
highest HR recorded in this activity. Using activity max shifts all zones
|
||||||
|
upward and makes easy runs look harder than they are.
|
||||||
"""
|
"""
|
||||||
activities = []
|
if not user_max_hr or user_max_hr < 100:
|
||||||
activities_dir = Path(export_dir) / "activities"
|
|
||||||
|
|
||||||
if not activities_dir.exists():
|
|
||||||
return activities
|
|
||||||
|
|
||||||
for fname in sorted(activities_dir.iterdir()):
|
|
||||||
if fname.suffix in (".fit", ".gpx"):
|
|
||||||
try:
|
|
||||||
if fname.suffix == ".fit":
|
|
||||||
act = parse_fit_file(str(fname))
|
|
||||||
else:
|
|
||||||
act = parse_gpx_file(str(fname))
|
|
||||||
act["source_type"] = "strava_" + fname.suffix[1:]
|
|
||||||
activities.append(act)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error parsing {fname}: {e}")
|
|
||||||
|
|
||||||
return activities
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
|
|
||||||
"""Calculate percentage of time spent in each HR zone."""
|
|
||||||
if not max_hr:
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||||
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||||
|
zones = {k: 0 for k in zone_keys}
|
||||||
total = 0
|
total = 0
|
||||||
|
|
||||||
for p in data_points:
|
for p in data_points:
|
||||||
hr = p.get("heart_rate")
|
hr = p.get("heart_rate")
|
||||||
if not hr:
|
if not hr or hr < 20:
|
||||||
continue
|
continue
|
||||||
pct = hr / max_hr
|
pct = hr / user_max_hr
|
||||||
total += 1
|
total += 1
|
||||||
if pct < zone_bounds[1]:
|
for i, key in enumerate(zone_keys):
|
||||||
zones["z1"] += 1
|
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||||
elif pct < zone_bounds[2]:
|
zones[key] += 1
|
||||||
zones["z2"] += 1
|
break
|
||||||
elif pct < zone_bounds[3]:
|
|
||||||
zones["z3"] += 1
|
|
||||||
elif pct < zone_bounds[4]:
|
|
||||||
zones["z4"] += 1
|
|
||||||
else:
|
else:
|
||||||
zones["z5"] += 1
|
zones["z5"] += 1 # anything above 90% goes to z5
|
||||||
|
|
||||||
if total:
|
if total:
|
||||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(val) -> Optional[float]:
|
|
||||||
try:
|
|
||||||
return float(val) if val is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _bounding_box(coords: list[tuple]) -> Optional[dict]:
|
|
||||||
if not coords:
|
|
||||||
return None
|
|
||||||
lats = [c[0] for c in coords]
|
|
||||||
lons = [c[1] for c in coords]
|
|
||||||
return {
|
|
||||||
"min_lat": min(lats), "max_lat": max(lats),
|
|
||||||
"min_lon": min(lons), "max_lon": max(lons),
|
|
||||||
}
|
|
||||||
|
|||||||
+134
-121
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
if existing:
|
if existing:
|
||||||
return {"activity_id": existing.id, "status": "duplicate"}
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
|
# Get user's configured max HR for accurate zone calculation
|
||||||
|
# Falls back to: user-set value → 220-age → activity max → 190
|
||||||
|
from app.models.user import User as UserModel
|
||||||
|
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
|
||||||
|
user_max_hr = None
|
||||||
|
if user_obj:
|
||||||
|
user_max_hr = user_obj.max_heart_rate
|
||||||
|
if not user_max_hr and user_obj.birth_year:
|
||||||
|
from datetime import date as _date
|
||||||
|
age = _date.today().year - user_obj.birth_year
|
||||||
|
user_max_hr = 220 - age
|
||||||
|
if not user_max_hr:
|
||||||
|
# Last resort: use activity max but warn this may shift zones
|
||||||
|
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||||
|
|
||||||
hr_zones = calculate_hr_zones(
|
hr_zones = calculate_hr_zones(
|
||||||
parsed.get("data_points", []),
|
parsed.get("data_points", []),
|
||||||
parsed.get("max_heart_rate") or 190
|
user_max_hr
|
||||||
)
|
)
|
||||||
|
|
||||||
start_time = datetime.fromisoformat(parsed["start_time"])
|
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||||
@@ -169,6 +184,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
activity_id = activity.id
|
activity_id = activity.id
|
||||||
|
|
||||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||||
|
# Auto route detection for running and cycling
|
||||||
|
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||||
|
detect_route.delay(activity_id, user_id)
|
||||||
return {"activity_id": activity_id, "status": "ok"}
|
return {"activity_id": activity_id, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -176,140 +194,46 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
def parse_wellness_fit(file_path: str, user_id: int):
|
def parse_wellness_fit(file_path: str, user_id: int):
|
||||||
"""
|
"""
|
||||||
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
||||||
These files contain resting HR, HRV, sleep, stress, SpO2 etc.
|
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
||||||
"""
|
"""
|
||||||
import fitparse
|
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||||
from app.core.database import SyncSessionLocal
|
from app.core.database import SyncSessionLocal
|
||||||
from app.models.user import HealthMetric
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from datetime import datetime, timezone, date
|
|
||||||
|
|
||||||
try:
|
result = _parse(file_path)
|
||||||
fit = fitparse.FitFile(file_path)
|
if result.get("error"):
|
||||||
except Exception as e:
|
return {"status": "error", "error": result["error"], "file": file_path}
|
||||||
return {"status": "error", "error": str(e)}
|
|
||||||
|
|
||||||
# Collect all monitoring/daily summary records keyed by date
|
days = result.get("days", {})
|
||||||
daily = {} # date -> dict of fields
|
if not days:
|
||||||
|
|
||||||
def get_or_create_day(d: date) -> dict:
|
|
||||||
if d not in daily:
|
|
||||||
daily[d] = {}
|
|
||||||
return daily[d]
|
|
||||||
|
|
||||||
for record in fit.get_messages():
|
|
||||||
name = record.name
|
|
||||||
fields = {f.name: f.value for f in record if f.value is not None}
|
|
||||||
|
|
||||||
if name == "monitoring_info":
|
|
||||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
|
||||||
if ts:
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if d:
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
day.setdefault("resting_hr", fields.get("resting_heart_rate"))
|
|
||||||
|
|
||||||
elif name == "monitoring":
|
|
||||||
ts = fields.get("timestamp") or fields.get("local_timestamp")
|
|
||||||
if not ts:
|
|
||||||
continue
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if not d:
|
|
||||||
continue
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
# Accumulate steps (they're stored as increments)
|
|
||||||
if "steps" in fields:
|
|
||||||
day["steps"] = day.get("steps", 0) + int(fields["steps"])
|
|
||||||
if "heart_rate" in fields:
|
|
||||||
hrs = day.setdefault("heart_rates", [])
|
|
||||||
hrs.append(int(fields["heart_rate"]))
|
|
||||||
if "stress_level_value" in fields:
|
|
||||||
stresses = day.setdefault("stress_values", [])
|
|
||||||
stresses.append(int(fields["stress_level_value"]))
|
|
||||||
|
|
||||||
elif name == "hrv_status_summary":
|
|
||||||
ts = fields.get("timestamp")
|
|
||||||
if ts:
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if d:
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
day.setdefault("hrv_nightly_avg", fields.get("weekly_average"))
|
|
||||||
day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high"))
|
|
||||||
day.setdefault("hrv_status", str(fields.get("hrv_status", "")))
|
|
||||||
|
|
||||||
elif name == "sleep_level":
|
|
||||||
ts = fields.get("timestamp")
|
|
||||||
if ts:
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if d:
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
levels = day.setdefault("sleep_levels", [])
|
|
||||||
levels.append(fields.get("sleep_level"))
|
|
||||||
|
|
||||||
elif name == "stress":
|
|
||||||
ts = fields.get("timestamp")
|
|
||||||
if ts:
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if d:
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
if "stress_level_value" in fields:
|
|
||||||
stresses = day.setdefault("stress_values", [])
|
|
||||||
stresses.append(int(fields["stress_level_value"]))
|
|
||||||
|
|
||||||
elif name == "spo2_data":
|
|
||||||
ts = fields.get("timestamp")
|
|
||||||
if ts:
|
|
||||||
d = ts.date() if hasattr(ts, "date") else None
|
|
||||||
if d:
|
|
||||||
day = get_or_create_day(d)
|
|
||||||
readings = day.setdefault("spo2_readings", [])
|
|
||||||
if "spo2_percent" in fields:
|
|
||||||
readings.append(fields["spo2_percent"])
|
|
||||||
|
|
||||||
if not daily:
|
|
||||||
return {"status": "no_data", "file": file_path}
|
return {"status": "no_data", "file": file_path}
|
||||||
|
|
||||||
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
|
|
||||||
with SyncSessionLocal() as db:
|
with SyncSessionLocal() as db:
|
||||||
for day_date, data in daily.items():
|
for day_date, data in days.items():
|
||||||
hrs = data.pop("heart_rates", [])
|
|
||||||
stresses = data.pop("stress_values", [])
|
|
||||||
spo2s = data.pop("spo2_readings", [])
|
|
||||||
sleep_levels = data.pop("sleep_levels", [])
|
|
||||||
|
|
||||||
resting_hr = data.get("resting_hr")
|
|
||||||
avg_hr = (sum(hrs) / len(hrs)) if hrs else None
|
|
||||||
avg_stress = (sum(stresses) / len(stresses)) if stresses else None
|
|
||||||
spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None
|
|
||||||
|
|
||||||
# Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem
|
|
||||||
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None
|
|
||||||
sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None
|
|
||||||
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None
|
|
||||||
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None
|
|
||||||
sleep_duration_s = (
|
|
||||||
(sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0)
|
|
||||||
) or None
|
|
||||||
|
|
||||||
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
# ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data
|
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress,
|
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
||||||
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps,
|
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||||
|
steps, floors_climbed, active_calories, total_calories,
|
||||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
||||||
VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress,
|
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||||
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps,
|
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||||
|
:steps, :floors, :active_cal, :total_cal,
|
||||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
||||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||||
|
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||||
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||||
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||||
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_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_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||||
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||||
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||||
|
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||||
|
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||||
|
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||||
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
||||||
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||||
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||||
@@ -317,20 +241,109 @@ def parse_wellness_fit(file_path: str, user_id: int):
|
|||||||
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
||||||
"""), {
|
"""), {
|
||||||
"user_id": user_id, "date": date_dt,
|
"user_id": user_id, "date": date_dt,
|
||||||
"resting_hr": resting_hr, "avg_hr": avg_hr,
|
"resting_hr": data.get("resting_hr"),
|
||||||
"avg_stress": avg_stress, "spo2_avg": spo2_avg,
|
"avg_hr": data.get("avg_hr_day"),
|
||||||
|
"max_hr": data.get("max_hr_day"),
|
||||||
|
"avg_stress": data.get("avg_stress"),
|
||||||
|
"spo2_avg": data.get("spo2_avg"),
|
||||||
"hrv_avg": data.get("hrv_nightly_avg"),
|
"hrv_avg": data.get("hrv_nightly_avg"),
|
||||||
"hrv_high": data.get("hrv_5min_high"),
|
"hrv_high": data.get("hrv_5min_high"),
|
||||||
"hrv_status": data.get("hrv_status"),
|
"hrv_status": data.get("hrv_status"),
|
||||||
"steps": data.get("steps"),
|
"steps": data.get("steps"),
|
||||||
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s,
|
"floors": data.get("floors_climbed"),
|
||||||
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s,
|
"active_cal": data.get("active_calories"),
|
||||||
"sleep_awake": sleep_awake_s,
|
"total_cal": data.get("total_calories"),
|
||||||
|
"sleep_dur": data.get("sleep_duration_s"),
|
||||||
|
"sleep_deep": data.get("sleep_deep_s"),
|
||||||
|
"sleep_light": data.get("sleep_light_s"),
|
||||||
|
"sleep_rem": data.get("sleep_rem_s"),
|
||||||
|
"sleep_awake": data.get("sleep_awake_s"),
|
||||||
})
|
})
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"status": "ok", "days_processed": len(daily), "file": file_path}
|
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||||
|
|
||||||
|
@celery_app.task(name="detect_route")
|
||||||
|
def detect_route(activity_id: int, user_id: int):
|
||||||
|
"""
|
||||||
|
After importing an activity, check if it matches any existing named routes.
|
||||||
|
If two+ unassigned activities match each other, auto-create a named route.
|
||||||
|
"""
|
||||||
|
from app.services.route_matcher import routes_are_similar
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import Activity, NamedRoute
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
# Get the new activity
|
||||||
|
new_act = db.execute(
|
||||||
|
select(Activity).where(Activity.id == activity_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not new_act or not new_act.polyline:
|
||||||
|
return {"status": "no_polyline"}
|
||||||
|
|
||||||
|
# Already assigned to a route?
|
||||||
|
if new_act.named_route_id:
|
||||||
|
return {"status": "already_assigned"}
|
||||||
|
|
||||||
|
# Check against existing named routes first
|
||||||
|
routes = db.execute(
|
||||||
|
select(NamedRoute).where(
|
||||||
|
NamedRoute.user_id == user_id,
|
||||||
|
NamedRoute.sport_type == new_act.sport_type,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
if route.reference_polyline and routes_are_similar(
|
||||||
|
new_act.polyline, route.reference_polyline,
|
||||||
|
new_act.bounding_box, route.bounding_box,
|
||||||
|
):
|
||||||
|
new_act.named_route_id = route.id
|
||||||
|
db.commit()
|
||||||
|
return {"status": "matched_existing", "route_id": route.id}
|
||||||
|
|
||||||
|
# No existing route matched - check unassigned activities for a match
|
||||||
|
candidates = db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.user_id == user_id,
|
||||||
|
Activity.sport_type == new_act.sport_type,
|
||||||
|
Activity.named_route_id == None,
|
||||||
|
Activity.id != activity_id,
|
||||||
|
Activity.polyline != None,
|
||||||
|
# Within 20% distance
|
||||||
|
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
||||||
|
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if routes_are_similar(
|
||||||
|
new_act.polyline, candidate.polyline,
|
||||||
|
new_act.bounding_box, candidate.bounding_box,
|
||||||
|
):
|
||||||
|
# Auto-create a route from the older activity
|
||||||
|
older = candidate if candidate.start_time < new_act.start_time else new_act
|
||||||
|
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||||
|
|
||||||
|
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
|
||||||
|
new_route = NamedRoute(
|
||||||
|
user_id=user_id,
|
||||||
|
name=route_name,
|
||||||
|
sport_type=older.sport_type,
|
||||||
|
reference_polyline=older.polyline,
|
||||||
|
bounding_box=older.bounding_box,
|
||||||
|
distance_m=older.distance_m,
|
||||||
|
auto_detected=True,
|
||||||
|
)
|
||||||
|
db.add(new_route)
|
||||||
|
db.flush()
|
||||||
|
older.named_route_id = new_route.id
|
||||||
|
newer.named_route_id = new_route.id
|
||||||
|
db.commit()
|
||||||
|
return {"status": "auto_created", "route_id": new_route.id}
|
||||||
|
|
||||||
|
return {"status": "no_match"}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="compute_personal_records")
|
@celery_app.task(name="compute_personal_records")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ python-multipart==0.0.9
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
redis[hiredis]==5.0.4
|
redis[hiredis]==5.0.4
|
||||||
celery[redis]==5.4.0
|
celery[redis]==5.4.0
|
||||||
|
garmin-fit-sdk==21.195.0
|
||||||
fitparse==1.2.0
|
fitparse==1.2.0
|
||||||
gpxpy==1.6.2
|
gpxpy==1.6.2
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_URL=/api
|
ARG VITE_API_URL=/api
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"zustand": "^4.5.2",
|
"zustand": "^4.5.2",
|
||||||
"@tanstack/react-query": "^5.40.0",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"react-dropzone": "^14.2.3"
|
"react-dropzone": "^14.2.3",
|
||||||
|
"@polyline-codec/core": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import HealthPage from './pages/HealthPage'
|
|||||||
import RoutesPage from './pages/RoutesPage'
|
import RoutesPage from './pages/RoutesPage'
|
||||||
import RecordsPage from './pages/RecordsPage'
|
import RecordsPage from './pages/RecordsPage'
|
||||||
import UploadPage from './pages/UploadPage'
|
import UploadPage from './pages/UploadPage'
|
||||||
|
import ProfilePage from './pages/ProfilePage'
|
||||||
|
|
||||||
function RequireAuth({ children }) {
|
function RequireAuth({ children }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
@@ -24,7 +25,6 @@ export default function App() {
|
|||||||
if (token) fetchUser()
|
if (token) fetchUser()
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
// Handle token from PocketID callback URL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const urlToken = params.get('token')
|
const urlToken = params.get('token')
|
||||||
@@ -38,14 +38,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<Layout />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="activities" element={<ActivitiesPage />} />
|
<Route path="activities" element={<ActivitiesPage />} />
|
||||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||||
@@ -53,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { sportColor } from '../../utils/format'
|
import { sportColor } from '../../utils/format'
|
||||||
|
|
||||||
// Fix Leaflet default icon issue with bundlers
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl
|
delete L.Icon.Default.prototype._getIconUrl
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
@@ -10,103 +9,87 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const TILE_LAYERS = {
|
||||||
|
dark: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
},
|
||||||
|
street: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
function decodePolyline(encoded) {
|
function decodePolyline(encoded) {
|
||||||
// Simple polyline decoder
|
|
||||||
const coords = []
|
const coords = []
|
||||||
let index = 0, lat = 0, lng = 0
|
let index = 0, lat = 0, lng = 0
|
||||||
|
|
||||||
while (index < encoded.length) {
|
while (index < encoded.length) {
|
||||||
let b, shift = 0, result = 0
|
let b, shift = 0, result = 0
|
||||||
do {
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
b = encoded.charCodeAt(index++) - 63
|
|
||||||
result |= (b & 0x1f) << shift
|
|
||||||
shift += 5
|
|
||||||
} while (b >= 0x20)
|
|
||||||
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
|
||||||
shift = 0; result = 0
|
shift = 0; result = 0
|
||||||
do {
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
b = encoded.charCodeAt(index++) - 63
|
|
||||||
result |= (b & 0x1f) << shift
|
|
||||||
shift += 5
|
|
||||||
} while (b >= 0x20)
|
|
||||||
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
|
||||||
coords.push([lat / 1e5, lng / 1e5])
|
coords.push([lat / 1e5, lng / 1e5])
|
||||||
}
|
}
|
||||||
return coords
|
return coords
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) {
|
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
||||||
const mapRef = useRef(null)
|
const mapRef = useRef(null)
|
||||||
const mapInstanceRef = useRef(null)
|
const mapInstanceRef = useRef(null)
|
||||||
const markerRef = useRef(null)
|
const markerRef = useRef(null)
|
||||||
const trackRef = useRef(null)
|
const trackRef = useRef(null)
|
||||||
|
const tileLayerRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return
|
if (!mapRef.current || mapInstanceRef.current) return
|
||||||
|
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
|
||||||
mapInstanceRef.current = L.map(mapRef.current, {
|
const tile = TILE_LAYERS['dark']
|
||||||
zoomControl: true,
|
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||||
attributionControl: true,
|
.addTo(mapInstanceRef.current)
|
||||||
})
|
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
|
||||||
|
|
||||||
// Use CartoDB dark tiles (no API key needed)
|
|
||||||
L.tileLayer(
|
|
||||||
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
||||||
{
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
||||||
maxZoom: 19,
|
|
||||||
}
|
|
||||||
).addTo(mapInstanceRef.current)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mapInstanceRef.current?.remove()
|
|
||||||
mapInstanceRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Draw route when polyline changes
|
// Switch tile layer when mapType changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current) return
|
||||||
|
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
|
||||||
|
if (tileLayerRef.current) {
|
||||||
|
tileLayerRef.current.remove()
|
||||||
|
}
|
||||||
|
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||||
|
.addTo(mapInstanceRef.current)
|
||||||
|
}, [mapType])
|
||||||
|
|
||||||
|
// Draw route
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current || !polyline) return
|
if (!mapInstanceRef.current || !polyline) return
|
||||||
|
if (trackRef.current) trackRef.current.remove()
|
||||||
if (trackRef.current) {
|
|
||||||
trackRef.current.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const coords = decodePolyline(polyline)
|
const coords = decodePolyline(polyline)
|
||||||
if (!coords.length) return
|
if (!coords.length) return
|
||||||
|
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
|
||||||
trackRef.current = L.polyline(coords, {
|
.addTo(mapInstanceRef.current)
|
||||||
color: sportColor(sportType),
|
|
||||||
weight: 3,
|
|
||||||
opacity: 0.9,
|
|
||||||
}).addTo(mapInstanceRef.current)
|
|
||||||
|
|
||||||
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
||||||
|
|
||||||
// Start/end markers
|
|
||||||
if (coords.length > 0) {
|
if (coords.length > 0) {
|
||||||
const startIcon = L.divIcon({
|
const dot = (color) => L.divIcon({
|
||||||
html: '<div style="width:12px;height:12px;background:#22c55e;border:2px solid white;border-radius:50%"></div>',
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||||
})
|
})
|
||||||
const endIcon = L.divIcon({
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
|
||||||
html: '<div style="width:12px;height:12px;background:#ef4444;border:2px solid white;border-radius:50%"></div>',
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
|
||||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
|
||||||
})
|
|
||||||
L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current)
|
|
||||||
L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current)
|
|
||||||
}
|
}
|
||||||
}, [polyline, sportType])
|
}, [polyline, sportType])
|
||||||
|
|
||||||
// Move position marker when timeline is hovered
|
// Position marker on timeline hover
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
||||||
|
|
||||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||||
if (!point?.latitude || !point?.longitude) return
|
if (!point?.latitude || !point?.longitude) return
|
||||||
|
|
||||||
if (markerRef.current) {
|
if (markerRef.current) {
|
||||||
markerRef.current.setLatLng([point.latitude, point.longitude])
|
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||||
} else {
|
} else {
|
||||||
@@ -114,8 +97,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
|||||||
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||||
})
|
})
|
||||||
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
|
||||||
.addTo(mapInstanceRef.current)
|
|
||||||
}
|
}
|
||||||
}, [hoveredDistance, dataPoints])
|
}, [hoveredDistance, dataPoints])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format'
|
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
export default function LapTable({ laps, sportType }) {
|
export default function LapTable({ laps, sportType }) {
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +26,7 @@ export default function LapTable({ laps, sportType }) {
|
|||||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'}
|
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right text-gray-400">
|
<td className="py-2 text-right text-gray-400">
|
||||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMemo, useCallback } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer, ReferenceLine,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { formatDuration, formatPace } from '../../utils/format'
|
import { formatPace, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
function downsample(points, maxPoints = 500) {
|
function downsample(points, maxPoints = 500) {
|
||||||
if (points.length <= maxPoints) return points
|
if (points.length <= maxPoints) return points
|
||||||
@@ -17,7 +17,7 @@ function buildChartData(dataPoints, activeMetrics) {
|
|||||||
.map(p => {
|
.map(p => {
|
||||||
const row = { distance_m: p.distance_m ?? 0 }
|
const row = { distance_m: p.distance_m ?? 0 }
|
||||||
for (const key of activeMetrics) {
|
for (const key of activeMetrics) {
|
||||||
row[key] = p[key] ?? null
|
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
|
||||||
}
|
}
|
||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
@@ -25,9 +25,7 @@ function buildChartData(dataPoints, activeMetrics) {
|
|||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
|
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
|
||||||
if (!active || !payload?.length) return null
|
if (!active || !payload?.length) return null
|
||||||
|
|
||||||
if (onHover) onHover(label)
|
if (onHover) onHover(label)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||||
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
|
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
|
||||||
@@ -37,7 +35,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
|
|||||||
let display = entry.value.toFixed(1)
|
let display = entry.value.toFixed(1)
|
||||||
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
|
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
|
||||||
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||||
else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm`
|
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
||||||
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
|
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
|
||||||
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
||||||
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
|
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
|
||||||
@@ -61,7 +59,6 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
|
|
||||||
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
|
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
|
||||||
|
|
||||||
// Build per-metric Y-axis domains
|
|
||||||
const domains = useMemo(() => {
|
const domains = useMemo(() => {
|
||||||
const result = {}
|
const result = {}
|
||||||
for (const m of activeMetricConfigs) {
|
for (const m of activeMetricConfigs) {
|
||||||
@@ -70,6 +67,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
const min = Math.min(...vals)
|
const min = Math.min(...vals)
|
||||||
const max = Math.max(...vals)
|
const max = Math.max(...vals)
|
||||||
const pad = (max - min) * 0.1 || 1
|
const pad = (max - min) * 0.1 || 1
|
||||||
|
// For elevation, don't start from 0 - show actual range
|
||||||
result[m.key] = [min - pad, max + pad]
|
result[m.key] = [min - pad, max + pad]
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -87,18 +85,14 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{activeMetricConfigs.map((metric, idx) => {
|
{activeMetricConfigs.map((metric, idx) => {
|
||||||
const domain = domains[metric.key] || ['auto', 'auto']
|
const domain = domains[metric.key] || ['auto', 'auto']
|
||||||
const data = chartData.filter(p => p[metric.key] != null)
|
const hasData = chartData.some(p => p[metric.key] != null)
|
||||||
if (!data.length) return null
|
if (!hasData) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={metric.key}>
|
<div key={metric.key}>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span style={{ color: metric.color }} className="text-xs font-medium">
|
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
|
||||||
{metric.label}
|
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||||
</span>
|
|
||||||
{metric.unit && (
|
|
||||||
<span className="text-xs text-gray-600">({metric.unit})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
||||||
@@ -118,20 +112,19 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={36}
|
width={40}
|
||||||
tickFormatter={v => {
|
tickFormatter={v => {
|
||||||
if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}`
|
if (metric.key === 'speed_ms') {
|
||||||
|
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||||
|
const spm = 1000 / v
|
||||||
|
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
|
||||||
return Math.round(v)
|
return Math.round(v)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
||||||
<CustomTooltip
|
|
||||||
metrics={metrics}
|
|
||||||
sportType={sportType}
|
|
||||||
onHover={onHoverDistance}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
@@ -148,8 +141,6 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Shared distance axis label */}
|
|
||||||
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const nav = [
|
|||||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||||
|
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -21,51 +22,38 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-gray-950">
|
<div className="flex h-screen overflow-hidden bg-gray-950">
|
||||||
{/* Sidebar */}
|
|
||||||
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
{/* Logo */}
|
|
||||||
<div className="px-4 py-5 border-b border-gray-800">
|
<div className="px-4 py-5 border-b border-gray-800">
|
||||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||||
<span className="text-blue-400">Mile</span>Vault
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
</h1>
|
</h1>
|
||||||
{user && (
|
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
|
||||||
<p className="text-xs text-gray-500 mt-0.5">@{user.username}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex-1 py-4 overflow-y-auto">
|
<nav className="flex-1 py-4 overflow-y-auto">
|
||||||
{nav.map(({ to, label, icon, exact }) => (
|
{nav.map(({ to, label, icon, exact }) => (
|
||||||
<NavLink
|
<NavLink key={to} to={to} end={exact}
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={exact}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
|
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
|
||||||
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
||||||
}`
|
}`
|
||||||
}
|
}>
|
||||||
>
|
|
||||||
<span>{icon}</span>
|
<span>{icon}</span>
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-4 py-4 border-t border-gray-800">
|
<div className="px-4 py-4 border-t border-gray-800">
|
||||||
<button
|
<button onClick={handleLogout}
|
||||||
onClick={handleLogout}
|
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
formatDate, sportIcon, sportColor,
|
formatDate, sportIcon, sportColor,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking']
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
export default function ActivitiesPage() {
|
export default function ActivitiesPage() {
|
||||||
const [sport, setSport] = useState('all')
|
const [sport, setSport] = useState('all')
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import LapTable from '../components/activity/LapTable'
|
|||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatElevation,
|
formatDuration, formatDistance, formatPace, formatElevation,
|
||||||
formatHeartRate, formatDateTime, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
const METRICS = [
|
const METRICS = [
|
||||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||||
{ key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' },
|
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||||
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
|
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
|
||||||
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
|
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
|
||||||
]
|
]
|
||||||
@@ -25,6 +25,8 @@ export default function ActivityDetailPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||||
|
const [mapHeight, setMapHeight] = useState(420)
|
||||||
|
const [mapType, setMapType] = useState('dark')
|
||||||
|
|
||||||
const { data: activity, isLoading } = useQuery({
|
const { data: activity, isLoading } = useQuery({
|
||||||
queryKey: ['activity', id],
|
queryKey: ['activity', id],
|
||||||
@@ -49,19 +51,21 @@ export default function ActivityDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
// Check which metrics have actual data
|
||||||
return (
|
const availableMetrics = useMemo(() => {
|
||||||
<div className="flex items-center justify-center h-full">
|
if (!dataPoints?.length) return new Set()
|
||||||
<div className="text-gray-500">Loading activity…</div>
|
return new Set(
|
||||||
</div>
|
METRICS
|
||||||
|
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
|
||||||
|
.map(m => m.key)
|
||||||
)
|
)
|
||||||
|
}, [dataPoints])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity…</div></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activity) return null
|
if (!activity) return null
|
||||||
|
|
||||||
const speed = activity.avg_speed_ms
|
|
||||||
const pace = formatPace(speed, activity.sport_type)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -75,12 +79,12 @@ export default function ActivityDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary stats */}
|
{/* Primary stats */}
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||||
<StatCard label="Pace" value={pace} />
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||||
<StatCard label="Elevation" value={`↑ ${formatElevation(activity.elevation_gain_m)}`} />
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -88,37 +92,71 @@ export default function ActivityDetailPage() {
|
|||||||
{/* Secondary stats */}
|
{/* Secondary stats */}
|
||||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||||
<StatCard label="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} />
|
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||||
|
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||||
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
||||||
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
||||||
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
|
|
||||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map with controls */}
|
||||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}>
|
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||||
|
{/* Map toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Map style:</span>
|
||||||
|
{['dark', 'street', 'satellite'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setMapType(t)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||||
|
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Height:</span>
|
||||||
|
{[280, 420, 560].map(h => (
|
||||||
|
<button
|
||||||
|
key={h}
|
||||||
|
onClick={() => setMapHeight(h)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||||
|
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: mapHeight }}>
|
||||||
<ActivityMap
|
<ActivityMap
|
||||||
polyline={activity.polyline}
|
polyline={activity.polyline}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
hoveredDistance={hoveredDistance}
|
hoveredDistance={hoveredDistance}
|
||||||
sportType={activity.sport_type}
|
sportType={activity.sport_type}
|
||||||
|
mapType={mapType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* HR Zones */}
|
{/* HR Zones */}
|
||||||
{activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && (
|
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||||
<HRZoneBar zones={activity.hr_zones} />
|
<HRZoneBar zones={activity.hr_zones} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metric selector */}
|
{/* Metric timeline */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{METRICS.map(({ key, label, color }) => (
|
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => toggleMetric(key)}
|
onClick={() => toggleMetric(key)}
|
||||||
@@ -134,15 +172,16 @@ export default function ActivityDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{dataPoints && dataPoints.length > 0 ? (
|
||||||
{dataPoints && (
|
|
||||||
<MetricTimeline
|
<MetricTimeline
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
activeMetrics={activeMetrics}
|
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||||
metrics={METRICS}
|
metrics={METRICS}
|
||||||
onHoverDistance={setHoveredDistance}
|
onHoverDistance={setHoveredDistance}
|
||||||
sportType={activity.sport_type}
|
sportType={activity.sport_type}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { format, subDays, startOfWeek } from 'date-fns'
|
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import {
|
import {
|
||||||
@@ -10,18 +10,29 @@ import {
|
|||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
if (!activities?.length) return null
|
if (!activities?.length) return (
|
||||||
|
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||||
|
)
|
||||||
|
|
||||||
// Build last 8 weeks of distance data
|
// Build last 8 weeks in chronological order
|
||||||
const weeks = {}
|
const now = new Date()
|
||||||
activities.forEach(a => {
|
const weeks = eachWeekOfInterval({
|
||||||
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
|
start: subWeeks(startOfWeek(now), 7),
|
||||||
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
|
end: startOfWeek(now),
|
||||||
weeks[week].km += (a.distance_m || 0) / 1000
|
|
||||||
weeks[week].runs++
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = Object.values(weeks).slice(-8)
|
const data = weeks.map(weekStart => {
|
||||||
|
const weekKey = format(weekStart, 'MMM d')
|
||||||
|
const weekEnd = new Date(weekStart)
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||||
|
const km = activities
|
||||||
|
.filter(a => {
|
||||||
|
const t = new Date(a.start_time)
|
||||||
|
return t >= weekStart && t < weekEnd
|
||||||
|
})
|
||||||
|
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||||
|
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
@@ -30,10 +41,8 @@ function WeeklyChart({ activities }) {
|
|||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||||
<Tooltip
|
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
||||||
formatter={(v, name) => [`${v.toFixed(1)} km`, 'Distance']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -50,10 +59,7 @@ export default function DashboardPage() {
|
|||||||
queryKey: ['activities-all-chart'],
|
queryKey: ['activities-all-chart'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/activities/', {
|
api.get('/activities/', {
|
||||||
params: {
|
params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
|
||||||
per_page: 100,
|
|
||||||
from_date: subDays(new Date(), 60).toISOString(),
|
|
||||||
},
|
|
||||||
}).then(r => r.data),
|
}).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,64 +74,45 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const latest = healthSummary?.latest
|
const latest = healthSummary?.latest
|
||||||
const totalActivities = recentActivities?.length ?? 0
|
|
||||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
<Link
|
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||||
to="/upload"
|
|
||||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
|
||||||
>
|
|
||||||
+ Import data
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top stats */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<StatCard label="Activities (10)" value={totalActivities} />
|
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
||||||
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" />
|
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
||||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Weekly distance chart */}
|
|
||||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
||||||
<WeeklyChart activities={allActivities} />
|
<WeeklyChart activities={allActivities} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health snapshot */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||||
{latest ? (
|
{latest ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between text-sm">
|
{[
|
||||||
<span className="text-gray-500">HRV</span>
|
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
|
||||||
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span>
|
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
|
||||||
|
['Steps', latest.steps?.toLocaleString() ?? '--'],
|
||||||
|
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
|
||||||
|
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="text-white">{val}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
))}
|
||||||
<span className="text-gray-500">Sleep score</span>
|
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard →</Link>
|
||||||
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Steps</span>
|
|
||||||
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">VO2 Max</span>
|
|
||||||
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-500">Stress</span>
|
|
||||||
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
|
|
||||||
</div>
|
|
||||||
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
|
|
||||||
View full health dashboard →
|
|
||||||
</Link>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
|
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
|
||||||
@@ -141,29 +128,17 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{recentActivities?.slice(0, 5).map(activity => (
|
{recentActivities?.slice(0, 5).map(activity => (
|
||||||
<Link
|
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||||
key={activity.id}
|
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
|
||||||
to={`/activities/${activity.id}`}
|
|
||||||
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-sm text-right">
|
<div className="flex gap-4 text-sm text-right">
|
||||||
<div>
|
<div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
|
||||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
<div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
|
||||||
<p className="text-xs text-gray-600">dist</p>
|
<div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-200">{formatDuration(activity.duration_s)}</p>
|
|
||||||
<p className="text-xs text-gray-600">time</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
|
||||||
<p className="text-xs text-gray-600">HR</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -175,7 +150,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PRs snapshot */}
|
|
||||||
{records?.length > 0 && (
|
{records?.length > 0 && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, AreaChart, Area, BarChart, Bar,
|
LineChart, Line, AreaChart, Area, BarChart, Bar,
|
||||||
@@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard'
|
|||||||
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
|
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
|
{ label: '1W', days: 7 },
|
||||||
{ label: '2W', days: 14 },
|
{ label: '2W', days: 14 },
|
||||||
{ label: '1M', days: 30 },
|
{ label: '1M', days: 30 },
|
||||||
{ label: '3M', days: 90 },
|
{ label: '3M', days: 90 },
|
||||||
@@ -17,7 +18,13 @@ const RANGES = [
|
|||||||
{ label: '1Y', days: 365 },
|
{ label: '1Y', days: 365 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
|
||||||
|
|
||||||
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
||||||
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
|
if (!vals.length) return (
|
||||||
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
|
||||||
@@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
dataKey="date"
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
axisLine={false}
|
tickFormatter={formatter} />
|
||||||
tickLine={false}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
tickFormatter={d => format(new Date(d), 'MMM d')}
|
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||||
interval="preserveStartEnd"
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
/>
|
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={32}
|
|
||||||
tickFormatter={formatter}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
|
||||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
|
||||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey={dataKey}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill={`url(#grad-${dataKey})`}
|
|
||||||
dot={false}
|
|
||||||
connectNulls={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)
|
)
|
||||||
@@ -71,7 +56,8 @@ function SleepChart({ data }) {
|
|||||||
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
||||||
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
||||||
}))
|
}))
|
||||||
|
const hasData = chartData.some(d => d.deep || d.rem || d.light)
|
||||||
|
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
||||||
@@ -80,9 +66,8 @@ function SleepChart({ data }) {
|
|||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
||||||
tickFormatter={v => `${v}h`} />
|
tickFormatter={v => `${v}h`} />
|
||||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||||
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
|
|
||||||
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||||
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
||||||
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
|
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
|
||||||
@@ -92,21 +77,22 @@ function SleepChart({ data }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HealthPage() {
|
export default function HealthPage() {
|
||||||
const [rangeDays, setRangeDays] = useState(30)
|
const [rangeDays, setRangeDays] = useState(7) // default 1 week
|
||||||
|
|
||||||
const fromDate = subDays(new Date(), rangeDays).toISOString()
|
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
|
||||||
|
|
||||||
const { data: summary } = useQuery({
|
const { data: summary } = useQuery({
|
||||||
queryKey: ['health-summary'],
|
queryKey: ['health-summary'],
|
||||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: metrics } = useQuery({
|
const { data: metrics, isLoading } = useQuery({
|
||||||
queryKey: ['health-metrics', rangeDays],
|
queryKey: ['health-metrics', rangeDays],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/health-metrics/', {
|
api.get('/health-metrics/', {
|
||||||
params: { from_date: fromDate, limit: rangeDays },
|
params: { from_date: fromDate, limit: rangeDays + 1 },
|
||||||
}).then(r => r.data.reverse()),
|
}).then(r => r.data.slice().reverse()), // oldest first for charts
|
||||||
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const latest = summary?.latest
|
const latest = summary?.latest
|
||||||
@@ -118,132 +104,75 @@ export default function HealthPage() {
|
|||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<StatCard
|
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
|
||||||
label="Resting HR"
|
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
|
||||||
value={formatHeartRate(latest?.resting_hr)}
|
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
||||||
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined}
|
sub={latest?.hrv_status || undefined} />
|
||||||
accent="red"
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
|
||||||
/>
|
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
|
||||||
<StatCard
|
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
|
||||||
label="HRV"
|
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
|
||||||
value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
||||||
sub={latest?.hrv_status || undefined}
|
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
|
||||||
/>
|
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
||||||
<StatCard
|
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
|
||||||
label="Sleep"
|
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
|
||||||
value={formatSleep(latest?.sleep_duration_s)}
|
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
|
||||||
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Weight"
|
|
||||||
value={formatWeight(latest?.weight_kg)}
|
|
||||||
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="VO2 Max"
|
|
||||||
value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
|
||||||
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined}
|
|
||||||
accent="blue"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Steps"
|
|
||||||
value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
|
||||||
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Avg Stress"
|
|
||||||
value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="SpO2"
|
|
||||||
value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Range selector */}
|
{/* Range selector */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{RANGES.map(({ label, days }) => (
|
{RANGES.map(({ label, days }) => (
|
||||||
<button
|
<button key={label} onClick={() => setRangeDays(days)}
|
||||||
key={label}
|
|
||||||
onClick={() => setRangeDays(days)}
|
|
||||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
rangeDays === days
|
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
? 'bg-blue-600 border-blue-600 text-white'
|
}`}>
|
||||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{metrics && metrics.length > 0 ? (
|
{isLoading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
) : metrics && metrics.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
{/* Resting HR */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||||
<MetricChart
|
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||||
data={metrics}
|
formatter={v => `${Math.round(v)} bpm`} />
|
||||||
dataKey="resting_hr"
|
|
||||||
color="#f43f5e"
|
|
||||||
formatter={v => `${Math.round(v)} bpm`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HRV */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
||||||
<MetricChart
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
data={metrics}
|
formatter={v => `${Math.round(v)} ms`} />
|
||||||
dataKey="hrv_nightly_avg"
|
|
||||||
color="#8b5cf6"
|
|
||||||
formatter={v => `${Math.round(v)} ms`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sleep */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
|
||||||
<SleepChart data={metrics} />
|
<SleepChart data={metrics} />
|
||||||
<div className="flex gap-4 mt-2">
|
<div className="flex gap-4 mt-2">
|
||||||
{[
|
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
|
||||||
{ label: 'Deep', color: '#6366f1' },
|
<div key={l} className="flex items-center gap-1.5">
|
||||||
{ label: 'REM', color: '#8b5cf6' },
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||||
{ label: 'Light', color: '#a78bfa' },
|
<span className="text-xs text-gray-400">{l}</span>
|
||||||
{ label: 'Awake', color: '#374151' },
|
|
||||||
].map(({ label, color }) => (
|
|
||||||
<div key={label} className="flex items-center gap-1.5">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
|
||||||
<span className="text-xs text-gray-400">{label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weight */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
||||||
<MetricChart
|
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
|
||||||
data={metrics}
|
formatter={v => `${v.toFixed(1)} kg`} />
|
||||||
dataKey="weight_kg"
|
|
||||||
color="#34d399"
|
|
||||||
formatter={v => `${v.toFixed(1)} kg`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VO2 Max */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||||
<MetricChart
|
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
||||||
data={metrics}
|
|
||||||
dataKey="vo2max"
|
|
||||||
color="#3b82f6"
|
|
||||||
formatter={v => v.toFixed(1)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
@@ -253,18 +182,30 @@ export default function HealthPage() {
|
|||||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
||||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
|
||||||
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||||
|
formatter={v => `${Math.round(v)} bpm`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||||
|
formatter={v => Math.round(v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-16 text-gray-600">
|
<div className="text-center py-16 text-gray-600">
|
||||||
<p className="text-4xl mb-3">📊</p>
|
<p className="text-4xl mb-3">📊</p>
|
||||||
<p className="text-lg">No health data yet</p>
|
<p className="text-lg">No health data for this period</p>
|
||||||
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p>
|
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||||
|
return (
|
||||||
|
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button onClick={onClick} disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{loading ? 'Saving…' : label}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-green-400 text-sm">✓ Saved</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: pocketidConfig } = useQuery({
|
||||||
|
queryKey: ['pocketid-config'],
|
||||||
|
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
|
||||||
|
enabled: !!user?.is_admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
// HR / measurements form
|
||||||
|
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
|
||||||
|
const [hrSaved, setHrSaved] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) setHrForm({
|
||||||
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
|
resting_heart_rate: profile.resting_heart_rate || '',
|
||||||
|
birth_year: profile.birth_year || '',
|
||||||
|
height_cm: profile.height_cm || '',
|
||||||
|
})
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
const updateProfile = useMutation({
|
||||||
|
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Weight log
|
||||||
|
const { data: weightLog } = useQuery({
|
||||||
|
queryKey: ['weight-log'],
|
||||||
|
queryFn: () => api.get('/profile/weight').then(r => r.data),
|
||||||
|
})
|
||||||
|
const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
|
||||||
|
const [weightSaved, setWeightSaved] = useState(false)
|
||||||
|
const addWeight = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
|
||||||
|
})
|
||||||
|
const deleteWeight = useMutation({
|
||||||
|
mutationFn: id => api.delete(`/profile/weight/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||||
|
const [pwError, setPwError] = useState('')
|
||||||
|
const [pwSaved, setPwSaved] = useState(false)
|
||||||
|
const changePassword = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
|
||||||
|
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
|
||||||
|
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// PocketID config
|
||||||
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||||
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
||||||
|
}, [pocketidConfig])
|
||||||
|
const savePocketID = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||||
|
|
||||||
|
{/* HR & Measurements */}
|
||||||
|
<Section title="Heart Rate & Measurements">
|
||||||
|
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
|
||||||
|
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
|
||||||
|
{effectiveMaxHr && (
|
||||||
|
<div className="mt-2 text-white">
|
||||||
|
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
|
||||||
|
{!profile?.max_heart_rate && ' (estimated from age)'}
|
||||||
|
{' · '}Zones: Z1 <{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)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||||
|
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
||||||
|
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||||
|
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Height (cm)">
|
||||||
|
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => updateProfile.mutate(Object.fromEntries(
|
||||||
|
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
||||||
|
))}
|
||||||
|
loading={updateProfile.isPending}
|
||||||
|
saved={hrSaved}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Weight log */}
|
||||||
|
<Section title="Weight Log">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Field label="Weight (kg)">
|
||||||
|
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Body fat % (optional)">
|
||||||
|
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="datetime-local" value={weightForm.date}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => addWeight.mutate({
|
||||||
|
weight_kg: parseFloat(weightForm.weight_kg),
|
||||||
|
body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
|
||||||
|
date: new Date(weightForm.date).toISOString(),
|
||||||
|
})}
|
||||||
|
loading={addWeight.isPending}
|
||||||
|
saved={weightSaved}
|
||||||
|
label="Log weight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{weightLog && weightLog.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{weightLog.slice(0, 20).map(entry => (
|
||||||
|
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
||||||
|
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||||
|
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
||||||
|
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
|
<button onClick={() => deleteWeight.mutate(entry.id)}
|
||||||
|
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Password change */}
|
||||||
|
<Section title="Change Password">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Current password">
|
||||||
|
<Input type="password" value={pwForm.current_password}
|
||||||
|
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
|
||||||
|
</Field>
|
||||||
|
<Field label="New password (min 8 characters)">
|
||||||
|
<Input type="password" value={pwForm.new_password}
|
||||||
|
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Confirm new password">
|
||||||
|
<Input type="password" value={pwForm.confirm}
|
||||||
|
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => {
|
||||||
|
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
|
||||||
|
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
|
||||||
|
}}
|
||||||
|
loading={changePassword.isPending}
|
||||||
|
saved={pwSaved}
|
||||||
|
label="Change password"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* PocketID — admin only */}
|
||||||
|
{user?.is_admin && (
|
||||||
|
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
|
||||||
|
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Client ID">
|
||||||
|
<Input value={pidForm.client_id} placeholder="milevault"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Client secret" hint="Leave blank to keep existing secret">
|
||||||
|
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
{pocketidConfig?.enabled && (
|
||||||
|
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => savePocketID.mutate(pidForm)}
|
||||||
|
loading={savePocketID.isPending}
|
||||||
|
saved={pidSaved}
|
||||||
|
label="Save PocketID config"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
|
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
@@ -20,18 +20,19 @@ export default function RoutesPage() {
|
|||||||
enabled: !!selected,
|
enabled: !!selected,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: segments } = useQuery({
|
const { data: recentActivities } = useQuery({
|
||||||
queryKey: ['route-segments', selected?.id],
|
queryKey: ['recent-activities-for-route'],
|
||||||
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
|
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
|
||||||
enabled: !!selected,
|
enabled: showCreate,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createRoute = useMutation({
|
const createRoute = useMutation({
|
||||||
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
||||||
onSuccess: () => {
|
onSuccess: (route) => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setNewRoute({ name: '', activity_id: '' })
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
|
setSelected(route)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,55 +41,62 @@ export default function RoutesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||||
<button
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
onClick={() => setShowCreate(true)}
|
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
</p>
|
||||||
>
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
+ New route
|
+ New route
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create route modal */}
|
{/* Create route */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||||
<input
|
<input value={newRoute.name}
|
||||||
value={newRoute.name}
|
|
||||||
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="e.g. Morning park loop"
|
placeholder="e.g. Morning park loop" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label>
|
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||||
<input
|
{recentActivities?.length === 0 ? (
|
||||||
type="number"
|
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
value={newRoute.activity_id}
|
value={newRoute.activity_id}
|
||||||
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
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"
|
>
|
||||||
/>
|
<option value="">Select an activity…</option>
|
||||||
|
{recentActivities?.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||||
disabled={!newRoute.name || !newRoute.activity_id}
|
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
>
|
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setShowCreate(false)}
|
||||||
onClick={() => setShowCreate(false)}
|
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,23 +106,24 @@ export default function RoutesPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Route list */}
|
{/* Route list */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{routes?.length === 0 && (
|
{routes?.length === 0 && !showCreate && (
|
||||||
<div className="text-center py-12 text-gray-600">
|
<div className="text-center py-12 text-gray-600">
|
||||||
<p className="text-3xl mb-2">🗺️</p>
|
<p className="text-3xl mb-2">🗺️</p>
|
||||||
<p className="text-sm">No named routes yet</p>
|
<p className="text-sm">No named routes yet</p>
|
||||||
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{routes?.map(route => (
|
{routes?.map(route => (
|
||||||
<button
|
<button key={route.id} onClick={() => setSelected(route)}
|
||||||
key={route.id}
|
|
||||||
onClick={() => setSelected(route)}
|
|
||||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||||
selected?.id === route.id
|
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||||
? 'bg-blue-900/20 border-blue-700'
|
}`}>
|
||||||
: 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
<div className="flex items-start justify-between">
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="font-medium text-white">{route.name}</p>
|
<p className="font-medium text-white">{route.name}</p>
|
||||||
|
{route.auto_detected && (
|
||||||
|
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||||
<span>{formatDistance(route.distance_m)}</span>
|
<span>{formatDistance(route.distance_m)}</span>
|
||||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||||
@@ -128,19 +137,20 @@ export default function RoutesPage() {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
|
<div className="flex items-start justify-between mb-3">
|
||||||
{selected.description && (
|
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||||
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
|
{selected.auto_detected && (
|
||||||
|
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
|
||||||
|
Auto-detected
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* CR */}
|
|
||||||
{fastest && (
|
{fastest && (
|
||||||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||||||
<p className="text-xs text-yellow-600 mb-1">Course record</p>
|
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-xl font-bold text-yellow-400">
|
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
|
||||||
{formatDuration(fastest.duration_s)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
||||||
</span>
|
</span>
|
||||||
@@ -148,16 +158,12 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* All runs on route */}
|
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||||
All runs ({routeActivities?.length ?? 0})
|
All runs ({routeActivities?.length ?? 0})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{routeActivities?.map((act, i) => (
|
{routeActivities?.map((act, i) => (
|
||||||
<div
|
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
|
||||||
key={act.id}
|
|
||||||
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
||||||
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||||||
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||||||
@@ -166,37 +172,12 @@ export default function RoutesPage() {
|
|||||||
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||||
)}
|
)}
|
||||||
{i === 0 && (
|
{i === 0 && (
|
||||||
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">
|
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||||||
CR
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segments */}
|
|
||||||
{segments && segments.length > 0 && (
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{segments.map(seg => (
|
|
||||||
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">{seg.name}</p>
|
|
||||||
{seg.description && (
|
|
||||||
<p className="text-xs text-gray-500">{seg.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 text-right">
|
|
||||||
<p>{formatDistance(seg.start_distance_m)} → {formatDistance(seg.end_distance_m)}</p>
|
|
||||||
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export function formatDuration(seconds) {
|
|||||||
export function formatPace(speedMs, sportType = 'running') {
|
export function formatPace(speedMs, sportType = 'running') {
|
||||||
if (!speedMs || speedMs <= 0) return '--'
|
if (!speedMs || speedMs <= 0) return '--'
|
||||||
if (sportType === 'cycling') {
|
if (sportType === 'cycling') {
|
||||||
const kph = speedMs * 3.6
|
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||||
return `${kph.toFixed(1)} km/h`
|
|
||||||
}
|
}
|
||||||
const secsPerKm = 1000 / speedMs
|
const secsPerKm = 1000 / speedMs
|
||||||
const mins = Math.floor(secsPerKm / 60)
|
const mins = Math.floor(secsPerKm / 60)
|
||||||
@@ -62,6 +61,17 @@ export function formatDateTime(dateStr) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatCadence(value, sportType) {
|
||||||
|
if (!value) return '--'
|
||||||
|
// Garmin stores running cadence as steps per minute / 2 (one foot)
|
||||||
|
// We need to double it to get total steps per minute (both feet)
|
||||||
|
if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
|
||||||
|
return `${Math.round(value * 2)} spm`
|
||||||
|
}
|
||||||
|
// Cycling is already in rpm
|
||||||
|
return `${Math.round(value)} rpm`
|
||||||
|
}
|
||||||
|
|
||||||
export function hrZoneColor(zone) {
|
export function hrZoneColor(zone) {
|
||||||
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
|
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
|
||||||
return colors[zone] || '#9ca3af'
|
return colors[zone] || '#9ca3af'
|
||||||
@@ -69,7 +79,7 @@ export function hrZoneColor(zone) {
|
|||||||
|
|
||||||
export function sportIcon(sportType) {
|
export function sportIcon(sportType) {
|
||||||
const icons = {
|
const icons = {
|
||||||
running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾',
|
running: '🏃', cycling: '🚴', hiking: '🥾',
|
||||||
walking: '🚶', other: '⚡',
|
walking: '🚶', other: '⚡',
|
||||||
}
|
}
|
||||||
return icons[sportType?.toLowerCase()] || '⚡'
|
return icons[sportType?.toLowerCase()] || '⚡'
|
||||||
@@ -77,7 +87,7 @@ export function sportIcon(sportType) {
|
|||||||
|
|
||||||
export function sportColor(sportType) {
|
export function sportColor(sportType) {
|
||||||
const colors = {
|
const colors = {
|
||||||
running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4',
|
running: '#3b82f6', cycling: '#f97316',
|
||||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
||||||
}
|
}
|
||||||
return colors[sportType?.toLowerCase()] || '#6b7280'
|
return colors[sportType?.toLowerCase()] || '#6b7280'
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# FitTracker configuration
|
||||||
|
# Copy this file to .env and edit, OR just run: bash install.sh
|
||||||
|
# install.sh auto-generates all secrets for you.
|
||||||
|
|
||||||
|
# ── Required ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Login for the web interface
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Security: generate with: openssl rand -hex 32
|
||||||
|
SECRET_KEY=changeme_run_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# Database password
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
DB_USER=fittracker
|
||||||
|
|
||||||
|
# Redis password
|
||||||
|
REDIS_PASSWORD=changeme
|
||||||
|
|
||||||
|
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Port to expose (default: 80)
|
||||||
|
HTTP_PORT=80
|
||||||
|
|
||||||
|
# Mapbox token for satellite map tiles — free at mapbox.com
|
||||||
|
# Leave blank to use OpenStreetMap (CartoDB dark tiles, no key needed)
|
||||||
|
VITE_MAPBOX_TOKEN=
|
||||||
|
|
||||||
|
# PocketID passkey authentication — leave blank to use local auth only
|
||||||
|
# See: https://github.com/pocket-id/pocket-id
|
||||||
|
POCKETID_ISSUER=
|
||||||
|
POCKETID_CLIENT_ID=
|
||||||
|
POCKETID_CLIENT_SECRET=
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build and push images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: # allow manual trigger from Gitea UI
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
|
||||||
|
OWNER: ${{ gitea.repository_owner }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
|
||||||
|
|
||||||
|
build-worker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push worker
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile.worker
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
file: ./frontend/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
|
||||||
|
build-args: |
|
||||||
|
VITE_API_URL=/api
|
||||||
|
VITE_MAPBOX_TOKEN=
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
*.sql.bak
|
||||||
|
db_data/
|
||||||
|
redis_data/
|
||||||
|
file_data/
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# MileVault
|
||||||
|
|
||||||
|
Self-hosted fitness tracking — Garmin & Strava import, maps, health trends, personal records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For users — deploy with two files
|
||||||
|
|
||||||
|
Once this repo is pushed to Gitea and the Actions workflow has run once, anyone on your network only needs **two files** to run MileVault. No source code, no cloning.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir milevault && cd milevault
|
||||||
|
|
||||||
|
# Download the two deployment files
|
||||||
|
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/docker-compose.deploy.yml
|
||||||
|
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/nginx.conf
|
||||||
|
|
||||||
|
# Start (images pulled automatically from your Gitea registry)
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Default login: `admin` / `admin`
|
||||||
|
**Change `ADMIN_PASSWORD` in a `.env` file before exposing to a network** (see Configuration below).
|
||||||
|
|
||||||
|
To update when a new version is pushed to Gitea:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.deploy.yml pull
|
||||||
|
docker compose -f docker-compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For developers — first-time Gitea setup
|
||||||
|
|
||||||
|
### 1. Enable the Gitea container registry
|
||||||
|
|
||||||
|
In your Gitea instance (`app.ini` or admin panel):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[packages]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Gitea. The registry is then available at `gitea.yourdomain.com`.
|
||||||
|
|
||||||
|
### 2. Create a Gitea Actions runner
|
||||||
|
|
||||||
|
Gitea Actions needs a runner on your server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the server that will build images
|
||||||
|
docker run -d \
|
||||||
|
--name gitea-runner \
|
||||||
|
--restart always \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v gitea-runner-data:/data \
|
||||||
|
-e GITEA_INSTANCE_URL=https://gitea.yourdomain.com \
|
||||||
|
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token from Gitea → Settings → Runners> \
|
||||||
|
gitea/act_runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the registration token from: **Gitea → Your repo → Settings → Actions → Runners → Create Runner**
|
||||||
|
|
||||||
|
### 3. Create a package token
|
||||||
|
|
||||||
|
The workflow needs a token to push images to the registry:
|
||||||
|
|
||||||
|
1. Gitea → Your profile → **Settings → Applications → Generate Token**
|
||||||
|
2. Scopes: tick **`write:package`**
|
||||||
|
3. Copy the token
|
||||||
|
|
||||||
|
Then in your repo: **Settings → Secrets → Actions → Add Secret**
|
||||||
|
- Name: `PACKAGE_TOKEN`
|
||||||
|
- Value: the token you just copied
|
||||||
|
|
||||||
|
### 4. Set the registry URL variable
|
||||||
|
|
||||||
|
In your repo: **Settings → Variables → Actions → Add Variable**
|
||||||
|
- Name: `GITEA_URL`
|
||||||
|
- Value: `gitea.yourdomain.com` (no `https://`)
|
||||||
|
|
||||||
|
### 5. Push the repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin https://gitea.yourdomain.com/yourusername/milevault.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
The Actions workflow (`.gitea/workflows/build.yml`) triggers automatically, builds all three images, and pushes them to your Gitea registry. Check progress under **Actions** in the Gitea UI.
|
||||||
|
|
||||||
|
### 6. Update docker-compose.deploy.yml
|
||||||
|
|
||||||
|
Before the first deploy, replace the placeholder registry URLs in `docker-compose.deploy.yml`:
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea.yourdomain.com/yourusername/ → your actual Gitea host and username
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a `.env` file next to `docker-compose.deploy.yml` to override any defaults:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Admin login
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=a_strong_password_here
|
||||||
|
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
HTTP_PORT=80
|
||||||
|
|
||||||
|
# Optional: Mapbox token for satellite tiles
|
||||||
|
VITE_MAPBOX_TOKEN=
|
||||||
|
|
||||||
|
# Optional: PocketID passkey auth
|
||||||
|
POCKETID_ISSUER=
|
||||||
|
POCKETID_CLIENT_ID=
|
||||||
|
POCKETID_CLIENT_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose picks up `.env` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If your Gitea registry requires authentication to pull
|
||||||
|
|
||||||
|
If your Gitea instance is private, add a pull secret on the deploy machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login gitea.yourdomain.com
|
||||||
|
# enter your Gitea username and password (or a read:package token)
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker stores the credentials in `~/.docker/config.json` and uses them automatically on `docker compose pull`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.gitea/workflows/build.yml ← Gitea Actions: builds & pushes images on push to main
|
||||||
|
docker-compose.yml ← dev/build compose (builds from source)
|
||||||
|
docker-compose.deploy.yml ← production compose (pulls pre-built images)
|
||||||
|
nginx.conf ← standalone nginx config for deploy compose
|
||||||
|
backend/ ← FastAPI + Celery worker
|
||||||
|
frontend/ ← React + Vite
|
||||||
|
nginx/nginx.conf ← nginx config for dev compose
|
||||||
|
docker/init.sql ← DB init (enables TimescaleDB extension)
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Single worker avoids race condition during DB initialization.
|
||||||
|
# For a personal app this is fine; async handles concurrent requests well.
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
sport_type: str
|
||||||
|
start_time: datetime
|
||||||
|
distance_m: Optional[float]
|
||||||
|
duration_s: Optional[float]
|
||||||
|
elevation_gain_m: Optional[float]
|
||||||
|
avg_heart_rate: Optional[float]
|
||||||
|
avg_cadence: Optional[float]
|
||||||
|
avg_speed_ms: Optional[float]
|
||||||
|
calories: Optional[float]
|
||||||
|
polyline: Optional[str]
|
||||||
|
bounding_box: Optional[dict]
|
||||||
|
hr_zones: Optional[dict]
|
||||||
|
named_route_id: Optional[int]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityDetail(ActivitySummary):
|
||||||
|
end_time: Optional[datetime]
|
||||||
|
elevation_loss_m: Optional[float]
|
||||||
|
max_heart_rate: Optional[float]
|
||||||
|
avg_power: Optional[float]
|
||||||
|
normalized_power: Optional[float]
|
||||||
|
max_speed_ms: Optional[float]
|
||||||
|
avg_temperature_c: Optional[float]
|
||||||
|
training_stress_score: Optional[float]
|
||||||
|
vo2max_estimate: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class DataPointOut(BaseModel):
|
||||||
|
timestamp: Optional[datetime]
|
||||||
|
latitude: Optional[float]
|
||||||
|
longitude: Optional[float]
|
||||||
|
altitude_m: Optional[float]
|
||||||
|
heart_rate: Optional[float]
|
||||||
|
cadence: Optional[float]
|
||||||
|
speed_ms: Optional[float]
|
||||||
|
power: Optional[float]
|
||||||
|
temperature_c: Optional[float]
|
||||||
|
distance_m: Optional[float]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LapOut(BaseModel):
|
||||||
|
lap_number: int
|
||||||
|
start_time: Optional[datetime]
|
||||||
|
duration_s: Optional[float]
|
||||||
|
distance_m: Optional[float]
|
||||||
|
avg_heart_rate: Optional[float]
|
||||||
|
avg_cadence: Optional[float]
|
||||||
|
avg_speed_ms: Optional[float]
|
||||||
|
avg_power: Optional[float]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ActivitySummary])
|
||||||
|
async def list_activities(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
sport_type: Optional[str] = None,
|
||||||
|
from_date: Optional[datetime] = None,
|
||||||
|
to_date: Optional[datetime] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = select(Activity).where(Activity.user_id == current_user.id)
|
||||||
|
|
||||||
|
if sport_type:
|
||||||
|
q = q.where(Activity.sport_type == sport_type)
|
||||||
|
if from_date:
|
||||||
|
q = q.where(Activity.start_time >= from_date)
|
||||||
|
if to_date:
|
||||||
|
q = q.where(Activity.start_time <= to_date)
|
||||||
|
|
||||||
|
q = q.order_by(desc(Activity.start_time))
|
||||||
|
q = q.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{activity_id}", response_model=ActivityDetail)
|
||||||
|
async def get_activity(
|
||||||
|
activity_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
|
||||||
|
async def get_data_points(
|
||||||
|
activity_id: int,
|
||||||
|
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Verify ownership
|
||||||
|
act = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not act.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
q = select(ActivityDataPoint).where(
|
||||||
|
ActivityDataPoint.activity_id == activity_id
|
||||||
|
).order_by(ActivityDataPoint.timestamp)
|
||||||
|
|
||||||
|
result = await db.execute(q)
|
||||||
|
points = result.scalars().all()
|
||||||
|
|
||||||
|
if downsample > 1:
|
||||||
|
points = points[::downsample]
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{activity_id}/laps", response_model=List[LapOut])
|
||||||
|
async def get_laps(
|
||||||
|
activity_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
act = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not act.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(ActivityLap)
|
||||||
|
.where(ActivityLap.activity_id == activity_id)
|
||||||
|
.order_by(ActivityLap.lap_number)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{activity_id}/name")
|
||||||
|
async def rename_activity(
|
||||||
|
activity_id: int,
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
activity.name = body.get("name", activity.name)
|
||||||
|
await db.commit()
|
||||||
|
return {"id": activity_id, "name": activity.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{activity_id}", status_code=204)
|
||||||
|
async def delete_activity(
|
||||||
|
activity_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
await db.delete(activity)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import verify_password, create_access_token, get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_pocketid_config(db: AsyncSession):
|
||||||
|
"""Get PocketID config from DB (admin user) falling back to env vars."""
|
||||||
|
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
|
||||||
|
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
|
||||||
|
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
|
||||||
|
return issuer, client_id, client_secret
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not user.hashed_password:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||||
|
if not verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||||
|
token = create_access_token({"sub": str(user.id)})
|
||||||
|
return Token(access_token=token, token_type="bearer",
|
||||||
|
user_id=user.id, username=user.username, is_admin=user.is_admin)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def get_me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pocketid/available")
|
||||||
|
async def pocketid_available(db: AsyncSession = Depends(get_db)):
|
||||||
|
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||||
|
return {"available": bool(issuer and client_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pocketid/login-url")
|
||||||
|
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
|
||||||
|
issuer, client_id, _ = await _get_pocketid_config(db)
|
||||||
|
if not issuer or not client_id:
|
||||||
|
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
params = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": "/api/auth/pocketid/callback",
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "openid profile email",
|
||||||
|
}
|
||||||
|
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pocketid/callback")
|
||||||
|
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
issuer, client_id, client_secret = await _get_pocketid_config(db)
|
||||||
|
if not issuer:
|
||||||
|
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{issuer}/token",
|
||||||
|
data={"grant_type": "authorization_code", "code": code,
|
||||||
|
"redirect_uri": "/api/auth/pocketid/callback",
|
||||||
|
"client_id": client_id, "client_secret": client_secret},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||||
|
tokens = resp.json()
|
||||||
|
userinfo_resp = await client.get(
|
||||||
|
f"{issuer}/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||||
|
)
|
||||||
|
userinfo = userinfo_resp.json()
|
||||||
|
|
||||||
|
sub = userinfo.get("sub")
|
||||||
|
email = userinfo.get("email")
|
||||||
|
preferred_username = userinfo.get("preferred_username") or email
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
user = User(username=preferred_username, email=email, pocketid_sub=sub)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
token = create_access_token({"sub": str(user.id)})
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url=f"/?token={token}")
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User, HealthMetric
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMetricOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
date: datetime
|
||||||
|
resting_hr: Optional[float]
|
||||||
|
max_hr_day: Optional[float]
|
||||||
|
avg_hr_day: Optional[float]
|
||||||
|
hrv_nightly_avg: Optional[float]
|
||||||
|
hrv_status: Optional[str]
|
||||||
|
hrv_5min_high: Optional[float]
|
||||||
|
hrv_5min_low: Optional[float]
|
||||||
|
sleep_duration_s: Optional[float]
|
||||||
|
sleep_deep_s: Optional[float]
|
||||||
|
sleep_light_s: Optional[float]
|
||||||
|
sleep_rem_s: Optional[float]
|
||||||
|
sleep_awake_s: Optional[float]
|
||||||
|
sleep_score: Optional[float]
|
||||||
|
sleep_start: Optional[datetime]
|
||||||
|
sleep_end: Optional[datetime]
|
||||||
|
weight_kg: Optional[float]
|
||||||
|
bmi: Optional[float]
|
||||||
|
body_fat_pct: Optional[float]
|
||||||
|
muscle_mass_kg: Optional[float]
|
||||||
|
vo2max: Optional[float]
|
||||||
|
fitness_age: Optional[int]
|
||||||
|
training_load: Optional[float]
|
||||||
|
recovery_time_h: Optional[float]
|
||||||
|
avg_stress: Optional[float]
|
||||||
|
steps: Optional[int]
|
||||||
|
floors_climbed: Optional[int]
|
||||||
|
active_calories: Optional[float]
|
||||||
|
total_calories: Optional[float]
|
||||||
|
spo2_avg: Optional[float]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[HealthMetricOut])
|
||||||
|
async def list_health_metrics(
|
||||||
|
from_date: Optional[datetime] = None,
|
||||||
|
to_date: Optional[datetime] = None,
|
||||||
|
limit: int = Query(365, ge=1, le=1000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
||||||
|
if from_date:
|
||||||
|
q = q.where(HealthMetric.date >= from_date)
|
||||||
|
if to_date:
|
||||||
|
q = q.where(HealthMetric.date <= to_date)
|
||||||
|
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def health_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Latest values + 30-day averages for dashboard widgets."""
|
||||||
|
# Latest record
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(HealthMetric)
|
||||||
|
.where(HealthMetric.user_id == current_user.id)
|
||||||
|
.order_by(desc(HealthMetric.date))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest = latest_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 30-day averages
|
||||||
|
from datetime import timedelta, timezone
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||||
|
avg_result = await db.execute(
|
||||||
|
select(
|
||||||
|
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
||||||
|
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
|
||||||
|
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
|
||||||
|
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
|
||||||
|
func.avg(HealthMetric.avg_stress).label("avg_stress"),
|
||||||
|
func.avg(HealthMetric.steps).label("avg_steps"),
|
||||||
|
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
||||||
|
).where(
|
||||||
|
HealthMetric.user_id == current_user.id,
|
||||||
|
HealthMetric.date >= cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
avgs = avg_result.one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest": HealthMetricOut.model_validate(latest) if latest else None,
|
||||||
|
"avg_30d": {
|
||||||
|
"resting_hr": avgs.avg_resting_hr,
|
||||||
|
"hrv": avgs.avg_hrv,
|
||||||
|
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
|
||||||
|
"sleep_score": avgs.avg_sleep_score,
|
||||||
|
"stress": avgs.avg_stress,
|
||||||
|
"steps": avgs.avg_steps,
|
||||||
|
"weight_kg": avgs.avg_weight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/manual")
|
||||||
|
async def add_manual_metric(
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Manually add or update a health metric for a given date."""
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
date_str = body.get("date")
|
||||||
|
if not date_str:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=400, detail="date required")
|
||||||
|
|
||||||
|
metric_date = datetime.fromisoformat(date_str)
|
||||||
|
|
||||||
|
# Check for existing
|
||||||
|
existing = await db.execute(
|
||||||
|
select(HealthMetric).where(
|
||||||
|
HealthMetric.user_id == current_user.id,
|
||||||
|
func.date(HealthMetric.date) == metric_date.date(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
metric = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if metric:
|
||||||
|
for key, val in body.items():
|
||||||
|
if hasattr(metric, key) and key not in ("id", "user_id"):
|
||||||
|
setattr(metric, key, val)
|
||||||
|
else:
|
||||||
|
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
|
||||||
|
k: v for k, v in body.items()
|
||||||
|
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
|
||||||
|
})
|
||||||
|
db.add(metric)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Personal Records ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PROut(BaseModel):
|
||||||
|
id: int
|
||||||
|
sport_type: str
|
||||||
|
distance_m: float
|
||||||
|
distance_label: str
|
||||||
|
duration_s: float
|
||||||
|
achieved_at: datetime
|
||||||
|
activity_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PROut])
|
||||||
|
async def list_records(
|
||||||
|
sport_type: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = select(PersonalRecord).where(
|
||||||
|
PersonalRecord.user_id == current_user.id,
|
||||||
|
PersonalRecord.is_current_record == True,
|
||||||
|
)
|
||||||
|
if sport_type:
|
||||||
|
q = q.where(PersonalRecord.sport_type == sport_type)
|
||||||
|
q = q.order_by(PersonalRecord.distance_m)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{distance_label}")
|
||||||
|
async def record_history(
|
||||||
|
distance_label: str,
|
||||||
|
sport_type: str = "running",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Show progression of a PR over time."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PersonalRecord).where(
|
||||||
|
PersonalRecord.user_id == current_user.id,
|
||||||
|
PersonalRecord.sport_type == sport_type,
|
||||||
|
PersonalRecord.distance_label == distance_label,
|
||||||
|
).order_by(PersonalRecord.achieved_at)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User, NamedRoute, RouteSegment, Activity
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
start_distance_m: float
|
||||||
|
end_distance_m: float
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
sport_type: Optional[str] = None
|
||||||
|
activity_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RouteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
sport_type: Optional[str]
|
||||||
|
reference_polyline: Optional[str]
|
||||||
|
bounding_box: Optional[dict]
|
||||||
|
distance_m: Optional[float]
|
||||||
|
auto_detected: Optional[bool]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
start_distance_m: float
|
||||||
|
end_distance_m: float
|
||||||
|
description: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[RouteOut])
|
||||||
|
async def list_routes(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(NamedRoute)
|
||||||
|
.where(NamedRoute.user_id == current_user.id)
|
||||||
|
.order_by(desc(NamedRoute.created_at))
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activities")
|
||||||
|
async def recent_activities_for_route(
|
||||||
|
days: int = Query(14, ge=1, le=90),
|
||||||
|
sport_type: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return recent activities for the route creation dropdown."""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
q = select(Activity).where(
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
Activity.start_time >= cutoff,
|
||||||
|
Activity.sport_type != "swimming",
|
||||||
|
)
|
||||||
|
if sport_type:
|
||||||
|
q = q.where(Activity.sport_type == sport_type)
|
||||||
|
q = q.order_by(desc(Activity.start_time)).limit(50)
|
||||||
|
result = await db.execute(q)
|
||||||
|
activities = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"name": a.name,
|
||||||
|
"sport_type": a.sport_type,
|
||||||
|
"start_time": a.start_time,
|
||||||
|
"distance_m": a.distance_m,
|
||||||
|
"duration_s": a.duration_s,
|
||||||
|
}
|
||||||
|
for a in activities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RouteOut)
|
||||||
|
async def create_route(
|
||||||
|
body: RouteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
act_result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == body.activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = act_result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
route = NamedRoute(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=body.name,
|
||||||
|
description=body.description,
|
||||||
|
sport_type=body.sport_type or activity.sport_type,
|
||||||
|
reference_polyline=activity.polyline,
|
||||||
|
bounding_box=activity.bounding_box,
|
||||||
|
distance_m=activity.distance_m,
|
||||||
|
auto_detected=False,
|
||||||
|
)
|
||||||
|
db.add(route)
|
||||||
|
await db.flush()
|
||||||
|
activity.named_route_id = route.id
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(route)
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{route_id}", response_model=RouteOut)
|
||||||
|
async def get_route(
|
||||||
|
route_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(NamedRoute).where(
|
||||||
|
NamedRoute.id == route_id,
|
||||||
|
NamedRoute.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
route = result.scalar_one_or_none()
|
||||||
|
if not route:
|
||||||
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
return route
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{route_id}/activities")
|
||||||
|
async def route_activities(
|
||||||
|
route_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.named_route_id == route_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
).order_by(Activity.duration_s)
|
||||||
|
)
|
||||||
|
activities = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"name": a.name,
|
||||||
|
"start_time": a.start_time,
|
||||||
|
"duration_s": a.duration_s,
|
||||||
|
"distance_m": a.distance_m,
|
||||||
|
"avg_heart_rate": a.avg_heart_rate,
|
||||||
|
"avg_speed_ms": a.avg_speed_ms,
|
||||||
|
}
|
||||||
|
for a in activities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{route_id}/assign-activity")
|
||||||
|
async def assign_activity_to_route(
|
||||||
|
route_id: int,
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
activity_id = body.get("activity_id")
|
||||||
|
act_result = await db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.id == activity_id,
|
||||||
|
Activity.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
activity = act_result.scalar_one_or_none()
|
||||||
|
if not activity:
|
||||||
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
activity.named_route_id = route_id
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
||||||
|
async def list_segments(
|
||||||
|
route_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RouteSegment)
|
||||||
|
.where(RouteSegment.route_id == route_id)
|
||||||
|
.order_by(RouteSegment.start_distance_m)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{route_id}/segments", response_model=SegmentOut)
|
||||||
|
async def create_segment(
|
||||||
|
route_id: int,
|
||||||
|
body: SegmentCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
segment = RouteSegment(
|
||||||
|
route_id=route_id,
|
||||||
|
name=body.name,
|
||||||
|
start_distance_m=body.start_distance_m,
|
||||||
|
end_distance_m=body.end_distance_m,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
db.add(segment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(segment)
|
||||||
|
return segment
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
||||||
|
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / upload.filename
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
shutil.copyfileobj(upload.file, f)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activity")
|
||||||
|
async def upload_activity(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
background_tasks: BackgroundTasks = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload a single .fit or .gpx activity file."""
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
if suffix not in {".fit", ".gpx"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
||||||
|
|
||||||
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||||
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
|
# Queue processing
|
||||||
|
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
||||||
|
|
||||||
|
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/garmin-export")
|
||||||
|
async def upload_garmin_export(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload a full Garmin Connect data export ZIP.
|
||||||
|
Processes all FIT files for activities + wellness data.
|
||||||
|
"""
|
||||||
|
if not file.filename.endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
|
||||||
|
|
||||||
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||||
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
|
# Extract and queue all FIT files
|
||||||
|
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||||
|
extract_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
task_ids = []
|
||||||
|
with zipfile.ZipFile(dest) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
for name in zf.namelist():
|
||||||
|
lower = name.lower()
|
||||||
|
if lower.endswith(".fit"):
|
||||||
|
fit_path = extract_dir / name
|
||||||
|
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||||
|
task_ids.append(task.id)
|
||||||
|
|
||||||
|
# Queue health/wellness data extraction
|
||||||
|
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "queued",
|
||||||
|
"activity_tasks": len(task_ids),
|
||||||
|
"health_task": health_task.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/strava-export")
|
||||||
|
async def upload_strava_export(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
||||||
|
if not file.filename.endswith(".zip"):
|
||||||
|
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
||||||
|
|
||||||
|
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||||
|
dest = save_upload(file, dest_dir)
|
||||||
|
|
||||||
|
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||||
|
extract_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
task_ids = []
|
||||||
|
with zipfile.ZipFile(dest) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
for name in zf.namelist():
|
||||||
|
lower = name.lower()
|
||||||
|
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
||||||
|
file_path = extract_dir / name
|
||||||
|
ext = Path(name).suffix[1:]
|
||||||
|
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
||||||
|
task_ids.append(task.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "queued",
|
||||||
|
"activity_tasks": len(task_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/task/{task_id}")
|
||||||
|
async def check_task_status(
|
||||||
|
task_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Check the status of an upload processing task."""
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
result = celery_app.AsyncResult(task_id)
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": result.status,
|
||||||
|
"result": result.result if result.ready() else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Database
|
||||||
|
database_url: str = Field(..., env="DATABASE_URL")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
secret_key: str = Field(..., env="SECRET_KEY")
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
# Admin account - optional so the worker (which doesn't seed users) can start
|
||||||
|
# without it. The backend service checks this at seed time.
|
||||||
|
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||||
|
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
# PocketID OIDC (optional)
|
||||||
|
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||||
|
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||||
|
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
|
||||||
|
|
||||||
|
# Files
|
||||||
|
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
environment: str = Field("production", env="ENVIRONMENT")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Async engine for FastAPI
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.environment == "development",
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync engine for Celery workers (Celery + asyncio don't mix well)
|
||||||
|
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
|
||||||
|
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||||
|
sync_engine = create_engine(
|
||||||
|
sync_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
)
|
||||||
|
to_encode["exp"] = expire
|
||||||
|
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from sqlalchemy import text
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.api import auth, activities, routes, health, records, upload, profile
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Create tables then seed admin, with retries for slow DB startup.
|
||||||
|
|
||||||
|
Multiple uvicorn workers may race here on first start. We tolerate
|
||||||
|
duplicate table errors since they just mean another worker got there first.
|
||||||
|
"""
|
||||||
|
for attempt in range(15):
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
|
||||||
|
print("Tables already created by another worker - skipping")
|
||||||
|
break
|
||||||
|
if attempt == 14:
|
||||||
|
raise
|
||||||
|
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Try TimescaleDB hypertable (non-fatal)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
||||||
|
"if_not_exists => TRUE, migrate_data => TRUE)"
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"TimescaleDB hypertable skipped: {e}")
|
||||||
|
|
||||||
|
# Seed admin user (only if password is configured)
|
||||||
|
if not settings.admin_password:
|
||||||
|
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||||
|
return
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.security import hash_password
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.username == settings.admin_username)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
admin = User(
|
||||||
|
username=settings.admin_username,
|
||||||
|
hashed_password=hash_password(settings.admin_password),
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
print(f"Admin user '{settings.admin_username}' created")
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "duplicate" in msg or "unique" in msg:
|
||||||
|
print("Admin user already exists - skipping seed")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="MileVault",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"] if settings.environment == "development" else [],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
|
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||||
|
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||||
|
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||||
|
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||||
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def healthcheck():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Float, DateTime, Boolean,
|
||||||
|
ForeignKey, Text, JSON, Index, UniqueConstraint
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
email = Column(String(256), unique=True, nullable=True)
|
||||||
|
hashed_password = Column(String(256), nullable=True)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
# Health profile
|
||||||
|
max_heart_rate = Column(Integer, nullable=True)
|
||||||
|
resting_heart_rate = Column(Integer, nullable=True)
|
||||||
|
birth_year = Column(Integer, nullable=True)
|
||||||
|
height_cm = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
# PocketID config (stored per-user so admin can set via UI)
|
||||||
|
pocketid_issuer = Column(String(512), nullable=True)
|
||||||
|
pocketid_client_id = Column(String(256), nullable=True)
|
||||||
|
pocketid_client_secret = Column(String(256), nullable=True)
|
||||||
|
|
||||||
|
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class WeightLog(Base):
|
||||||
|
"""Manual weight entries separate from health_metrics for easy tracking."""
|
||||||
|
__tablename__ = "weight_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
weight_kg = Column(Float, nullable=False)
|
||||||
|
body_fat_pct = Column(Float, nullable=True)
|
||||||
|
note = Column(String(256), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_weight_user_date", "user_id", "date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="weight_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class Activity(Base):
|
||||||
|
__tablename__ = "activities"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(256), nullable=False)
|
||||||
|
sport_type = Column(String(64), nullable=False)
|
||||||
|
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
distance_m = Column(Float, nullable=True)
|
||||||
|
duration_s = Column(Float, nullable=True)
|
||||||
|
elevation_gain_m = Column(Float, nullable=True)
|
||||||
|
elevation_loss_m = Column(Float, nullable=True)
|
||||||
|
avg_heart_rate = Column(Float, nullable=True)
|
||||||
|
max_heart_rate = Column(Float, nullable=True)
|
||||||
|
avg_cadence = Column(Float, nullable=True)
|
||||||
|
avg_power = Column(Float, nullable=True)
|
||||||
|
normalized_power = Column(Float, nullable=True)
|
||||||
|
avg_speed_ms = Column(Float, nullable=True)
|
||||||
|
max_speed_ms = Column(Float, nullable=True)
|
||||||
|
avg_temperature_c = Column(Float, nullable=True)
|
||||||
|
calories = Column(Float, nullable=True)
|
||||||
|
training_stress_score = Column(Float, nullable=True)
|
||||||
|
vo2max_estimate = Column(Float, nullable=True)
|
||||||
|
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
||||||
|
polyline = Column(Text, nullable=True)
|
||||||
|
bounding_box = Column(JSON, nullable=True)
|
||||||
|
source_file = Column(String(512), nullable=True)
|
||||||
|
source_type = Column(String(32), nullable=True)
|
||||||
|
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
||||||
|
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
||||||
|
hr_zones = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="activities")
|
||||||
|
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
|
||||||
|
named_route = relationship("NamedRoute", back_populates="activities")
|
||||||
|
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityDataPoint(Base):
|
||||||
|
__tablename__ = "activity_data_points"
|
||||||
|
|
||||||
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
|
||||||
|
latitude = Column(Float, nullable=True)
|
||||||
|
longitude = Column(Float, nullable=True)
|
||||||
|
altitude_m = Column(Float, nullable=True)
|
||||||
|
heart_rate = Column(Float, nullable=True)
|
||||||
|
cadence = Column(Float, nullable=True)
|
||||||
|
speed_ms = Column(Float, nullable=True)
|
||||||
|
power = Column(Float, nullable=True)
|
||||||
|
temperature_c = Column(Float, nullable=True)
|
||||||
|
distance_m = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
activity = relationship("Activity", back_populates="data_points")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLap(Base):
|
||||||
|
__tablename__ = "activity_laps"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
||||||
|
lap_number = Column(Integer, nullable=False)
|
||||||
|
start_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
duration_s = Column(Float, nullable=True)
|
||||||
|
distance_m = Column(Float, nullable=True)
|
||||||
|
avg_heart_rate = Column(Float, nullable=True)
|
||||||
|
avg_cadence = Column(Float, nullable=True)
|
||||||
|
avg_speed_ms = Column(Float, nullable=True)
|
||||||
|
avg_power = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
activity = relationship("Activity", back_populates="laps")
|
||||||
|
|
||||||
|
|
||||||
|
class NamedRoute(Base):
|
||||||
|
__tablename__ = "named_routes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(256), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
sport_type = Column(String(64), nullable=True)
|
||||||
|
reference_polyline = Column(Text, nullable=True)
|
||||||
|
bounding_box = Column(JSON, nullable=True)
|
||||||
|
distance_m = Column(Float, nullable=True)
|
||||||
|
auto_detected = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="named_routes")
|
||||||
|
activities = relationship("Activity", back_populates="named_route")
|
||||||
|
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class RouteSegment(Base):
|
||||||
|
__tablename__ = "route_segments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
||||||
|
name = Column(String(256), nullable=False)
|
||||||
|
start_distance_m = Column(Float, nullable=False)
|
||||||
|
end_distance_m = Column(Float, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
route = relationship("NamedRoute", back_populates="segments")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalRecord(Base):
|
||||||
|
__tablename__ = "personal_records"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
||||||
|
sport_type = Column(String(64), nullable=False)
|
||||||
|
distance_m = Column(Float, nullable=False)
|
||||||
|
distance_label = Column(String(32), nullable=False)
|
||||||
|
duration_s = Column(Float, nullable=False)
|
||||||
|
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
is_current_record = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
||||||
|
name="uq_pr_current"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMetric(Base):
|
||||||
|
__tablename__ = "health_metrics"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
resting_hr = Column(Float, nullable=True)
|
||||||
|
max_hr_day = Column(Float, nullable=True)
|
||||||
|
avg_hr_day = Column(Float, nullable=True)
|
||||||
|
hrv_status = Column(String(32), nullable=True)
|
||||||
|
hrv_nightly_avg = Column(Float, nullable=True)
|
||||||
|
hrv_5min_high = Column(Float, nullable=True)
|
||||||
|
hrv_5min_low = Column(Float, nullable=True)
|
||||||
|
sleep_duration_s = Column(Float, nullable=True)
|
||||||
|
sleep_deep_s = Column(Float, nullable=True)
|
||||||
|
sleep_light_s = Column(Float, nullable=True)
|
||||||
|
sleep_rem_s = Column(Float, nullable=True)
|
||||||
|
sleep_awake_s = Column(Float, nullable=True)
|
||||||
|
sleep_score = Column(Float, nullable=True)
|
||||||
|
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
weight_kg = Column(Float, nullable=True)
|
||||||
|
bmi = Column(Float, nullable=True)
|
||||||
|
body_fat_pct = Column(Float, nullable=True)
|
||||||
|
muscle_mass_kg = Column(Float, nullable=True)
|
||||||
|
vo2max = Column(Float, nullable=True)
|
||||||
|
fitness_age = Column(Integer, nullable=True)
|
||||||
|
training_load = Column(Float, nullable=True)
|
||||||
|
recovery_time_h = Column(Float, nullable=True)
|
||||||
|
avg_stress = Column(Float, nullable=True)
|
||||||
|
steps = Column(Integer, nullable=True)
|
||||||
|
floors_climbed = Column(Integer, nullable=True)
|
||||||
|
active_calories = Column(Float, nullable=True)
|
||||||
|
total_calories = Column(Float, nullable=True)
|
||||||
|
spo2_avg = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||||
|
Index("ix_health_user_date", "user_id", "date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="health_metrics")
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
FIT and GPX file parser using:
|
||||||
|
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
|
||||||
|
- gpxpy for .gpx files
|
||||||
|
|
||||||
|
The official SDK correctly handles scale/offset, component expansion,
|
||||||
|
semicircle-to-degree conversion, and HR message merging.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import gpxpy
|
||||||
|
import polyline as polyline_lib
|
||||||
|
|
||||||
|
|
||||||
|
FIT_EPOCH_S = 631065600
|
||||||
|
|
||||||
|
|
||||||
|
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||||
|
"""Distance in metres between two GPS points."""
|
||||||
|
R = 6371000
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||||
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(val) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
return float(val) if val is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bounding_box(coords: list) -> Optional[dict]:
|
||||||
|
if not coords:
|
||||||
|
return None
|
||||||
|
lats = [c[0] for c in coords]
|
||||||
|
lons = [c[1] for c in coords]
|
||||||
|
return {"min_lat": min(lats), "max_lat": max(lats),
|
||||||
|
"min_lon": min(lons), "max_lon": max(lons)}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fit_file(filepath: str) -> dict:
|
||||||
|
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
|
||||||
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
|
||||||
|
session = {}
|
||||||
|
records = []
|
||||||
|
laps = []
|
||||||
|
|
||||||
|
def listener(mesg_num: int, msg: dict):
|
||||||
|
nonlocal session
|
||||||
|
if mesg_num == 18: # session
|
||||||
|
session = msg
|
||||||
|
elif mesg_num == 20: # record
|
||||||
|
records.append(msg)
|
||||||
|
elif mesg_num == 19: # lap
|
||||||
|
laps.append(msg)
|
||||||
|
|
||||||
|
stream = Stream.from_file(filepath)
|
||||||
|
decoder = Decoder(stream)
|
||||||
|
decoder.read(
|
||||||
|
apply_scale_and_offset=True,
|
||||||
|
convert_datetimes_to_dates=True,
|
||||||
|
convert_types_to_strings=True,
|
||||||
|
enable_crc_check=False,
|
||||||
|
expand_sub_fields=True,
|
||||||
|
expand_components=True,
|
||||||
|
merge_heart_rates=True,
|
||||||
|
mesg_listener=listener,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map sport type
|
||||||
|
sport = str(session.get("sport", "generic")).lower()
|
||||||
|
sport_map = {
|
||||||
|
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||||
|
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||||
|
"open_water_swimming": "swimming", "trail_running": "running",
|
||||||
|
"e_biking": "cycling",
|
||||||
|
}
|
||||||
|
sport_type = sport_map.get(sport, sport)
|
||||||
|
|
||||||
|
start_time = session.get("start_time")
|
||||||
|
if isinstance(start_time, datetime) and start_time.tzinfo is None:
|
||||||
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Build GPS track
|
||||||
|
coords = [
|
||||||
|
(r["position_lat"], r["position_long"])
|
||||||
|
for r in records
|
||||||
|
if r.get("position_lat") is not None and r.get("position_long") is not None
|
||||||
|
]
|
||||||
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
|
# Normalize data points
|
||||||
|
normalized_points = []
|
||||||
|
for r in records:
|
||||||
|
ts = r.get("timestamp")
|
||||||
|
if isinstance(ts, datetime) and ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
normalized_points.append({
|
||||||
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
|
"latitude": r.get("position_lat"),
|
||||||
|
"longitude": r.get("position_long"),
|
||||||
|
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
|
||||||
|
"heart_rate": r.get("heart_rate"),
|
||||||
|
"cadence": r.get("cadence") or r.get("fractional_cadence"),
|
||||||
|
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
|
||||||
|
"power": r.get("power"),
|
||||||
|
"temperature_c": r.get("temperature"),
|
||||||
|
"distance_m": r.get("distance"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Normalize laps
|
||||||
|
normalized_laps = []
|
||||||
|
for i, lap in enumerate(laps):
|
||||||
|
ls = lap.get("start_time")
|
||||||
|
if isinstance(ls, datetime) and ls.tzinfo is None:
|
||||||
|
ls = ls.replace(tzinfo=timezone.utc)
|
||||||
|
normalized_laps.append({
|
||||||
|
"lap_number": i + 1,
|
||||||
|
"start_time": ls.isoformat() if ls else None,
|
||||||
|
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
||||||
|
"distance_m": _safe_float(lap.get("total_distance")),
|
||||||
|
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||||
|
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||||
|
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
|
||||||
|
"avg_power": _safe_float(lap.get("avg_power")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build activity name
|
||||||
|
name = session.get("sport", "Activity").title()
|
||||||
|
if start_time:
|
||||||
|
name += " " + start_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sport_type": sport_type,
|
||||||
|
"start_time": start_time.isoformat() if start_time else None,
|
||||||
|
"distance_m": _safe_float(session.get("total_distance")),
|
||||||
|
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
||||||
|
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
||||||
|
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
||||||
|
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
||||||
|
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
||||||
|
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||||
|
"avg_power": _safe_float(session.get("avg_power")),
|
||||||
|
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||||
|
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
|
||||||
|
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
|
||||||
|
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||||
|
"calories": _safe_float(session.get("total_calories")),
|
||||||
|
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||||
|
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
|
||||||
|
"polyline": encoded_polyline,
|
||||||
|
"bounding_box": bounding_box,
|
||||||
|
"source_type": "fit",
|
||||||
|
"data_points": normalized_points,
|
||||||
|
"laps": normalized_laps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gpx_file(filepath: str) -> dict:
|
||||||
|
"""Parse a GPX file."""
|
||||||
|
with open(filepath) as f:
|
||||||
|
gpx = gpxpy.parse(f)
|
||||||
|
|
||||||
|
data_points = []
|
||||||
|
track = gpx.tracks[0] if gpx.tracks else None
|
||||||
|
if not track:
|
||||||
|
raise ValueError("No tracks found in GPX file")
|
||||||
|
|
||||||
|
for segment in track.segments:
|
||||||
|
for pt in segment.points:
|
||||||
|
ts = pt.time
|
||||||
|
if ts and ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
extensions = {}
|
||||||
|
if pt.extensions:
|
||||||
|
for ext in pt.extensions:
|
||||||
|
for child in ext:
|
||||||
|
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||||
|
try:
|
||||||
|
extensions[tag] = float(child.text)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
data_points.append({
|
||||||
|
"timestamp": ts.isoformat() if ts else None,
|
||||||
|
"latitude": pt.latitude,
|
||||||
|
"longitude": pt.longitude,
|
||||||
|
"altitude_m": pt.elevation,
|
||||||
|
"heart_rate": extensions.get("hr"),
|
||||||
|
"cadence": extensions.get("cad"),
|
||||||
|
"speed_ms": extensions.get("speed"),
|
||||||
|
"power": extensions.get("power"),
|
||||||
|
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||||
|
"distance_m": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||||
|
if p["latitude"] and p["longitude"]]
|
||||||
|
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||||
|
bounding_box = _bounding_box(coords)
|
||||||
|
|
||||||
|
# Add cumulative distance
|
||||||
|
total_dist = 0.0
|
||||||
|
prev = None
|
||||||
|
for p in data_points:
|
||||||
|
if p["latitude"] and p["longitude"]:
|
||||||
|
if prev:
|
||||||
|
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
||||||
|
prev = (p["latitude"], p["longitude"])
|
||||||
|
p["distance_m"] = total_dist
|
||||||
|
|
||||||
|
# Elevation gain/loss
|
||||||
|
uphill, downhill = 0.0, 0.0
|
||||||
|
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||||
|
for i in range(1, len(alts)):
|
||||||
|
diff = alts[i] - alts[i-1]
|
||||||
|
if diff > 0:
|
||||||
|
uphill += diff
|
||||||
|
else:
|
||||||
|
downhill += abs(diff)
|
||||||
|
|
||||||
|
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||||
|
start_time_str = data_points[0]["timestamp"] if data_points else None
|
||||||
|
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||||
|
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||||
|
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||||
|
|
||||||
|
sport = "running"
|
||||||
|
if track.type:
|
||||||
|
sport = track.type.lower()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||||
|
"sport_type": sport,
|
||||||
|
"start_time": start_time_str,
|
||||||
|
"distance_m": total_dist,
|
||||||
|
"duration_s": duration,
|
||||||
|
"elevation_gain_m": uphill,
|
||||||
|
"elevation_loss_m": downhill,
|
||||||
|
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||||
|
"max_heart_rate": max(hrs) if hrs else None,
|
||||||
|
"avg_cadence": None,
|
||||||
|
"avg_power": None,
|
||||||
|
"normalized_power": None,
|
||||||
|
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||||
|
"max_speed_ms": None,
|
||||||
|
"avg_temperature_c": None,
|
||||||
|
"calories": None,
|
||||||
|
"training_stress_score": None,
|
||||||
|
"vo2max_estimate": None,
|
||||||
|
"polyline": encoded_polyline,
|
||||||
|
"bounding_box": bounding_box,
|
||||||
|
"source_type": "gpx",
|
||||||
|
"data_points": data_points,
|
||||||
|
"laps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
|
||||||
|
"""
|
||||||
|
Calculate % time in each HR zone using the user's configured max HR.
|
||||||
|
|
||||||
|
Zones follow the standard 5-zone model as % of max HR:
|
||||||
|
Z1 Recovery: < 60%
|
||||||
|
Z2 Base: 60 - 70%
|
||||||
|
Z3 Tempo: 70 - 80%
|
||||||
|
Z4 Threshold: 80 - 90%
|
||||||
|
Z5 Max: > 90%
|
||||||
|
|
||||||
|
user_max_hr should be the user's actual physiological max HR, NOT the
|
||||||
|
highest HR recorded in this activity. Using activity max shifts all zones
|
||||||
|
upward and makes easy runs look harder than they are.
|
||||||
|
"""
|
||||||
|
if not user_max_hr or user_max_hr < 100:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
|
||||||
|
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
|
||||||
|
zones = {k: 0 for k in zone_keys}
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for p in data_points:
|
||||||
|
hr = p.get("heart_rate")
|
||||||
|
if not hr or hr < 20:
|
||||||
|
continue
|
||||||
|
pct = hr / user_max_hr
|
||||||
|
total += 1
|
||||||
|
for i, key in enumerate(zone_keys):
|
||||||
|
if zone_bounds[i] <= pct < zone_bounds[i+1]:
|
||||||
|
zones[key] += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
zones["z5"] += 1 # anything above 90% goes to z5
|
||||||
|
|
||||||
|
if total:
|
||||||
|
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Route matching: identifies when multiple activities were on the same route.
|
||||||
|
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
import polyline as polyline_lib
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
|
||||||
|
return polyline_lib.decode(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
|
||||||
|
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
|
||||||
|
return (
|
||||||
|
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
|
||||||
|
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
|
||||||
|
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
|
||||||
|
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
|
||||||
|
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
|
||||||
|
if len(coords) <= n:
|
||||||
|
return coords
|
||||||
|
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
|
||||||
|
return [coords[i] for i in indices]
|
||||||
|
|
||||||
|
|
||||||
|
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
|
||||||
|
"""
|
||||||
|
Compute DTW distance between two GPS tracks.
|
||||||
|
Each point is (lat, lon). Returns average distance in metres per matched pair.
|
||||||
|
"""
|
||||||
|
n, m = len(track1), len(track2)
|
||||||
|
dtw = np.full((n + 1, m + 1), np.inf)
|
||||||
|
dtw[0][0] = 0.0
|
||||||
|
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
for j in range(1, m + 1):
|
||||||
|
cost = haversine_m(track1[i-1], track2[j-1])
|
||||||
|
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
|
||||||
|
|
||||||
|
return dtw[n][m] / max(n, m)
|
||||||
|
|
||||||
|
|
||||||
|
def haversine_m(p1: tuple, p2: tuple) -> float:
|
||||||
|
R = 6371000
|
||||||
|
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||||
|
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
|
||||||
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
def routes_are_similar(
|
||||||
|
poly1: str,
|
||||||
|
poly2: str,
|
||||||
|
bb1: Optional[dict],
|
||||||
|
bb2: Optional[dict],
|
||||||
|
dtw_threshold_m: float = 80.0,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if two activities are on sufficiently similar routes.
|
||||||
|
First does a cheap bounding box check, then DTW on downsampled tracks.
|
||||||
|
"""
|
||||||
|
if bb1 and bb2:
|
||||||
|
if not bounding_boxes_overlap(bb1, bb2):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
|
||||||
|
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not coords1 or not coords2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
dist = dtw_distance(coords1, coords2)
|
||||||
|
return dist < dtw_threshold_m
|
||||||
|
|
||||||
|
|
||||||
|
def find_segment_times(
|
||||||
|
data_points: list[dict],
|
||||||
|
start_dist_m: float,
|
||||||
|
end_dist_m: float,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Given activity data points (with cumulative distance_m),
|
||||||
|
find the time to traverse from start_dist_m to end_dist_m.
|
||||||
|
Returns duration in seconds, or None if not found.
|
||||||
|
"""
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
for p in data_points:
|
||||||
|
dist = p.get("distance_m")
|
||||||
|
ts = p.get("timestamp")
|
||||||
|
if dist is None or ts is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if start_time is None and dist >= start_dist_m:
|
||||||
|
start_time = ts
|
||||||
|
|
||||||
|
if start_time is not None and dist >= end_dist_m:
|
||||||
|
end_time = ts
|
||||||
|
break
|
||||||
|
|
||||||
|
if start_time and end_time:
|
||||||
|
from datetime import datetime
|
||||||
|
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
||||||
|
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
||||||
|
return (t2 - t1).total_seconds()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_best_split_time(
|
||||||
|
data_points: list[dict],
|
||||||
|
target_distance_m: float,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Find the best (fastest) time over any target_distance_m window within an activity.
|
||||||
|
E.g. fastest 1km split in a 10km run.
|
||||||
|
Returns duration in seconds.
|
||||||
|
"""
|
||||||
|
points_with_dist = [
|
||||||
|
p for p in data_points
|
||||||
|
if p.get("distance_m") is not None and p.get("timestamp") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if not points_with_dist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best = None
|
||||||
|
j = 0
|
||||||
|
|
||||||
|
for i, start_p in enumerate(points_with_dist):
|
||||||
|
start_dist = start_p["distance_m"]
|
||||||
|
start_ts = start_p["timestamp"]
|
||||||
|
|
||||||
|
# Advance j until distance covered >= target
|
||||||
|
while j < len(points_with_dist):
|
||||||
|
end_p = points_with_dist[j]
|
||||||
|
covered = end_p["distance_m"] - start_dist
|
||||||
|
if covered >= target_distance_m:
|
||||||
|
from datetime import datetime
|
||||||
|
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
|
||||||
|
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
|
||||||
|
duration = (t2 - t1).total_seconds()
|
||||||
|
if best is None or duration < best:
|
||||||
|
best = duration
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
if j >= len(points_with_dist):
|
||||||
|
break
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
STANDARD_DISTANCES = [
|
||||||
|
(400, "400m"),
|
||||||
|
(800, "800m"),
|
||||||
|
(1000, "1k"),
|
||||||
|
(1609.34, "1 mile"),
|
||||||
|
(3000, "3k"),
|
||||||
|
(5000, "5k"),
|
||||||
|
(10000, "10k"),
|
||||||
|
(21097.5, "Half marathon"),
|
||||||
|
(42195, "Marathon"),
|
||||||
|
(50000, "50k"),
|
||||||
|
(100000, "100k"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
|
||||||
|
"""Compute best split times for all standard distances that fit within the activity."""
|
||||||
|
results = {}
|
||||||
|
for dist_m, label in STANDARD_DISTANCES:
|
||||||
|
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
|
||||||
|
best = find_best_split_time(data_points, dist_m)
|
||||||
|
if best:
|
||||||
|
results[label] = best
|
||||||
|
return results
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""
|
||||||
|
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
|
||||||
|
|
||||||
|
The official SDK (garmin-fit-sdk) correctly handles:
|
||||||
|
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
|
||||||
|
- Garmin proprietary messages stored by numeric mesg_num
|
||||||
|
- Unknown fields stored by field definition number
|
||||||
|
- Scale/offset application, component expansion, HR merging
|
||||||
|
|
||||||
|
Fenix 6X proprietary message numbers identified by binary analysis:
|
||||||
|
55 - activity accumulation snapshots (cumulative steps, HR per interval)
|
||||||
|
103 - daily totals summary (total steps, floors, calories)
|
||||||
|
211 - resting HR + HRV summary
|
||||||
|
227 - per-minute stress level + heart rate (most valuable for health dashboard)
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_ts(seconds) -> Optional[datetime]:
|
||||||
|
"""Convert FIT timestamp to UTC datetime."""
|
||||||
|
if seconds is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
s = int(seconds)
|
||||||
|
if s == 0 or s == 0xFFFFFFFF:
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
|
||||||
|
except (TypeError, ValueError, OverflowError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_datetime(v) -> bool:
|
||||||
|
return isinstance(v, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wellness_fit(file_path: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
|
||||||
|
|
||||||
|
Returns {"days": {date: metrics_dict}, "error": str|None}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from garmin_fit_sdk import Decoder, Stream
|
||||||
|
except ImportError:
|
||||||
|
# Fall back to fitparse-based parser if SDK not installed yet
|
||||||
|
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
|
||||||
|
return _fb(file_path)
|
||||||
|
|
||||||
|
daily = {} # date -> aggregation dict
|
||||||
|
|
||||||
|
def ensure_day(d: date) -> dict:
|
||||||
|
if d not in daily:
|
||||||
|
daily[d] = {
|
||||||
|
"heart_rates": [],
|
||||||
|
"stress_values": [],
|
||||||
|
"spo2_readings": [],
|
||||||
|
"sleep_levels": [],
|
||||||
|
"steps": None,
|
||||||
|
"floors_climbed": None,
|
||||||
|
"active_calories": None,
|
||||||
|
"total_calories": None,
|
||||||
|
"resting_hr": None,
|
||||||
|
"hrv_nightly_avg": None,
|
||||||
|
"hrv_5min_high": None,
|
||||||
|
"hrv_status": None,
|
||||||
|
}
|
||||||
|
return daily[d]
|
||||||
|
|
||||||
|
def get_date(msg: dict, *keys) -> Optional[date]:
|
||||||
|
"""Extract a date from a message, trying multiple field names."""
|
||||||
|
for key in keys:
|
||||||
|
v = msg.get(key)
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
if _is_datetime(v):
|
||||||
|
return v.date()
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
dt = fit_ts(v)
|
||||||
|
if dt:
|
||||||
|
return dt.date()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def listener(mesg_num: int, msg: dict):
|
||||||
|
"""Called for every message after full decoding."""
|
||||||
|
|
||||||
|
# ── Standard: monitoring (148) ────────────────────────────────────
|
||||||
|
if mesg_num == 148:
|
||||||
|
d = get_date(msg, "timestamp", "local_timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
entry = ensure_day(d)
|
||||||
|
|
||||||
|
hr = msg.get("heart_rate")
|
||||||
|
if hr and 20 < hr < 250:
|
||||||
|
entry["heart_rates"].append(int(hr))
|
||||||
|
|
||||||
|
steps = msg.get("steps") or msg.get("cycles")
|
||||||
|
if steps and steps > 0:
|
||||||
|
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||||
|
|
||||||
|
stress = msg.get("stress_level_value")
|
||||||
|
if stress is not None and stress >= 0:
|
||||||
|
entry["stress_values"].append(int(stress))
|
||||||
|
|
||||||
|
# ── Standard: monitoring_info (147) ───────────────────────────────
|
||||||
|
elif mesg_num == 147:
|
||||||
|
d = get_date(msg, "timestamp", "local_timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
rhr = msg.get("resting_heart_rate")
|
||||||
|
if rhr and 20 < rhr < 120:
|
||||||
|
ensure_day(d)["resting_hr"] = int(rhr)
|
||||||
|
|
||||||
|
# ── Standard: hrv_status_summary (275) ────────────────────────────
|
||||||
|
elif mesg_num == 275:
|
||||||
|
d = get_date(msg, "timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
entry = ensure_day(d)
|
||||||
|
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
|
||||||
|
v = msg.get(key)
|
||||||
|
if v:
|
||||||
|
entry["hrv_nightly_avg"] = float(v)
|
||||||
|
break
|
||||||
|
high = msg.get("last_night_5_min_high")
|
||||||
|
if high:
|
||||||
|
entry["hrv_5min_high"] = float(high)
|
||||||
|
status = msg.get("hrv_status")
|
||||||
|
if status:
|
||||||
|
entry["hrv_status"] = str(status)
|
||||||
|
|
||||||
|
# ── Standard: stress_level (132) ──────────────────────────────────
|
||||||
|
elif mesg_num == 132:
|
||||||
|
d = get_date(msg, "stress_level_time", "timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
stress = msg.get("stress_level_value")
|
||||||
|
if stress is not None and stress >= 0:
|
||||||
|
ensure_day(d)["stress_values"].append(int(stress))
|
||||||
|
|
||||||
|
# ── Standard: spo2_data (258) ─────────────────────────────────────
|
||||||
|
elif mesg_num == 258:
|
||||||
|
d = get_date(msg, "timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
|
||||||
|
if spo2 and 50 < spo2 <= 100:
|
||||||
|
ensure_day(d)["spo2_readings"].append(float(spo2))
|
||||||
|
|
||||||
|
# ── Standard: sleep_level (269) ───────────────────────────────────
|
||||||
|
elif mesg_num == 269:
|
||||||
|
d = get_date(msg, "timestamp")
|
||||||
|
if not d:
|
||||||
|
return
|
||||||
|
level = msg.get("sleep_level")
|
||||||
|
if level is not None:
|
||||||
|
# Convert string level names to numeric codes if SDK decoded them
|
||||||
|
if isinstance(level, str):
|
||||||
|
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
|
||||||
|
level = level_map.get(level.lower())
|
||||||
|
if level is not None:
|
||||||
|
ensure_day(d)["sleep_levels"].append(int(level))
|
||||||
|
|
||||||
|
# ── Proprietary 227: per-minute stress + HR ───────────────────────
|
||||||
|
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
|
||||||
|
elif mesg_num == 227:
|
||||||
|
# SDK stores unknown fields as "unknown_N" or by def_num
|
||||||
|
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
|
||||||
|
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
|
||||||
|
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
|
||||||
|
|
||||||
|
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
|
||||||
|
ts_raw if _is_datetime(ts_raw) else None
|
||||||
|
)
|
||||||
|
if not ts:
|
||||||
|
return
|
||||||
|
entry = ensure_day(ts.date())
|
||||||
|
|
||||||
|
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
|
||||||
|
entry["heart_rates"].append(int(hr_raw))
|
||||||
|
|
||||||
|
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
|
||||||
|
entry["stress_values"].append(int(stress_raw))
|
||||||
|
|
||||||
|
# ── Proprietary 103: daily totals summary ─────────────────────────
|
||||||
|
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
|
||||||
|
elif mesg_num == 103:
|
||||||
|
ts_v = msg.get(253) or msg.get("timestamp")
|
||||||
|
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||||
|
if not ts:
|
||||||
|
return
|
||||||
|
entry = ensure_day(ts.date())
|
||||||
|
|
||||||
|
steps = msg.get(3)
|
||||||
|
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||||
|
entry["steps"] = int(steps)
|
||||||
|
|
||||||
|
floors = msg.get(4)
|
||||||
|
if floors and isinstance(floors, (int, float)) and floors > 0:
|
||||||
|
f = float(floors)
|
||||||
|
if f > 1000:
|
||||||
|
f = f / 100
|
||||||
|
entry["floors_climbed"] = round(f, 1)
|
||||||
|
|
||||||
|
active_cal = msg.get(5)
|
||||||
|
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
|
||||||
|
entry["active_calories"] = float(active_cal)
|
||||||
|
|
||||||
|
total_cal = msg.get(7)
|
||||||
|
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
|
||||||
|
entry["total_calories"] = float(total_cal)
|
||||||
|
|
||||||
|
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
|
||||||
|
elif mesg_num == 211:
|
||||||
|
ts_v = msg.get(253) or msg.get("timestamp")
|
||||||
|
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||||
|
if not ts:
|
||||||
|
return
|
||||||
|
entry = ensure_day(ts.date())
|
||||||
|
|
||||||
|
rhr = msg.get(0)
|
||||||
|
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
|
||||||
|
entry["resting_hr"] = int(rhr)
|
||||||
|
|
||||||
|
hrv = msg.get(1)
|
||||||
|
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
|
||||||
|
entry["hrv_nightly_avg"] = float(hrv)
|
||||||
|
|
||||||
|
# ── Proprietary 55: activity accumulation snapshots ───────────────
|
||||||
|
elif mesg_num == 55:
|
||||||
|
ts_v = msg.get(253) or msg.get("timestamp")
|
||||||
|
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
|
||||||
|
if not ts:
|
||||||
|
return
|
||||||
|
entry = ensure_day(ts.date())
|
||||||
|
|
||||||
|
steps = msg.get(2)
|
||||||
|
if steps and isinstance(steps, (int, float)) and steps > 0:
|
||||||
|
entry["steps"] = max(entry["steps"] or 0, int(steps))
|
||||||
|
|
||||||
|
hr = msg.get(19)
|
||||||
|
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
|
||||||
|
entry["heart_rates"].append(int(hr))
|
||||||
|
|
||||||
|
# Decode the file
|
||||||
|
try:
|
||||||
|
stream = Stream.from_file(file_path)
|
||||||
|
decoder = Decoder(stream)
|
||||||
|
messages, errors = decoder.read(
|
||||||
|
apply_scale_and_offset=True,
|
||||||
|
convert_datetimes_to_dates=True,
|
||||||
|
convert_types_to_strings=True,
|
||||||
|
enable_crc_check=False, # wellness files sometimes have bad CRCs
|
||||||
|
expand_sub_fields=True,
|
||||||
|
expand_components=True,
|
||||||
|
merge_heart_rates=True,
|
||||||
|
mesg_listener=listener,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "days": {}}
|
||||||
|
|
||||||
|
# Aggregate per-day
|
||||||
|
result = {}
|
||||||
|
for day_date, data in daily.items():
|
||||||
|
hrs = data.pop("heart_rates", [])
|
||||||
|
stresses = data.pop("stress_values", [])
|
||||||
|
spo2s = data.pop("spo2_readings", [])
|
||||||
|
sleep_levels = data.pop("sleep_levels", [])
|
||||||
|
|
||||||
|
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
|
||||||
|
max_hr = max(hrs) if hrs else None
|
||||||
|
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
|
||||||
|
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
|
||||||
|
|
||||||
|
# Sleep stage seconds (each level record = 30s epoch)
|
||||||
|
if sleep_levels:
|
||||||
|
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
|
||||||
|
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
|
||||||
|
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
|
||||||
|
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
|
||||||
|
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
|
||||||
|
else:
|
||||||
|
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
||||||
|
|
||||||
|
result[day_date] = {
|
||||||
|
"resting_hr": data.get("resting_hr"),
|
||||||
|
"avg_hr_day": avg_hr,
|
||||||
|
"max_hr_day": max_hr,
|
||||||
|
"avg_stress": avg_stress,
|
||||||
|
"spo2_avg": spo2_avg,
|
||||||
|
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
|
||||||
|
"hrv_5min_high": data.get("hrv_5min_high"),
|
||||||
|
"hrv_status": data.get("hrv_status"),
|
||||||
|
"steps": data.get("steps"),
|
||||||
|
"floors_climbed": data.get("floors_climbed"),
|
||||||
|
"active_calories": data.get("active_calories"),
|
||||||
|
"total_calories": data.get("total_calories"),
|
||||||
|
"sleep_duration_s": sleep_duration_s,
|
||||||
|
"sleep_deep_s": sleep_deep_s,
|
||||||
|
"sleep_light_s": sleep_light_s,
|
||||||
|
"sleep_rem_s": sleep_rem_s,
|
||||||
|
"sleep_awake_s": sleep_awake_s,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"days": result, "error": None}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Celery entry point. Re-exports celery_app from tasks so the worker
|
||||||
|
can be started with: celery -A app.workers.celery_app worker
|
||||||
|
"""
|
||||||
|
from app.workers.tasks import celery_app
|
||||||
|
|
||||||
|
__all__ = ["celery_app"]
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""
|
||||||
|
Background tasks: activity ingestion, route matching, PR calculation.
|
||||||
|
|
||||||
|
Uses synchronous SQLAlchemy because Celery's prefork model doesn't play
|
||||||
|
well with asyncio - each worker process needs its own connection pool,
|
||||||
|
and async pools don't survive process forks.
|
||||||
|
"""
|
||||||
|
from celery import Celery
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"milevault",
|
||||||
|
broker=settings.redis_url,
|
||||||
|
backend=settings.redis_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
result_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Garmin FIT file suffixes that are health/wellness data, not activities
|
||||||
|
WELLNESS_SUFFIXES = (
|
||||||
|
"_METRICS.fit",
|
||||||
|
"_WELLNESS.fit",
|
||||||
|
"_SLEEP.fit",
|
||||||
|
"_STRESS.fit",
|
||||||
|
"_SPO2.fit",
|
||||||
|
"_HRV.fit",
|
||||||
|
"_MONITORING.fit",
|
||||||
|
"_MONITORING_B.fit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_wellness_file(file_path: str) -> bool:
|
||||||
|
name = file_path.upper()
|
||||||
|
return any(name.endswith(s.upper()) for s in WELLNESS_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="process_activity_file")
|
||||||
|
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||||
|
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
||||||
|
|
||||||
|
# Route wellness/metrics files to health parser instead
|
||||||
|
if is_wellness_file(file_path):
|
||||||
|
parse_wellness_fit.delay(file_path, user_id)
|
||||||
|
return {"status": "routed_to_wellness", "file": file_path}
|
||||||
|
|
||||||
|
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import Activity, ActivityDataPoint, ActivityLap
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if source_type == "fit" or file_path.endswith(".fit"):
|
||||||
|
parsed = parse_fit_file(file_path)
|
||||||
|
else:
|
||||||
|
parsed = parse_gpx_file(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise self.retry(exc=e, countdown=10, max_retries=3)
|
||||||
|
|
||||||
|
# Skip files with no usable activity data
|
||||||
|
if not parsed.get("start_time"):
|
||||||
|
return {"status": "skipped", "reason": "no start_time", "file": file_path}
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
# Check for duplicate by garmin activity ID
|
||||||
|
if parsed.get("garmin_activity_id"):
|
||||||
|
existing = db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.garmin_activity_id == parsed["garmin_activity_id"]
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return {"activity_id": existing.id, "status": "duplicate"}
|
||||||
|
|
||||||
|
# Get user's configured max HR for accurate zone calculation
|
||||||
|
# Falls back to: user-set value → 220-age → activity max → 190
|
||||||
|
from app.models.user import User as UserModel
|
||||||
|
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
|
||||||
|
user_max_hr = None
|
||||||
|
if user_obj:
|
||||||
|
user_max_hr = user_obj.max_heart_rate
|
||||||
|
if not user_max_hr and user_obj.birth_year:
|
||||||
|
from datetime import date as _date
|
||||||
|
age = _date.today().year - user_obj.birth_year
|
||||||
|
user_max_hr = 220 - age
|
||||||
|
if not user_max_hr:
|
||||||
|
# Last resort: use activity max but warn this may shift zones
|
||||||
|
user_max_hr = parsed.get("max_heart_rate") or 190
|
||||||
|
|
||||||
|
hr_zones = calculate_hr_zones(
|
||||||
|
parsed.get("data_points", []),
|
||||||
|
user_max_hr
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = datetime.fromisoformat(parsed["start_time"])
|
||||||
|
|
||||||
|
activity = Activity(
|
||||||
|
user_id=user_id,
|
||||||
|
name=parsed["name"],
|
||||||
|
sport_type=parsed["sport_type"],
|
||||||
|
start_time=start_time,
|
||||||
|
distance_m=parsed.get("distance_m"),
|
||||||
|
duration_s=parsed.get("duration_s"),
|
||||||
|
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||||
|
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||||
|
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||||
|
max_heart_rate=parsed.get("max_heart_rate"),
|
||||||
|
avg_cadence=parsed.get("avg_cadence"),
|
||||||
|
avg_power=parsed.get("avg_power"),
|
||||||
|
normalized_power=parsed.get("normalized_power"),
|
||||||
|
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||||
|
max_speed_ms=parsed.get("max_speed_ms"),
|
||||||
|
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||||
|
calories=parsed.get("calories"),
|
||||||
|
training_stress_score=parsed.get("training_stress_score"),
|
||||||
|
polyline=parsed.get("polyline"),
|
||||||
|
bounding_box=parsed.get("bounding_box"),
|
||||||
|
source_file=file_path,
|
||||||
|
source_type=parsed.get("source_type"),
|
||||||
|
hr_zones=hr_zones,
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Insert data points, deduping on (activity_id, timestamp)
|
||||||
|
seen = set()
|
||||||
|
points = parsed.get("data_points", [])
|
||||||
|
batch = []
|
||||||
|
for p in points:
|
||||||
|
if not p.get("timestamp"):
|
||||||
|
continue
|
||||||
|
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
|
||||||
|
key = (activity.id, ts)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
batch.append(ActivityDataPoint(
|
||||||
|
activity_id=activity.id,
|
||||||
|
timestamp=ts,
|
||||||
|
latitude=p.get("latitude"),
|
||||||
|
longitude=p.get("longitude"),
|
||||||
|
altitude_m=p.get("altitude_m"),
|
||||||
|
heart_rate=p.get("heart_rate"),
|
||||||
|
cadence=p.get("cadence"),
|
||||||
|
speed_ms=p.get("speed_ms"),
|
||||||
|
power=p.get("power"),
|
||||||
|
temperature_c=p.get("temperature_c"),
|
||||||
|
distance_m=p.get("distance_m"),
|
||||||
|
))
|
||||||
|
if len(batch) >= 500:
|
||||||
|
db.add_all(batch)
|
||||||
|
db.flush()
|
||||||
|
batch = []
|
||||||
|
if batch:
|
||||||
|
db.add_all(batch)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Laps
|
||||||
|
for lap in parsed.get("laps", []):
|
||||||
|
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||||
|
db.add(ActivityLap(
|
||||||
|
activity_id=activity.id,
|
||||||
|
lap_number=lap["lap_number"],
|
||||||
|
start_time=ls,
|
||||||
|
duration_s=lap.get("duration_s"),
|
||||||
|
distance_m=lap.get("distance_m"),
|
||||||
|
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||||
|
avg_cadence=lap.get("avg_cadence"),
|
||||||
|
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||||
|
avg_power=lap.get("avg_power"),
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
activity_id = activity.id
|
||||||
|
|
||||||
|
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||||
|
# Auto route detection for running and cycling
|
||||||
|
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
|
||||||
|
detect_route.delay(activity_id, user_id)
|
||||||
|
return {"activity_id": activity_id, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="parse_wellness_fit")
|
||||||
|
def parse_wellness_fit(file_path: str, user_id: int):
|
||||||
|
"""
|
||||||
|
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
|
||||||
|
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
|
||||||
|
"""
|
||||||
|
from app.services.wellness_parser import parse_wellness_fit as _parse
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
result = _parse(file_path)
|
||||||
|
if result.get("error"):
|
||||||
|
return {"status": "error", "error": result["error"], "file": file_path}
|
||||||
|
|
||||||
|
days = result.get("days", {})
|
||||||
|
if not days:
|
||||||
|
return {"status": "no_data", "file": file_path}
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
for day_date, data in days.items():
|
||||||
|
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
|
||||||
|
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||||
|
steps, floors_climbed, active_calories, total_calories,
|
||||||
|
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
|
||||||
|
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||||
|
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||||
|
:steps, :floors, :active_cal, :total_cal,
|
||||||
|
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
|
||||||
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
|
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
||||||
|
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
|
||||||
|
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||||
|
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
|
||||||
|
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
|
||||||
|
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
|
||||||
|
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
|
||||||
|
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||||
|
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||||
|
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||||
|
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||||
|
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
|
||||||
|
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
|
||||||
|
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
|
||||||
|
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
|
||||||
|
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
|
||||||
|
"""), {
|
||||||
|
"user_id": user_id, "date": date_dt,
|
||||||
|
"resting_hr": data.get("resting_hr"),
|
||||||
|
"avg_hr": data.get("avg_hr_day"),
|
||||||
|
"max_hr": data.get("max_hr_day"),
|
||||||
|
"avg_stress": data.get("avg_stress"),
|
||||||
|
"spo2_avg": data.get("spo2_avg"),
|
||||||
|
"hrv_avg": data.get("hrv_nightly_avg"),
|
||||||
|
"hrv_high": data.get("hrv_5min_high"),
|
||||||
|
"hrv_status": data.get("hrv_status"),
|
||||||
|
"steps": data.get("steps"),
|
||||||
|
"floors": data.get("floors_climbed"),
|
||||||
|
"active_cal": data.get("active_calories"),
|
||||||
|
"total_cal": data.get("total_calories"),
|
||||||
|
"sleep_dur": data.get("sleep_duration_s"),
|
||||||
|
"sleep_deep": data.get("sleep_deep_s"),
|
||||||
|
"sleep_light": data.get("sleep_light_s"),
|
||||||
|
"sleep_rem": data.get("sleep_rem_s"),
|
||||||
|
"sleep_awake": data.get("sleep_awake_s"),
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "days_processed": len(days), "file": file_path}
|
||||||
|
|
||||||
|
@celery_app.task(name="detect_route")
|
||||||
|
def detect_route(activity_id: int, user_id: int):
|
||||||
|
"""
|
||||||
|
After importing an activity, check if it matches any existing named routes.
|
||||||
|
If two+ unassigned activities match each other, auto-create a named route.
|
||||||
|
"""
|
||||||
|
from app.services.route_matcher import routes_are_similar
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import Activity, NamedRoute
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
# Get the new activity
|
||||||
|
new_act = db.execute(
|
||||||
|
select(Activity).where(Activity.id == activity_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not new_act or not new_act.polyline:
|
||||||
|
return {"status": "no_polyline"}
|
||||||
|
|
||||||
|
# Already assigned to a route?
|
||||||
|
if new_act.named_route_id:
|
||||||
|
return {"status": "already_assigned"}
|
||||||
|
|
||||||
|
# Check against existing named routes first
|
||||||
|
routes = db.execute(
|
||||||
|
select(NamedRoute).where(
|
||||||
|
NamedRoute.user_id == user_id,
|
||||||
|
NamedRoute.sport_type == new_act.sport_type,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
if route.reference_polyline and routes_are_similar(
|
||||||
|
new_act.polyline, route.reference_polyline,
|
||||||
|
new_act.bounding_box, route.bounding_box,
|
||||||
|
):
|
||||||
|
new_act.named_route_id = route.id
|
||||||
|
db.commit()
|
||||||
|
return {"status": "matched_existing", "route_id": route.id}
|
||||||
|
|
||||||
|
# No existing route matched - check unassigned activities for a match
|
||||||
|
candidates = db.execute(
|
||||||
|
select(Activity).where(
|
||||||
|
Activity.user_id == user_id,
|
||||||
|
Activity.sport_type == new_act.sport_type,
|
||||||
|
Activity.named_route_id == None,
|
||||||
|
Activity.id != activity_id,
|
||||||
|
Activity.polyline != None,
|
||||||
|
# Within 20% distance
|
||||||
|
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
||||||
|
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if routes_are_similar(
|
||||||
|
new_act.polyline, candidate.polyline,
|
||||||
|
new_act.bounding_box, candidate.bounding_box,
|
||||||
|
):
|
||||||
|
# Auto-create a route from the older activity
|
||||||
|
older = candidate if candidate.start_time < new_act.start_time else new_act
|
||||||
|
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||||
|
|
||||||
|
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
|
||||||
|
new_route = NamedRoute(
|
||||||
|
user_id=user_id,
|
||||||
|
name=route_name,
|
||||||
|
sport_type=older.sport_type,
|
||||||
|
reference_polyline=older.polyline,
|
||||||
|
bounding_box=older.bounding_box,
|
||||||
|
distance_m=older.distance_m,
|
||||||
|
auto_detected=True,
|
||||||
|
)
|
||||||
|
db.add(new_route)
|
||||||
|
db.flush()
|
||||||
|
older.named_route_id = new_route.id
|
||||||
|
newer.named_route_id = new_route.id
|
||||||
|
db.commit()
|
||||||
|
return {"status": "auto_created", "route_id": new_route.id}
|
||||||
|
|
||||||
|
return {"status": "no_match"}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="compute_personal_records")
|
||||||
|
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||||
|
"""Calculate personal records for standard distances from this activity."""
|
||||||
|
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import PersonalRecord
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
data_points = parsed.get("data_points", [])
|
||||||
|
total_dist = parsed.get("distance_m", 0) or 0
|
||||||
|
sport = parsed.get("sport_type", "running")
|
||||||
|
start_time_str = parsed.get("start_time")
|
||||||
|
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
best_splits = compute_best_splits(data_points, total_dist)
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
for label, duration_s in best_splits.items():
|
||||||
|
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
|
||||||
|
if dist_m is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = db.execute(
|
||||||
|
select(PersonalRecord).where(
|
||||||
|
PersonalRecord.user_id == user_id,
|
||||||
|
PersonalRecord.sport_type == sport,
|
||||||
|
PersonalRecord.distance_m == dist_m,
|
||||||
|
PersonalRecord.is_current_record == True,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if current is None or duration_s < current.duration_s:
|
||||||
|
if current:
|
||||||
|
current.is_current_record = False
|
||||||
|
db.add(PersonalRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
activity_id=activity_id,
|
||||||
|
sport_type=sport,
|
||||||
|
distance_m=dist_m,
|
||||||
|
distance_label=label,
|
||||||
|
duration_s=duration_s,
|
||||||
|
achieved_at=start_time,
|
||||||
|
is_current_record=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="process_garmin_health_zip")
|
||||||
|
def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||||
|
"""Extract wellness data from a Garmin Connect export ZIP."""
|
||||||
|
import zipfile
|
||||||
|
import json
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import HealthMetric
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if "DailyMetrics" not in name or not name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
with zf.open(name) as f:
|
||||||
|
try:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_str = data.get("calendarDate") or data.get("date")
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
from sqlalchemy import text as _text
|
||||||
|
db.execute(_text("""
|
||||||
|
INSERT INTO health_metrics (user_id, date, resting_hr, steps,
|
||||||
|
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
|
||||||
|
VALUES (:user_id, :date, :resting_hr, :steps,
|
||||||
|
:floors, :active_cal, :total_cal, :stress, :spo2)
|
||||||
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
|
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
|
||||||
|
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
|
||||||
|
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
|
||||||
|
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
|
||||||
|
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
|
||||||
|
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
|
||||||
|
"""), {
|
||||||
|
"user_id": user_id, "date": date_dt,
|
||||||
|
"resting_hr": data.get("restingHeartRate"),
|
||||||
|
"steps": data.get("totalSteps"),
|
||||||
|
"floors": data.get("floorsAscended"),
|
||||||
|
"active_cal": data.get("activeKilocalories"),
|
||||||
|
"total_cal": data.get("totalKilocalories"),
|
||||||
|
"stress": data.get("averageStressLevel"),
|
||||||
|
"spo2": data.get("avgSpo2"),
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.0
|
||||||
|
sqlalchemy[asyncio]==2.0.30
|
||||||
|
asyncpg==0.29.0
|
||||||
|
alembic==1.13.1
|
||||||
|
pydantic==2.7.1
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.0
|
||||||
|
redis[hiredis]==5.0.4
|
||||||
|
celery[redis]==5.4.0
|
||||||
|
garmin-fit-sdk==21.195.0
|
||||||
|
fitparse==1.2.0
|
||||||
|
gpxpy==1.6.2
|
||||||
|
numpy==1.26.4
|
||||||
|
scipy==1.13.0
|
||||||
|
geopy==2.4.1
|
||||||
|
polyline==2.0.2
|
||||||
|
Pillow==10.3.0
|
||||||
|
aiofiles==23.2.1
|
||||||
|
python-dateutil==2.9.0
|
||||||
|
pytz==2024.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# MileVault — standalone deployment
|
||||||
|
#
|
||||||
|
# 1. Copy this file somewhere on your server (no other files needed)
|
||||||
|
# 2. Run: docker compose up -d
|
||||||
|
# 3. Visit http://localhost
|
||||||
|
#
|
||||||
|
# Images are pulled from your Gitea container registry automatically.
|
||||||
|
# To update to the latest build: docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# ── Replace these with your actual Gitea host and username ───────────────────
|
||||||
|
x-registry: ®istry gitea.yourdomain.com/yourusername
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb:latest-pg16
|
||||||
|
container_name: milevault_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: milevault
|
||||||
|
POSTGRES_USER: ${DB_USER:-milevault}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: milevault_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: gitea.yourdomain.com/yourusername/milevault-backend:latest
|
||||||
|
container_name: milevault_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
|
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
ENVIRONMENT: production
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
|
||||||
|
container_name: milevault_worker
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
|
||||||
|
container_name: milevault_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: milevault_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
|
file_data:
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: timescale/timescaledb:latest-pg16
|
||||||
|
container_name: milevault_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: milevault
|
||||||
|
POSTGRES_USER: ${DB_USER:-milevault}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: milevault_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: milevault_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
|
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
|
||||||
|
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
|
||||||
|
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.worker
|
||||||
|
container_name: milevault_worker
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||||
|
VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN:-}
|
||||||
|
container_name: milevault_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: milevault_nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
|
file_data:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Enable TimescaleDB extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- Activity data points will use TimescaleDB hypertable for efficient
|
||||||
|
-- time-series queries on HR, cadence, power, temperature, etc.
|
||||||
|
-- Tables are created by Alembic migrations; this just ensures extensions exist.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_API_URL=/api
|
||||||
|
ARG VITE_MAPBOX_TOKEN=
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MileVault</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "milevault-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"zustand": "^4.5.2",
|
||||||
|
"@tanstack/react-query": "^5.40.0",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"@polyline-codec/core": "^2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.2.13",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useAuthStore } from './hooks/useAuth'
|
||||||
|
import Layout from './components/ui/Layout'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import ActivitiesPage from './pages/ActivitiesPage'
|
||||||
|
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||||
|
import HealthPage from './pages/HealthPage'
|
||||||
|
import RoutesPage from './pages/RoutesPage'
|
||||||
|
import RecordsPage from './pages/RecordsPage'
|
||||||
|
import UploadPage from './pages/UploadPage'
|
||||||
|
import ProfilePage from './pages/ProfilePage'
|
||||||
|
|
||||||
|
function RequireAuth({ children }) {
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
|
if (!token) return <Navigate to="/login" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { token, fetchUser } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) fetchUser()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const urlToken = params.get('token')
|
||||||
|
if (urlToken) {
|
||||||
|
localStorage.setItem('token', urlToken)
|
||||||
|
useAuthStore.setState({ token: urlToken })
|
||||||
|
window.history.replaceState({}, '', '/')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="activities" element={<ActivitiesPage />} />
|
||||||
|
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||||
|
<Route path="health" element={<HealthPage />} />
|
||||||
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="records" element={<RecordsPage />} />
|
||||||
|
<Route path="upload" element={<UploadPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { sportColor } from '../../utils/format'
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TILE_LAYERS = {
|
||||||
|
dark: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
},
|
||||||
|
street: {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePolyline(encoded) {
|
||||||
|
const coords = []
|
||||||
|
let index = 0, lat = 0, lng = 0
|
||||||
|
while (index < encoded.length) {
|
||||||
|
let b, shift = 0, result = 0
|
||||||
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
|
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
shift = 0; result = 0
|
||||||
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||||
|
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||||
|
coords.push([lat / 1e5, lng / 1e5])
|
||||||
|
}
|
||||||
|
return coords
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
||||||
|
const mapRef = useRef(null)
|
||||||
|
const mapInstanceRef = useRef(null)
|
||||||
|
const markerRef = useRef(null)
|
||||||
|
const trackRef = useRef(null)
|
||||||
|
const tileLayerRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || mapInstanceRef.current) return
|
||||||
|
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
|
||||||
|
const tile = TILE_LAYERS['dark']
|
||||||
|
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||||
|
.addTo(mapInstanceRef.current)
|
||||||
|
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Switch tile layer when mapType changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current) return
|
||||||
|
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
|
||||||
|
if (tileLayerRef.current) {
|
||||||
|
tileLayerRef.current.remove()
|
||||||
|
}
|
||||||
|
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||||
|
.addTo(mapInstanceRef.current)
|
||||||
|
}, [mapType])
|
||||||
|
|
||||||
|
// Draw route
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current || !polyline) return
|
||||||
|
if (trackRef.current) trackRef.current.remove()
|
||||||
|
const coords = decodePolyline(polyline)
|
||||||
|
if (!coords.length) return
|
||||||
|
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
|
||||||
|
.addTo(mapInstanceRef.current)
|
||||||
|
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const dot = (color) => L.divIcon({
|
||||||
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||||
|
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||||
|
})
|
||||||
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
|
||||||
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
|
||||||
|
}
|
||||||
|
}, [polyline, sportType])
|
||||||
|
|
||||||
|
// Position marker on timeline hover
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
||||||
|
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||||
|
if (!point?.latitude || !point?.longitude) return
|
||||||
|
if (markerRef.current) {
|
||||||
|
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||||
|
} else {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||||
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||||
|
})
|
||||||
|
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
|
||||||
|
}
|
||||||
|
}, [hoveredDistance, dataPoints])
|
||||||
|
|
||||||
|
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const ZONE_CONFIG = [
|
||||||
|
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||||
|
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||||
|
{ key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' },
|
||||||
|
{ key: 'z4', label: 'Z4 Threshold', color: '#f97316' },
|
||||||
|
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function HRZoneBar({ zones }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Stacked bar */}
|
||||||
|
<div className="flex h-4 rounded-full overflow-hidden gap-0.5">
|
||||||
|
{ZONE_CONFIG.map(({ key, color }) => {
|
||||||
|
const pct = zones[key] || 0
|
||||||
|
if (pct < 0.5) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
|
className="h-full"
|
||||||
|
title={`${key.toUpperCase()}: ${pct}%`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{ZONE_CONFIG.map(({ key, label, color }) => {
|
||||||
|
const pct = zones[key] || 0
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-xs text-gray-400">{label}</span>
|
||||||
|
<span className="text-xs font-medium text-white">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
|
export default function LapTable({ laps, sportType }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
||||||
|
<th className="text-left pb-2 font-medium">Lap</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Distance</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Time</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Pace</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||||
|
<th className="text-right pb-2 font-medium">Power</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{laps.map((lap) => (
|
||||||
|
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||||
|
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||||
|
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||||
|
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
||||||
|
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-400">
|
||||||
|
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-gray-400">
|
||||||
|
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { formatPace, formatCadence } from '../../utils/format'
|
||||||
|
|
||||||
|
function downsample(points, maxPoints = 500) {
|
||||||
|
if (points.length <= maxPoints) return points
|
||||||
|
const step = Math.ceil(points.length / maxPoints)
|
||||||
|
return points.filter((_, i) => i % step === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(dataPoints, activeMetrics) {
|
||||||
|
return dataPoints
|
||||||
|
.filter(p => p.timestamp)
|
||||||
|
.map(p => {
|
||||||
|
const row = { distance_m: p.distance_m ?? 0 }
|
||||||
|
for (const key of activeMetrics) {
|
||||||
|
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
if (onHover) onHover(label)
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||||
|
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
|
||||||
|
{payload.map(entry => {
|
||||||
|
const metric = metrics.find(m => m.key === entry.dataKey)
|
||||||
|
if (!metric || entry.value == null) return null
|
||||||
|
let display = entry.value.toFixed(1)
|
||||||
|
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
|
||||||
|
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||||
|
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
||||||
|
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
|
||||||
|
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
||||||
|
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
|
||||||
|
return (
|
||||||
|
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||||
|
<span style={{ color: entry.color }}>●</span>
|
||||||
|
<span className="text-gray-300">{metric.label}:</span>
|
||||||
|
<span className="text-white font-medium">{display}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
|
||||||
|
const chartData = useMemo(() =>
|
||||||
|
downsample(buildChartData(dataPoints, activeMetrics)),
|
||||||
|
[dataPoints, activeMetrics]
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
|
||||||
|
|
||||||
|
const domains = useMemo(() => {
|
||||||
|
const result = {}
|
||||||
|
for (const m of activeMetricConfigs) {
|
||||||
|
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
|
||||||
|
if (!vals.length) continue
|
||||||
|
const min = Math.min(...vals)
|
||||||
|
const max = Math.max(...vals)
|
||||||
|
const pad = (max - min) * 0.1 || 1
|
||||||
|
// For elevation, don't start from 0 - show actual range
|
||||||
|
result[m.key] = [min - pad, max + pad]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [chartData, activeMetricConfigs])
|
||||||
|
|
||||||
|
if (!chartData.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
||||||
|
No timeline data available
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activeMetricConfigs.map((metric, idx) => {
|
||||||
|
const domain = domains[metric.key] || ['auto', 'auto']
|
||||||
|
const hasData = chartData.some(p => p[metric.key] != null)
|
||||||
|
if (!hasData) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={metric.key}>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
|
||||||
|
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="distance_m"
|
||||||
|
type="number"
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
|
||||||
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
hide={idx < activeMetricConfigs.length - 1}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={domain}
|
||||||
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={40}
|
||||||
|
tickFormatter={v => {
|
||||||
|
if (metric.key === 'speed_ms') {
|
||||||
|
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||||
|
const spm = 1000 / v
|
||||||
|
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
|
||||||
|
return Math.round(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric.key}
|
||||||
|
stroke={metric.color}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../../hooks/useAuth'
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
|
||||||
|
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
||||||
|
{ to: '/health', label: 'Health', icon: '❤️' },
|
||||||
|
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||||
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
|
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||||
|
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-950">
|
||||||
|
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
|
<div className="px-4 py-5 border-b border-gray-800">
|
||||||
|
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||||
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
|
</h1>
|
||||||
|
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 py-4 overflow-y-auto">
|
||||||
|
{nav.map(({ to, label, icon, exact }) => (
|
||||||
|
<NavLink key={to} to={to} end={exact}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
||||||
|
}`
|
||||||
|
}>
|
||||||
|
<span>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-4 py-4 border-t border-gray-800">
|
||||||
|
<button onClick={handleLogout}
|
||||||
|
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const accentColors = {
|
||||||
|
default: 'text-white',
|
||||||
|
red: 'text-red-400',
|
||||||
|
blue: 'text-blue-400',
|
||||||
|
green: 'text-green-400',
|
||||||
|
orange: 'text-orange-400',
|
||||||
|
purple: 'text-purple-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCard({ label, value, accent = 'default', sub }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||||
|
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
||||||
|
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
export const useAuthStore = create((set) => ({
|
||||||
|
token: localStorage.getItem('token'),
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
login: async (username, password) => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('username', username)
|
||||||
|
params.append('password', password)
|
||||||
|
const { data } = await api.post('/auth/token', params, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
})
|
||||||
|
localStorage.setItem('token', data.access_token)
|
||||||
|
set({ token: data.access_token, user: data, isLoading: false })
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
set({ isLoading: false })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
set({ token: null, user: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUser: async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/auth/me')
|
||||||
|
set({ user: data })
|
||||||
|
} catch {
|
||||||
|
set({ token: null, user: null })
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 60_000, retry: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import {
|
||||||
|
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||||
|
formatDate, sportIcon, sportColor,
|
||||||
|
} from '../utils/format'
|
||||||
|
|
||||||
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
|
export default function ActivitiesPage() {
|
||||||
|
const [sport, setSport] = useState('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data: activities, isLoading } = useQuery({
|
||||||
|
queryKey: ['activities', sport, page],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get('/activities/', {
|
||||||
|
params: {
|
||||||
|
sport_type: sport === 'all' ? undefined : sport,
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
}).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
|
<Link
|
||||||
|
to="/upload"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ Import
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sport filter */}
|
||||||
|
<div className="flex gap-2 mb-6 flex-wrap">
|
||||||
|
{SPORTS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setSport(s); setPage(1) }}
|
||||||
|
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
|
sport === s
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activities?.map(activity => (
|
||||||
|
<Link
|
||||||
|
key={activity.id}
|
||||||
|
to={`/activities/${activity.id}`}
|
||||||
|
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group"
|
||||||
|
>
|
||||||
|
{/* Sport indicator */}
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
|
||||||
|
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||||||
|
>
|
||||||
|
{sportIcon(activity.sport_type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + date */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
||||||
|
{activity.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
|
||||||
|
<p className="text-xs text-gray-600">distance</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-gray-200 font-medium">{formatDuration(activity.duration_s)}</p>
|
||||||
|
<p className="text-xs text-gray-600">time</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
|
||||||
|
<p className="text-xs text-gray-600">pace</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-red-400 font-medium">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||||
|
<p className="text-xs text-gray-600">avg HR</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-gray-200 font-medium">
|
||||||
|
{activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600">elev</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-gray-700 group-hover:text-gray-400 transition-colors ml-2">›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{activities?.length === 0 && (
|
||||||
|
<div className="text-center py-16 text-gray-600">
|
||||||
|
<p className="text-4xl mb-3">🏃</p>
|
||||||
|
<p className="text-lg">No activities yet</p>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{activities?.length === 20 && (
|
||||||
|
<div className="flex justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg disabled:opacity-30 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-4 py-2 text-sm text-gray-500">Page {page}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
|
import MetricTimeline from '../components/activity/MetricTimeline'
|
||||||
|
import HRZoneBar from '../components/activity/HRZoneBar'
|
||||||
|
import LapTable from '../components/activity/LapTable'
|
||||||
|
import StatCard from '../components/ui/StatCard'
|
||||||
|
import {
|
||||||
|
formatDuration, formatDistance, formatPace, formatElevation,
|
||||||
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
|
} from '../utils/format'
|
||||||
|
|
||||||
|
const METRICS = [
|
||||||
|
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||||
|
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
|
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||||
|
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||||
|
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
|
||||||
|
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ActivityDetailPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||||
|
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||||
|
const [mapHeight, setMapHeight] = useState(420)
|
||||||
|
const [mapType, setMapType] = useState('dark')
|
||||||
|
|
||||||
|
const { data: activity, isLoading } = useQuery({
|
||||||
|
queryKey: ['activity', id],
|
||||||
|
queryFn: () => api.get(`/activities/${id}`).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: dataPoints } = useQuery({
|
||||||
|
queryKey: ['activity-points', id],
|
||||||
|
queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data),
|
||||||
|
enabled: !!activity,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: laps } = useQuery({
|
||||||
|
queryKey: ['activity-laps', id],
|
||||||
|
queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data),
|
||||||
|
enabled: !!activity,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMetric = (key) => {
|
||||||
|
setActiveMetrics(prev =>
|
||||||
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which metrics have actual data
|
||||||
|
const availableMetrics = useMemo(() => {
|
||||||
|
if (!dataPoints?.length) return new Set()
|
||||||
|
return new Set(
|
||||||
|
METRICS
|
||||||
|
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
|
||||||
|
.map(m => m.key)
|
||||||
|
)
|
||||||
|
}, [dataPoints])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity…</div></div>
|
||||||
|
}
|
||||||
|
if (!activity) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{activity.name}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary stats */}
|
||||||
|
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||||
|
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||||
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||||
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||||
|
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||||
|
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary stats */}
|
||||||
|
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||||
|
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||||
|
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||||
|
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
||||||
|
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
||||||
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map with controls */}
|
||||||
|
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||||
|
{/* Map toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Map style:</span>
|
||||||
|
{['dark', 'street', 'satellite'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setMapType(t)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||||
|
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Height:</span>
|
||||||
|
{[280, 420, 560].map(h => (
|
||||||
|
<button
|
||||||
|
key={h}
|
||||||
|
onClick={() => setMapHeight(h)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||||
|
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: mapHeight }}>
|
||||||
|
<ActivityMap
|
||||||
|
polyline={activity.polyline}
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
hoveredDistance={hoveredDistance}
|
||||||
|
sportType={activity.sport_type}
|
||||||
|
mapType={mapType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HR Zones */}
|
||||||
|
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||||
|
<HRZoneBar zones={activity.hr_zones} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metric timeline */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleMetric(key)}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||||
|
activeMetrics.includes(key)
|
||||||
|
? 'border-transparent text-white'
|
||||||
|
: 'border-gray-700 text-gray-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
style={activeMetrics.includes(key) ? { backgroundColor: color + '33', borderColor: color, color } : {}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dataPoints && dataPoints.length > 0 ? (
|
||||||
|
<MetricTimeline
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||||
|
metrics={METRICS}
|
||||||
|
onHoverDistance={setHoveredDistance}
|
||||||
|
sportType={activity.sport_type}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Laps */}
|
||||||
|
{laps && laps.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||||
|
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
|
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import StatCard from '../components/ui/StatCard'
|
||||||
|
import {
|
||||||
|
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||||
|
formatDate, sportIcon, formatSleep,
|
||||||
|
} from '../utils/format'
|
||||||
|
|
||||||
|
function WeeklyChart({ activities }) {
|
||||||
|
if (!activities?.length) return (
|
||||||
|
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build last 8 weeks in chronological order
|
||||||
|
const now = new Date()
|
||||||
|
const weeks = eachWeekOfInterval({
|
||||||
|
start: subWeeks(startOfWeek(now), 7),
|
||||||
|
end: startOfWeek(now),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = weeks.map(weekStart => {
|
||||||
|
const weekKey = format(weekStart, 'MMM d')
|
||||||
|
const weekEnd = new Date(weekStart)
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||||
|
const km = activities
|
||||||
|
.filter(a => {
|
||||||
|
const t = new Date(a.start_time)
|
||||||
|
return t >= weekStart && t < weekEnd
|
||||||
|
})
|
||||||
|
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||||
|
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
|
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||||
|
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
|
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
||||||
|
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: recentActivities } = useQuery({
|
||||||
|
queryKey: ['activities-recent'],
|
||||||
|
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: allActivities } = useQuery({
|
||||||
|
queryKey: ['activities-all-chart'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get('/activities/', {
|
||||||
|
params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
|
||||||
|
}).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: healthSummary } = useQuery({
|
||||||
|
queryKey: ['health-summary'],
|
||||||
|
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: records } = useQuery({
|
||||||
|
queryKey: ['records-running'],
|
||||||
|
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = healthSummary?.latest
|
||||||
|
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
|
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
||||||
|
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
||||||
|
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||||
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
|
||||||
|
<WeeklyChart activities={allActivities} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||||
|
{latest ? (
|
||||||
|
<>
|
||||||
|
{[
|
||||||
|
['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'],
|
||||||
|
['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'],
|
||||||
|
['Steps', latest.steps?.toLocaleString() ?? '--'],
|
||||||
|
['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'],
|
||||||
|
['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="text-white">{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard →</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent activities */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Recent activities</h3>
|
||||||
|
<Link to="/activities" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentActivities?.slice(0, 5).map(activity => (
|
||||||
|
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||||
|
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
|
||||||
|
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-sm text-right">
|
||||||
|
<div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
|
||||||
|
<div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
|
||||||
|
<div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!recentActivities?.length && (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-8">
|
||||||
|
No activities yet — <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{records?.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Running PRs</h3>
|
||||||
|
<Link to="/records" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{records.slice(0, 5).map(rec => (
|
||||||
|
<div key={rec.id} className="bg-gray-800/60 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">{rec.distance_label}</p>
|
||||||
|
<p className="font-mono font-semibold text-yellow-400">{formatDuration(rec.duration_s)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
LineChart, Line, AreaChart, Area, BarChart, Bar,
|
||||||
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { format, subDays } from 'date-fns'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import StatCard from '../components/ui/StatCard'
|
||||||
|
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ label: '1W', days: 7 },
|
||||||
|
{ label: '2W', days: 14 },
|
||||||
|
{ label: '1M', days: 30 },
|
||||||
|
{ label: '3M', days: 90 },
|
||||||
|
{ label: '6M', days: 180 },
|
||||||
|
{ label: '1Y', days: 365 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
|
||||||
|
|
||||||
|
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
||||||
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
|
if (!vals.length) return (
|
||||||
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
|
tickFormatter={formatter} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
|
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||||
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||||
|
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SleepChart({ data }) {
|
||||||
|
const chartData = data.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
|
||||||
|
rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
|
||||||
|
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
||||||
|
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
||||||
|
}))
|
||||||
|
const hasData = chartData.some(d => d.deep || d.rem || d.light)
|
||||||
|
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
||||||
|
tickFormatter={v => `${v}h`} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
|
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||||
|
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||||
|
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
||||||
|
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const [rangeDays, setRangeDays] = useState(7) // default 1 week
|
||||||
|
|
||||||
|
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
|
||||||
|
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: ['health-summary'],
|
||||||
|
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: metrics, isLoading } = useQuery({
|
||||||
|
queryKey: ['health-metrics', rangeDays],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get('/health-metrics/', {
|
||||||
|
params: { from_date: fromDate, limit: rangeDays + 1 },
|
||||||
|
}).then(r => r.data.slice().reverse()), // oldest first for charts
|
||||||
|
keepPreviousData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = summary?.latest
|
||||||
|
const avg30 = summary?.avg_30d
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
|
||||||
|
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
|
||||||
|
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
||||||
|
sub={latest?.hrv_status || undefined} />
|
||||||
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
|
||||||
|
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
|
||||||
|
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
|
||||||
|
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
|
||||||
|
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
||||||
|
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
|
||||||
|
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
||||||
|
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
|
||||||
|
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
|
||||||
|
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Range selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{RANGES.map(({ label, days }) => (
|
||||||
|
<button key={label} onClick={() => setRangeDays(days)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||||
|
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
) : metrics && metrics.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||||
|
formatter={v => `${Math.round(v)} bpm`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
|
formatter={v => `${Math.round(v)} ms`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
|
||||||
|
<SleepChart data={metrics} />
|
||||||
|
<div className="flex gap-4 mt-2">
|
||||||
|
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
|
||||||
|
<div key={l} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||||
|
<span className="text-xs text-gray-400">{l}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
|
||||||
|
formatter={v => `${v.toFixed(1)} kg`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
|
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
|
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||||
|
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||||
|
formatter={v => `${Math.round(v)} bpm`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||||
|
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||||
|
formatter={v => Math.round(v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-gray-600">
|
||||||
|
<p className="text-4xl mb-3">📊</p>
|
||||||
|
<p className="text-lg">No health data for this period</p>
|
||||||
|
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const { login, isLoading } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: pocketidData } = useQuery({
|
||||||
|
queryKey: ['pocketid-available'],
|
||||||
|
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await login(username, password)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Login failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePocketID = async () => {
|
||||||
|
const { data } = await api.get('/auth/pocketid/login-url')
|
||||||
|
window.location.href = data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">Your personal fitness dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-2xl p-6 border border-gray-800">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{pocketidData?.available && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 my-4">
|
||||||
|
<div className="flex-1 h-px bg-gray-800" />
|
||||||
|
<span className="text-xs text-gray-600">or</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePocketID}
|
||||||
|
className="w-full bg-gray-800 hover:bg-gray-700 text-gray-300 font-medium py-2.5 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
🔑 Sign in with passkey
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||||
|
return (
|
||||||
|
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button onClick={onClick} disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{loading ? 'Saving…' : label}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-green-400 text-sm">✓ Saved</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: pocketidConfig } = useQuery({
|
||||||
|
queryKey: ['pocketid-config'],
|
||||||
|
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
|
||||||
|
enabled: !!user?.is_admin,
|
||||||
|
})
|
||||||
|
|
||||||
|
// HR / measurements form
|
||||||
|
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
|
||||||
|
const [hrSaved, setHrSaved] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile) setHrForm({
|
||||||
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
|
resting_heart_rate: profile.resting_heart_rate || '',
|
||||||
|
birth_year: profile.birth_year || '',
|
||||||
|
height_cm: profile.height_cm || '',
|
||||||
|
})
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
const updateProfile = useMutation({
|
||||||
|
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Weight log
|
||||||
|
const { data: weightLog } = useQuery({
|
||||||
|
queryKey: ['weight-log'],
|
||||||
|
queryFn: () => api.get('/profile/weight').then(r => r.data),
|
||||||
|
})
|
||||||
|
const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
|
||||||
|
const [weightSaved, setWeightSaved] = useState(false)
|
||||||
|
const addWeight = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
|
||||||
|
})
|
||||||
|
const deleteWeight = useMutation({
|
||||||
|
mutationFn: id => api.delete(`/profile/weight/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||||
|
const [pwError, setPwError] = useState('')
|
||||||
|
const [pwSaved, setPwSaved] = useState(false)
|
||||||
|
const changePassword = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
|
||||||
|
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
|
||||||
|
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// PocketID config
|
||||||
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||||
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
||||||
|
}, [pocketidConfig])
|
||||||
|
const savePocketID = useMutation({
|
||||||
|
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||||
|
|
||||||
|
{/* HR & Measurements */}
|
||||||
|
<Section title="Heart Rate & Measurements">
|
||||||
|
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
|
||||||
|
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
|
||||||
|
{effectiveMaxHr && (
|
||||||
|
<div className="mt-2 text-white">
|
||||||
|
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
|
||||||
|
{!profile?.max_heart_rate && ' (estimated from age)'}
|
||||||
|
{' · '}Zones: Z1 <{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)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||||
|
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
||||||
|
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||||
|
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Height (cm)">
|
||||||
|
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||||
|
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => updateProfile.mutate(Object.fromEntries(
|
||||||
|
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
||||||
|
))}
|
||||||
|
loading={updateProfile.isPending}
|
||||||
|
saved={hrSaved}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Weight log */}
|
||||||
|
<Section title="Weight Log">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Field label="Weight (kg)">
|
||||||
|
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Body fat % (optional)">
|
||||||
|
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Date">
|
||||||
|
<Input type="datetime-local" value={weightForm.date}
|
||||||
|
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => addWeight.mutate({
|
||||||
|
weight_kg: parseFloat(weightForm.weight_kg),
|
||||||
|
body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
|
||||||
|
date: new Date(weightForm.date).toISOString(),
|
||||||
|
})}
|
||||||
|
loading={addWeight.isPending}
|
||||||
|
saved={weightSaved}
|
||||||
|
label="Log weight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{weightLog && weightLog.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{weightLog.slice(0, 20).map(entry => (
|
||||||
|
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
||||||
|
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||||
|
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
||||||
|
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
|
<button onClick={() => deleteWeight.mutate(entry.id)}
|
||||||
|
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Password change */}
|
||||||
|
<Section title="Change Password">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Current password">
|
||||||
|
<Input type="password" value={pwForm.current_password}
|
||||||
|
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
|
||||||
|
</Field>
|
||||||
|
<Field label="New password (min 8 characters)">
|
||||||
|
<Input type="password" value={pwForm.new_password}
|
||||||
|
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Confirm new password">
|
||||||
|
<Input type="password" value={pwForm.confirm}
|
||||||
|
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => {
|
||||||
|
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
|
||||||
|
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
|
||||||
|
}}
|
||||||
|
loading={changePassword.isPending}
|
||||||
|
saved={pwSaved}
|
||||||
|
label="Change password"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* PocketID — admin only */}
|
||||||
|
{user?.is_admin && (
|
||||||
|
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
|
||||||
|
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Client ID">
|
||||||
|
<Input value={pidForm.client_id} placeholder="milevault"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Client secret" hint="Leave blank to keep existing secret">
|
||||||
|
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||||
|
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
{pocketidConfig?.enabled && (
|
||||||
|
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => savePocketID.mutate(pidForm)}
|
||||||
|
loading={savePocketID.isPending}
|
||||||
|
saved={pidSaved}
|
||||||
|
label="Save PocketID config"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { formatDuration, formatDate } from '../utils/format'
|
||||||
|
|
||||||
|
const SPORTS = ['running', 'cycling', 'swimming']
|
||||||
|
|
||||||
|
const DISTANCE_ORDER = [
|
||||||
|
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
||||||
|
'Half marathon', 'Marathon', '50k', '100k',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function RecordsPage() {
|
||||||
|
const [sport, setSport] = useState('running')
|
||||||
|
const [selectedDistance, setSelectedDistance] = useState(null)
|
||||||
|
|
||||||
|
const { data: records } = useQuery({
|
||||||
|
queryKey: ['records', sport],
|
||||||
|
queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: history } = useQuery({
|
||||||
|
queryKey: ['record-history', selectedDistance, sport],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, {
|
||||||
|
params: { sport_type: sport },
|
||||||
|
}).then(r => r.data),
|
||||||
|
enabled: !!selectedDistance,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by standard distance order
|
||||||
|
const sortedRecords = records?.slice().sort((a, b) => {
|
||||||
|
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
||||||
|
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
||||||
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
|
||||||
|
|
||||||
|
{/* Sport selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{SPORTS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setSport(s); setSelectedDistance(null) }}
|
||||||
|
className={`capitalize text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
||||||
|
sport === s
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedRecords?.length === 0 && (
|
||||||
|
<div className="text-center py-16 text-gray-600">
|
||||||
|
<p className="text-4xl mb-3">🏆</p>
|
||||||
|
<p>No records yet — import activities to track your best times</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Records table */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Distance</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Best time</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Date</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedRecords?.map(rec => (
|
||||||
|
<tr
|
||||||
|
key={rec.id}
|
||||||
|
onClick={() => setSelectedDistance(rec.distance_label)}
|
||||||
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||||
|
selectedDistance === rec.distance_label
|
||||||
|
? 'bg-blue-900/20'
|
||||||
|
: 'hover:bg-gray-800/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
|
{formatDuration(rec.duration_s)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatDate(rec.achieved_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link
|
||||||
|
to={`/activities/${rec.activity_id}`}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-xs text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress chart */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
{selectedDistance && history ? (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-gray-300 mb-1">
|
||||||
|
{selectedDistance} progression
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
||||||
|
{history.length > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart
|
||||||
|
data={history.map(h => ({
|
||||||
|
date: h.achieved_at,
|
||||||
|
time: h.duration_s,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={d => format(new Date(d), 'MMM yy')}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={40}
|
||||||
|
tickFormatter={formatDuration}
|
||||||
|
reversed
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
|
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
|
formatter={v => [formatDuration(v), 'Time']}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="time"
|
||||||
|
stroke="#fbbf24"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#fbbf24', r: 4 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
||||||
|
Only one record — complete more activities to see progression
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||||
|
Select a distance to see your progression
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||||
|
|
||||||
|
export default function RoutesPage() {
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const { data: routes } = useQuery({
|
||||||
|
queryKey: ['routes'],
|
||||||
|
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: routeActivities } = useQuery({
|
||||||
|
queryKey: ['route-activities', selected?.id],
|
||||||
|
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||||
|
enabled: !!selected,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: recentActivities } = useQuery({
|
||||||
|
queryKey: ['recent-activities-for-route'],
|
||||||
|
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
|
||||||
|
enabled: showCreate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createRoute = useMutation({
|
||||||
|
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
||||||
|
onSuccess: (route) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewRoute({ name: '', activity_id: '' })
|
||||||
|
setSelected(route)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fastest = routeActivities?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
|
+ New route
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create route */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||||
|
<input value={newRoute.name}
|
||||||
|
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g. Morning park loop" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||||
|
{recentActivities?.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={newRoute.activity_id}
|
||||||
|
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select an activity…</option>
|
||||||
|
{recentActivities?.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||||
|
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowCreate(false)}
|
||||||
|
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Route list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{routes?.length === 0 && !showCreate && (
|
||||||
|
<div className="text-center py-12 text-gray-600">
|
||||||
|
<p className="text-3xl mb-2">🗺️</p>
|
||||||
|
<p className="text-sm">No named routes yet</p>
|
||||||
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{routes?.map(route => (
|
||||||
|
<button key={route.id} onClick={() => setSelected(route)}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||||
|
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<p className="font-medium text-white">{route.name}</p>
|
||||||
|
{route.auto_detected && (
|
||||||
|
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
<span>{formatDistance(route.distance_m)}</span>
|
||||||
|
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||||
|
<span>{formatDate(route.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route detail */}
|
||||||
|
{selected && (
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||||
|
{selected.auto_detected && (
|
||||||
|
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
|
||||||
|
Auto-detected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fastest && (
|
||||||
|
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||||
|
All runs ({routeActivities?.length ?? 0})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{routeActivities?.map((act, i) => (
|
||||||
|
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
|
||||||
|
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
||||||
|
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||||||
|
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||||||
|
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||||
|
{act.avg_heart_rate && (
|
||||||
|
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||||
|
)}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async (file) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const { data } = await api.post(endpoint, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
return { file: file.name, ...data }
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTasks(t => [...t, { ...data, status: 'queued' }])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onDrop = useCallback((accepted) => {
|
||||||
|
accepted.forEach(file => upload.mutate(file))
|
||||||
|
}, [upload])
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept,
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span className="text-2xl">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||||
|
isDragActive
|
||||||
|
? 'border-blue-500 bg-blue-950/30'
|
||||||
|
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
{isDragActive ? (
|
||||||
|
<p className="text-blue-400 text-sm">Drop files here…</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
|
||||||
|
<p className="text-gray-600 text-xs mt-1">
|
||||||
|
{Object.values(accept).flat().join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upload.isPending && (
|
||||||
|
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{tasks.map((task, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||||||
|
{task.activity_tasks !== undefined && (
|
||||||
|
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-green-400">✓ Queued</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
Import activities from Garmin or Strava. Large exports are processed in the background.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How to export guides */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
|
||||||
|
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
|
||||||
|
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||||||
|
<li>Go to Garmin Connect → Profile → Account</li>
|
||||||
|
<li>Scroll to Data Management → Export Your Data</li>
|
||||||
|
<li>Request export and wait for the email</li>
|
||||||
|
<li>Download and upload the ZIP file below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
|
||||||
|
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
|
||||||
|
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||||||
|
<li>Go to strava.com → Settings → My Account</li>
|
||||||
|
<li>Scroll to Download or Delete Your Account</li>
|
||||||
|
<li>Click "Request Your Archive"</li>
|
||||||
|
<li>Download and upload the ZIP file below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{/* Single FIT/GPX */}
|
||||||
|
<UploadZone
|
||||||
|
title="Single activity"
|
||||||
|
description="Upload a .fit or .gpx file"
|
||||||
|
icon="🏃"
|
||||||
|
endpoint="/upload/activity"
|
||||||
|
accept={{
|
||||||
|
'application/octet-stream': ['.fit'],
|
||||||
|
'application/gpx+xml': ['.gpx'],
|
||||||
|
'text/xml': ['.gpx'],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Garmin full export */}
|
||||||
|
<UploadZone
|
||||||
|
title="Garmin Connect export"
|
||||||
|
description="Upload your full Garmin data export ZIP"
|
||||||
|
icon="⌚"
|
||||||
|
endpoint="/upload/garmin-export"
|
||||||
|
accept={{ 'application/zip': ['.zip'] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Strava export */}
|
||||||
|
<UploadZone
|
||||||
|
title="Strava bulk export"
|
||||||
|
description="Upload your Strava archive ZIP"
|
||||||
|
icon="🚴"
|
||||||
|
endpoint="/upload/strava-export"
|
||||||
|
accept={{ 'application/zip': ['.zip'] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ongoing FIT files */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span className="text-2xl">🔄</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Ongoing sync</h3>
|
||||||
|
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-xs text-gray-500">
|
||||||
|
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
|
||||||
|
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
|
||||||
|
GARMIN/Activity/*.fit
|
||||||
|
</code>
|
||||||
|
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
|
||||||
|
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
|
||||||
|
{`# Example: auto-upload new FIT files
|
||||||
|
inotifywait -m ~/Garmin/Activity/ -e create \\
|
||||||
|
--format '%f' | while read file; do
|
||||||
|
curl -X POST /api/upload/activity \\
|
||||||
|
-H "Authorization: Bearer TOKEN" \\
|
||||||
|
-F "file=@$file"
|
||||||
|
done`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '--'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPace(speedMs, sportType = 'running') {
|
||||||
|
if (!speedMs || speedMs <= 0) return '--'
|
||||||
|
if (sportType === 'cycling') {
|
||||||
|
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||||
|
}
|
||||||
|
const secsPerKm = 1000 / speedMs
|
||||||
|
const mins = Math.floor(secsPerKm / 60)
|
||||||
|
const secs = Math.floor(secsPerKm % 60)
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')} /km`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistance(metres) {
|
||||||
|
if (!metres) return '--'
|
||||||
|
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
||||||
|
return `${Math.round(metres)} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElevation(metres) {
|
||||||
|
if (metres == null) return '--'
|
||||||
|
return `${Math.round(metres)} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHeartRate(bpm) {
|
||||||
|
if (!bpm) return '--'
|
||||||
|
return `${Math.round(bpm)} bpm`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSleep(seconds) {
|
||||||
|
if (!seconds) return '--'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.round((seconds % 3600) / 60)
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeight(kg) {
|
||||||
|
if (!kg) return '--'
|
||||||
|
return `${kg.toFixed(1)} kg`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '--'
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(dateStr) {
|
||||||
|
if (!dateStr) return '--'
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCadence(value, sportType) {
|
||||||
|
if (!value) return '--'
|
||||||
|
// Garmin stores running cadence as steps per minute / 2 (one foot)
|
||||||
|
// We need to double it to get total steps per minute (both feet)
|
||||||
|
if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
|
||||||
|
return `${Math.round(value * 2)} spm`
|
||||||
|
}
|
||||||
|
// Cycling is already in rpm
|
||||||
|
return `${Math.round(value)} rpm`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrZoneColor(zone) {
|
||||||
|
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
|
||||||
|
return colors[zone] || '#9ca3af'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sportIcon(sportType) {
|
||||||
|
const icons = {
|
||||||
|
running: '🏃', cycling: '🚴', hiking: '🥾',
|
||||||
|
walking: '🚶', other: '⚡',
|
||||||
|
}
|
||||||
|
return icons[sportType?.toLowerCase()] || '⚡'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sportColor(sportType) {
|
||||||
|
const colors = {
|
||||||
|
running: '#3b82f6', cycling: '#f97316',
|
||||||
|
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
||||||
|
}
|
||||||
|
return colors[sportType?.toLowerCase()] || '#6b7280'
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Executable
+209
@@ -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 ""
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[milevault]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[milevault]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[milevault]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
check_env() {
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
warn ".env not found — copying from .env.example"
|
||||||
|
cp .env.example .env
|
||||||
|
warn "Please edit .env and set required secrets, then re-run this script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
source .env
|
||||||
|
[ -z "${DB_PASSWORD:-}" ] && error "DB_PASSWORD must be set in .env"
|
||||||
|
[ -z "${SECRET_KEY:-}" ] && error "SECRET_KEY must be set in .env (use: openssl rand -hex 32)"
|
||||||
|
[ -z "${ADMIN_PASSWORD:-}" ] && error "ADMIN_PASSWORD must be set in .env"
|
||||||
|
info "Environment looks good"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_secrets() {
|
||||||
|
info "Generating .env from template..."
|
||||||
|
cp .env.example .env
|
||||||
|
SECRET=$(openssl rand -hex 32)
|
||||||
|
DB_PASS=$(openssl rand -base64 16 | tr -d '/+=')
|
||||||
|
ADMIN_PASS=$(openssl rand -base64 12 | tr -d '/+=')
|
||||||
|
REDIS_PASS=$(openssl rand -base64 12 | tr -d '/+=')
|
||||||
|
|
||||||
|
sed -i "s/changeme_generate_with_openssl_rand_hex_32/$SECRET/" .env
|
||||||
|
sed -i "s/changeme_strong_password/$DB_PASS/" .env
|
||||||
|
sed -i "s/changeme_admin_password/$ADMIN_PASS/" .env
|
||||||
|
sed -i "s/redispass/$REDIS_PASS/" .env
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Generated secrets:${NC}"
|
||||||
|
echo " Admin username: admin"
|
||||||
|
echo " Admin password: $ADMIN_PASS"
|
||||||
|
echo " (saved to .env)"
|
||||||
|
echo ""
|
||||||
|
warn "Save these credentials! The admin password won't be shown again."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
check_env
|
||||||
|
info "Starting MileVault..."
|
||||||
|
docker compose up -d --build
|
||||||
|
info "Started! Visit http://localhost:${HTTP_PORT:-80}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
info "Stopping MileVault..."
|
||||||
|
docker compose down
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
docker compose logs -f "${1:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_setup() {
|
||||||
|
info "First-time setup"
|
||||||
|
generate_secrets
|
||||||
|
cmd_start
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_backup() {
|
||||||
|
source .env
|
||||||
|
BACKUP_FILE="milevault_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||||
|
info "Backing up database to $BACKUP_FILE..."
|
||||||
|
docker compose exec -T db pg_dump \
|
||||||
|
-U "${DB_USER:-milevault}" milevault > "$BACKUP_FILE"
|
||||||
|
info "Backup saved: $BACKUP_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_restore() {
|
||||||
|
[ -z "${1:-}" ] && error "Usage: $0 restore <backup.sql>"
|
||||||
|
source .env
|
||||||
|
info "Restoring from $1..."
|
||||||
|
docker compose exec -T db psql \
|
||||||
|
-U "${DB_USER:-milevault}" milevault < "$1"
|
||||||
|
info "Restore complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_update() {
|
||||||
|
info "Pulling latest and rebuilding..."
|
||||||
|
git pull
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
info "Update complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-help}" in
|
||||||
|
setup) cmd_setup ;;
|
||||||
|
start) cmd_start ;;
|
||||||
|
stop) cmd_stop ;;
|
||||||
|
restart) cmd_stop; cmd_start ;;
|
||||||
|
logs) cmd_logs "${2:-}" ;;
|
||||||
|
backup) cmd_backup ;;
|
||||||
|
restore) cmd_restore "${2:-}" ;;
|
||||||
|
update) cmd_update ;;
|
||||||
|
*)
|
||||||
|
echo "MileVault management script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 <command>"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " setup First-time setup (generates secrets, starts containers)"
|
||||||
|
echo " start Start all containers"
|
||||||
|
echo " stop Stop all containers"
|
||||||
|
echo " restart Restart all containers"
|
||||||
|
echo " logs Follow logs (optionally: logs backend)"
|
||||||
|
echo " backup Backup PostgreSQL database"
|
||||||
|
echo " restore Restore from backup: restore <file.sql>"
|
||||||
|
echo " update Pull and rebuild"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user