diff --git a/CLAUDE.md b/CLAUDE.md index 361649e..8d0cbe9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,12 +104,12 @@ Required in `.env` (or passed to Docker Compose): | `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | -## Debugging and troubleshooting -- The latest build will always be running in docker at ~/milevault_docker with the following container names: +## Rules +- The current build will always be running in docker at ~/milevault_docker with the following container names: `milevault_backend` `milevault_db` `milevault_frontend` `milevault_redis` `milevault_worker` -- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, rectify the issues in ~/milevault where the development is happening +- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, use these for finding the problem, rectify the issues in ~/milevault project without running the updated versions, push to git instead. - Do NOT patch the running files under any circumstances, fix the development files. \ No newline at end of file diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 1087659..78f38ee 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select, desc, func from pydantic import BaseModel from typing import Optional, List from datetime import datetime, timedelta, timezone @@ -36,6 +36,7 @@ class RouteOut(BaseModel): distance_m: Optional[float] auto_detected: Optional[bool] created_at: datetime + activity_count: int = 0 class Config: from_attributes = True @@ -48,6 +49,7 @@ class SegmentOut(BaseModel): end_distance_m: float description: Optional[str] auto_generated: Optional[bool] = False + auto_generated_type: Optional[str] = None class Config: from_attributes = True @@ -71,12 +73,26 @@ async def list_routes( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + # Fetch routes with activity counts in one query + count_subq = ( + select(Activity.named_route_id, func.count(Activity.id).label("cnt")) + .where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None)) + .group_by(Activity.named_route_id) + .subquery() + ) result = await db.execute( - select(NamedRoute) + select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count")) + .outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id) .where(NamedRoute.user_id == current_user.id) .order_by(desc(NamedRoute.created_at)) ) - return result.scalars().all() + rows = result.all() + out = [] + for route, cnt in rows: + d = {c.name: getattr(route, c.name) for c in route.__table__.columns} + d["activity_count"] = cnt + out.append(RouteOut(**d)) + return out @router.get("/recent-activities") @@ -352,11 +368,12 @@ async def auto_generate_segments( if body.type not in ("1km", "turns", "hills"): raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'") - # Clear existing auto-generated segments of this type + # Clear only auto-generated segments of the same type so other auto types are preserved await db.execute( sql_delete(RouteSegment).where( RouteSegment.route_id == route_id, RouteSegment.auto_generated == True, + RouteSegment.auto_generated_type == body.type, ) ) @@ -403,6 +420,7 @@ async def auto_generate_segments( start_distance_m=start_m, end_distance_m=end_m, auto_generated=True, + auto_generated_type=body.type, ) db.add(seg) new_segments.append(seg) diff --git a/backend/app/main.py b/backend/app/main.py index 6539034..e2dd65f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -69,9 +69,35 @@ async def init_db(): await conn.execute(text( "ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" )) + await conn.execute(text( + "ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)" + )) except Exception as e: print(f"route_segments column migration skipped: {e}") + # Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days + try: + async with engine.begin() as conn: + await conn.execute(text(""" + UPDATE health_metrics SET + avg_hr_day = sub.avg_hr, + max_hr_day = sub.max_hr + FROM ( + SELECT id, + AVG((elem->>1)::float) AS avg_hr, + MAX((elem->>1)::float) AS max_hr + FROM health_metrics, + json_array_elements(intraday_hr) AS elem + WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL) + AND intraday_hr IS NOT NULL + AND (elem->>1)::float > 0 + GROUP BY id + ) sub + WHERE health_metrics.id = sub.id + """)) + except Exception as e: + print(f"avg_hr_day backfill skipped: {e}") + # Replace the all-columns unique constraint on personal_records with a partial # index (only current records must be unique per user/sport/distance). # The old constraint also covered is_current_record=False rows, causing diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 86aed53..b751a7f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -182,6 +182,7 @@ class RouteSegment(Base): end_distance_m = Column(Float, nullable=False) description = Column(Text, nullable=True) auto_generated = Column(Boolean, default=False) + auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills' route = relationship("NamedRoute", back_populates="segments") diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index b62d2cc..1e02c73 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -258,13 +258,17 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, if bb: row["body_battery"] = _json.dumps(bb) - # Intraday heart rate — store non-null [epoch_ms, bpm] pairs + # Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages intraday = None if hr_raw: raw_vals = hr_raw.get("heartRateValues") or [] intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None] if intraday: row["intraday_hr"] = intraday + hr_vals = [v for _, v in intraday if v > 0] + if hr_vals: + row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1) + row["max_hr_day"] = float(max(hr_vals)) # High-resolution body battery derived from BB checkpoints + intraday HR if bb and intraday: diff --git a/frontend/src/components/ui/RouteMiniMap.jsx b/frontend/src/components/ui/RouteMiniMap.jsx index bb3bd39..b448bb7 100644 --- a/frontend/src/components/ui/RouteMiniMap.jsx +++ b/frontend/src/components/ui/RouteMiniMap.jsx @@ -16,36 +16,94 @@ function decodePolyline(encoded) { return coords } +function haversineDist([lat1, lng1], [lat2, lng2]) { + const R = 6371000 + const dLat = (lat2 - lat1) * Math.PI / 180 + const dLng = (lng2 - lng1) * Math.PI / 180 + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2 + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) +} + // Internal viewBox dimensions — path is always drawn into this space, SVG scales it const VW = 100 const VH = 80 const PAD = 6 -export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60 }) { - const pathD = useMemo(() => { - if (!polyline) return null - const coords = decodePolyline(polyline) - if (coords.length < 2) return null +function buildPaths(polyline, segStartM, segEndM) { + if (!polyline) return null + const coords = decodePolyline(polyline) + if (coords.length < 2) return null - const lats = coords.map(c => c[0]) - const lngs = coords.map(c => c[1]) - const minLat = Math.min(...lats), maxLat = Math.max(...lats) - const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) - const latRange = maxLat - minLat || 0.001 - const lngRange = maxLng - minLng || 0.001 + const lats = coords.map(c => c[0]) + const lngs = coords.map(c => c[1]) + const minLat = Math.min(...lats), maxLat = Math.max(...lats) + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) + const latRange = maxLat - minLat || 0.001 + const lngRange = maxLng - minLng || 0.001 - const drawW = VW - PAD * 2 - const drawH = VH - PAD * 2 - const scale = Math.min(drawW / lngRange, drawH / latRange) - const offX = PAD + (drawW - lngRange * scale) / 2 - const offY = PAD + (drawH - latRange * scale) / 2 + const drawW = VW - PAD * 2 + const drawH = VH - PAD * 2 + const scale = Math.min(drawW / lngRange, drawH / latRange) + const offX = PAD + (drawW - lngRange * scale) / 2 + const offY = PAD + (drawH - latRange * scale) / 2 - return coords.map((c, i) => { - const x = offX + (c[1] - minLng) * scale - const y = offY + (maxLat - c[0]) * scale - return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}` - }).join(' ') - }, [polyline]) + const toXY = ([lat, lng]) => [ + offX + (lng - minLng) * scale, + offY + (maxLat - lat) * scale, + ] + + const fullPath = coords.map((c, i) => { + const [x, y] = toXY(c) + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}` + }).join(' ') + + if (segStartM == null || segEndM == null) return { fullPath, segPath: null } + + // Compute cumulative distances to find segment slice + const cumDist = [0] + for (let i = 1; i < coords.length; i++) { + cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i])) + } + const totalDist = cumDist[cumDist.length - 1] || 1 + + // Interpolate a point at a given distance along the route + const interpAt = (targetM) => { + for (let i = 1; i < cumDist.length; i++) { + if (cumDist[i] >= targetM || i === cumDist.length - 1) { + const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) + const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0]) + const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1]) + return [lat, lng] + } + } + return coords[coords.length - 1] + } + + const clampedStart = Math.max(0, Math.min(segStartM, totalDist)) + const clampedEnd = Math.max(0, Math.min(segEndM, totalDist)) + + // Collect segment points: interpolated start + all interior coords + interpolated end + const segCoords = [interpAt(clampedStart)] + for (let i = 0; i < coords.length; i++) { + if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) { + segCoords.push(coords[i]) + } + } + segCoords.push(interpAt(clampedEnd)) + + const segPath = segCoords.map((c, i) => { + const [x, y] = toXY(c) + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}` + }).join(' ') + + return { fullPath, segPath } +} + +export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) { + const paths = useMemo( + () => buildPaths(polyline, segmentStartM, segmentEndM), + [polyline, segmentStartM, segmentEndM], + ) const svgProps = { viewBox: `0 0 ${VW} ${VH}`, @@ -54,15 +112,22 @@ export default function RouteMiniMap({ polyline, sportType, width = 80, height = style: { background: '#111827', width, height }, } - if (!pathD) return ( + if (!paths) return ( ) + const baseColor = paths.segPath ? '#374151' : sportColor(sportType) + return ( ) } diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 6e81072..7facf58 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -153,16 +153,71 @@ function BodyBatteryChart({ bb, hiresValues }) { ) } -function SleepStagesBar({ deep, light, rem, awake }) { - const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0) - if (!total) return null - const pct = s => `${((s || 0) / total * 100).toFixed(1)}%` +// Sleep timeline bar spanning from sleep_start to sleep_end with proportional stage coloring +function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) { + if (!sleepStart || !sleepEnd) return null + const stageSecs = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0) + if (!stageSecs) return null + + const startMs = new Date(sleepStart).getTime() + const endMs = new Date(sleepEnd).getTime() + const windowMs = endMs - startMs + if (windowMs <= 0) return null + + // Build stage segments proportional to duration, but rendered across the sleep window + const stages = [ + { key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' }, + { key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' }, + { key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' }, + { key: 'awake', secs: awake || 0, color: '#374151', label: 'Awake' }, + ].filter(s => s.secs > 0) + + // Generate hour tick marks within the sleep window + const startHour = new Date(startMs) + startHour.setMinutes(0, 0, 0) + startHour.setHours(startHour.getHours() + 1) + const ticks = [] + let tick = startHour.getTime() + while (tick < endMs) { + const pct = Math.min(100, Math.max(0, (tick - startMs) / windowMs * 100)) + ticks.push({ pct, label: new Date(tick).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) }) + tick += 3600000 + } + return ( -
+
{formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)} ({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})
+ {/* Times preview row */} + {!timesLoading && ( +Loading times…
}Best
-{formatDuration(bestTime)}
-Last
-{formatDuration(lastTime)}
-Loading times…
} - {!timesLoading && !times?.length && ( -No times recorded yet
- )} - {times?.length > 0 && ( -{r.name}
- {r.distance_m && ( -{(r.distance_m / 1000).toFixed(1)} km
- )} +{(r.distance_m / 1000).toFixed(1)} km
+ )} + {r.activity_count > 0 && ( +{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}
+ )} +{selectedRoute.sport_type && {selectedRoute.sport_type}} {selectedRoute.distance_m && · {formatDistance(selectedRoute.distance_m)}} + {selectedRoute.activity_count > 0 && · {selectedRoute.activity_count} runs} {selectedRoute.auto_detected && (auto-detected)}
Auto-generate replaces previously auto-generated segments. Manual segments are kept.
+Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.