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 ( -
{start_level} → {end_level} today
)} + {sparkData.length >= 2 && ( +No health data. Import a Garmin export.
- )} -No health data. Import a Garmin export.
+ )}| Route | -Distance | -Best time | -Pace | -Date | -+ | + | Route | +Distance | +Best time | +Pace | +Date |
|---|---|---|---|---|---|---|---|---|---|---|---|
| + | |||||||||||
|
+ |
+ {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 → - - |