diff --git a/CLAUDE.md b/CLAUDE.md index fa25c15..361649e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,3 +103,13 @@ Required in `.env` (or passed to Docker Compose): | `BASE_URL` | Used for PocketID OAuth callback redirect URI | | `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: + `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 +- 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/records.py b/backend/app/api/records.py index 49822d9..b3472ca 100644 --- a/backend/app/api/records.py +++ b/backend/app/api/records.py @@ -58,6 +58,7 @@ async def route_records( nr.name AS route_name, nr.sport_type, nr.distance_m, + nr.reference_polyline, a.id AS activity_id, a.name AS activity_name, a.duration_s, diff --git a/backend/app/main.py b/backend/app/main.py index cd4dbcd..6539034 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -106,6 +106,20 @@ async def init_db(): except Exception as e: print(f"FK migration skipped: {e}") + # Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) + try: + async with engine.begin() as conn: + await conn.execute(text( + "UPDATE activities SET avg_speed_ms = distance_m / duration_s " + "WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0" + )) + await conn.execute(text( + "UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s " + "WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0" + )) + except Exception as e: + print(f"avg_speed_ms fix skipped: {e}") + # Seed admin user (only if password is configured) if not settings.admin_password: print("ADMIN_PASSWORD not set - skipping admin user seed") diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py index facb3e1..543b1e7 100644 --- a/backend/app/services/fit_parser.py +++ b/backend/app/services/fit_parser.py @@ -34,6 +34,16 @@ def _safe_float(val) -> Optional[float]: return None +def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]: + """Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur.""" + fv = _safe_float(val) + if fv is None or fv >= 65.0: + if dist_m and dur_s and float(dur_s) > 0: + return float(dist_m) / float(dur_s) + return None + return fv + + def _bounding_box(coords): if not coords: return None @@ -180,15 +190,19 @@ def parse_fit_file(filepath: str) -> dict: normalized_laps = [] for i, lap in enumerate(laps): ls = _to_dt(get(lap, "startTime", "start_time")) + lap_dist = _safe_float(get(lap, "totalDistance", "total_distance")) + lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time")) normalized_laps.append({ "lap_number": i + 1, "start_time": ls.isoformat() if ls else None, - "duration_s": _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time")), - "distance_m": _safe_float(get(lap, "totalDistance", "total_distance")), + "duration_s": lap_dur, + "distance_m": lap_dist, "avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")), "avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")), - "avg_speed_ms": _safe_float(get(lap, "avgSpeed", "avg_speed", - "enhancedAvgSpeed", "enhanced_avg_speed")), + "avg_speed_ms": _sanitize_speed( + get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"), + dist_m=lap_dist, dur_s=lap_dur, + ), "avg_power": _safe_float(get(lap, "avgPower", "avg_power")), }) @@ -209,8 +223,11 @@ def parse_fit_file(filepath: str) -> dict: "avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")), "avg_power": _safe_float(get(session_data, "avgPower", "avg_power")), "normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")), - "avg_speed_ms": _safe_float(get(session_data, "avgSpeed", "avg_speed", - "enhancedAvgSpeed", "enhanced_avg_speed")), + "avg_speed_ms": _sanitize_speed( + get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"), + dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")), + dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")), + ), "max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed", "enhancedMaxSpeed", "enhanced_max_speed")), "avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")), diff --git a/deploy.sh b/deploy.sh index f764f32..15599bb 100755 --- a/deploy.sh +++ b/deploy.sh @@ -10,7 +10,7 @@ git commit -m "$MESSAGE" git push cd ../milevault_docker -docker compose down -v +docker compose down echo "" echo "Done. Run 'docker compose pull && docker compose up -d' when the build completes." diff --git a/frontend/src/components/ui/RouteMiniMap.jsx b/frontend/src/components/ui/RouteMiniMap.jsx new file mode 100644 index 0000000..bb3bd39 --- /dev/null +++ b/frontend/src/components/ui/RouteMiniMap.jsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react' +import { sportColor } from '../../utils/format' + +function decodePolyline(encoded) { + const coords = [] + let index = 0, lat = 0, lng = 0 + while (index < encoded.length) { + let b, shift = 0, result = 0 + do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) + lat += (result & 1) ? ~(result >> 1) : result >> 1 + shift = 0; result = 0 + do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) + lng += (result & 1) ? ~(result >> 1) : result >> 1 + coords.push([lat / 1e5, lng / 1e5]) + } + return coords +} + +// 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 + + 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 + + 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 svgProps = { + viewBox: `0 0 ${VW} ${VH}`, + preserveAspectRatio: 'xMidYMid meet', + className: 'rounded overflow-hidden block', + style: { background: '#111827', width, height }, + } + + if (!pathD) return ( + + + + ) + + return ( + + + + ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 80e8f92..407e9fe 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,6 +1,6 @@ import { Link, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts' import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import api from '../utils/api' import StatCard from '../components/ui/StatCard' @@ -19,10 +19,13 @@ function bbLevelColor(level) { function MiniBodyBattery({ bb }) { if (!bb?.end_level && !bb?.charged) return null - const { charged, drained, start_level, end_level } = bb + const { charged, drained, start_level, end_level, values } = bb const color = bbLevelColor(end_level) + const sparkData = Array.isArray(values) + ? values.map(([ts, level]) => ({ ts, level })) + : [] return ( -
+

Body Battery

View → @@ -41,6 +44,27 @@ function MiniBodyBattery({ bb }) { {start_level != null && end_level != null && (

{start_level} → {end_level} today

)} + {sparkData.length >= 2 && ( +
+ + + + + + + + + + format(new Date(ts), 'HH:mm')} + formatter={v => [`${Math.round(v)}`, 'Battery']} + /> + + +
+ )}
) } @@ -145,36 +169,37 @@ export default function DashboardPage() {
-
+

Weekly distance (km)

-
+
-
-

Health today

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

No health data. Import a Garmin export.

- )} -
+
+ +
+

Health today

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

No health data. Import a Garmin export.

+ )}
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 4586fdc..6e81072 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -432,7 +432,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder, // ── Trend Charts ──────────────────────────────────────────────────────────── -function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick }) { +function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) { const vals = data.filter(d => d[dataKey] != null) if (!vals.length) return (
No data
@@ -465,7 +465,9 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa )} + fill={`url(#grad-${dataKey})`} + dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false} + connectNulls={connectNulls} isAnimationActive={false} /> ) @@ -657,9 +659,12 @@ export default function HealthPage() {

Weight

- d.weight_kg != null)} + dataKey="weight_kg" color="#34d399" formatter={v => `${v.toFixed(1)} kg`} - selectedDate={selDateForCharts} onDayClick={handleDayClick} /> + selectedDate={selDateForCharts} onDayClick={handleDayClick} + connectNulls showDots />
@@ -697,7 +702,7 @@ export default function HealthPage() {
-

Avg Heart Rate (day)

+

Heart Rate

`${Math.round(v)} bpm`} selectedDate={selDateForCharts} onDayClick={handleDayClick} /> diff --git a/frontend/src/pages/RecordsPage.jsx b/frontend/src/pages/RecordsPage.jsx index f8e25be..9bb81dd 100644 --- a/frontend/src/pages/RecordsPage.jsx +++ b/frontend/src/pages/RecordsPage.jsx @@ -1,12 +1,13 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Link } from 'react-router-dom' +import { Link, useNavigate } 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, formatPace, formatDistance } from '../utils/format' +import RouteMiniMap from '../components/ui/RouteMiniMap' -const SPORTS = ['running', 'cycling', 'swimming'] +const SPORTS = ['running', 'cycling'] const DISTANCE_ORDER = [ '400m', '800m', '1k', '1 mile', '3k', '5k', '10k', @@ -149,6 +150,7 @@ function DistancePRs() { } function RouteRecords() { + const navigate = useNavigate() const { data: records, isLoading } = useQuery({ queryKey: ['route-records'], queryFn: () => api.get('/records/routes').then(r => r.data), @@ -168,38 +170,40 @@ function RouteRecords() { - - - - - - + + + + {records.map(rec => ( - - navigate(`/activities/${rec.activity_id}`)} + className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer" + > + + - - - - - ))} @@ -226,33 +230,49 @@ function SegmentRecords() { ? bests.reduce((sum, b) => sum + b.best_s, 0) : null + if (!routes?.length) return ( +

+ No named routes yet.{' '} + Create one on the Routes page. +

+ ) + return (
-
- - {!routes?.length ? ( -

No named routes yet. Create one on the Routes page.

- ) : ( - - )} + +

{r.name}

+ {r.distance_m && ( +

{(r.distance_m / 1000).toFixed(1)} km

+ )} + + ))}
{selectedRouteId && ( isLoading ? (

Loading…

) : !bests?.length ? ( -

No segments for this route. Create some on the Segments page.

+

+ No segments for this route.{' '} + Create some on the Segments page. +

) : (
RouteDistanceBest timePaceDate + + RouteDistanceBest timePaceDate
+
+ + {rec.sport_type} {rec.route_name} + {formatDistance(rec.distance_m)} + {formatDuration(rec.duration_s)} + {formatPace(rec.avg_speed_ms, rec.sport_type)} + {formatDate(rec.start_time)} - - View → - -
diff --git a/frontend/src/pages/SegmentsPage.jsx b/frontend/src/pages/SegmentsPage.jsx index 40484c8..b55babd 100644 --- a/frontend/src/pages/SegmentsPage.jsx +++ b/frontend/src/pages/SegmentsPage.jsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { format } from 'date-fns' import api from '../utils/api' import { formatDuration, formatDistance } from '../utils/format' +import RouteMiniMap from '../components/ui/RouteMiniMap' function formatSegmentDist(m) { if (m == null) return '--' @@ -218,26 +219,37 @@ export default function SegmentsPage() {

Segments

- {/* Route selector */} -
- - {!routes?.length ? ( + {/* Route tile grid */} + {!routes?.length ? ( +

No named routes yet. Create one on the Routes page.

- ) : ( - - )} -
+
+ ) : ( +
+ {routes.map(r => ( + + ))} +
+ )} {selectedRoute && (