/* ============================================================ screens-bike.jsx — BIKE: rides detected from precise OwnTracks GPS, with delayed Find My bike-tag corroboration. Distance/speed/ calories come from that trip record; HR (avg/max/zones) is computed by openclawclinic from its own raw_hr stream, time- aligned to the ride window. Fetches its own endpoints (/api/bike/summary, /api/bike/rides/{id}) rather than reading the shared dashboard snapshot — ride volume/cadence doesn't fit the once-daily assembled-snapshot contract the rest of the app uses. ============================================================ */ (function () { const { useState, useEffect, useCallback } = React; const ZONE_COLORS = [ "var(--ink-4)", "var(--c-line)", "var(--attn)", "color-mix(in oklch, var(--attn) 45%, var(--acute))", "var(--acute)", ]; const ZONE_KEYS = ["z1", "z2", "z3", "z4", "z5"]; function zoneColor(bpm, maxHrUsed) { if (bpm == null || !maxHrUsed) return "var(--ink-4)"; const frac = bpm / maxHrUsed; const idx = frac < 0.6 ? 0 : frac < 0.7 ? 1 : frac < 0.8 ? 2 : frac < 0.9 ? 3 : 4; return ZONE_COLORS[idx]; } function fmtDuration(min) { if (min == null) return "—"; return min < 60 ? `${Math.round(min)}m` : `${(min / 60).toFixed(1)}h`; } function fmtRideDate(iso) { const d = new Date(iso); return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " · " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } // ---- route: Web Mercator street map, HR-zone-colored ---- function mercatorPoint(point) { const lat = Math.max(-85.05112878, Math.min(85.05112878, point.lat)); const sin = Math.sin((lat * Math.PI) / 180); return { x: (point.lon + 180) / 360, y: 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI), bpm: point.bpm, }; } function projectTrack(track, w, h, pad) { if (!track || track.length < 2) return null; const mercator = track.map(mercatorPoint); const xs = mercator.map((p) => p.x); const ys = mercator.map((p) => p.y); const xlo = Math.min(...xs), xhi = Math.max(...xs); const ylo = Math.min(...ys), yhi = Math.max(...ys); const spanX = Math.max(xhi - xlo, 1e-12), spanY = Math.max(yhi - ylo, 1e-12); let zoom = 18; while (zoom > 2) { const world = 256 * (2 ** zoom); if (spanX * world <= w - pad * 2 && spanY * world <= h - pad * 2) break; zoom -= 1; } const scale = 256 * (2 ** zoom); const cx = (xlo + xhi) / 2, cy = (ylo + yhi) / 2; const left = cx * scale - w / 2; const top = cy * scale - h / 2; const points = mercator.map((p) => ({ x: p.x * scale - left, y: p.y * scale - top, bpm: p.bpm, })); const tiles = []; const tileCount = 2 ** zoom; for (let ty = Math.floor(top / 256); ty <= Math.floor((top + h) / 256); ty += 1) { if (ty < 0 || ty >= tileCount) continue; for (let tx = Math.floor(left / 256); tx <= Math.floor((left + w) / 256); tx += 1) { const tileX = ((tx % tileCount) + tileCount) % tileCount; tiles.push({ key: `${zoom}/${tileX}/${ty}`, href: `/api/bike/map-tiles/${zoom}/${tileX}/${ty}.png`, x: tx * 256 - left, y: ty * 256 - top, }); } } return { points, tiles }; } function RouteMap({ track, maxHrUsed, w = 320, h = 260 }) { const projected = projectTrack(track, w, h, 24); if (!projected) { return (
No GPS track for this ride.
); } const { points: pts, tiles } = projected; return (
{tiles.map((tile) => ( ))} {pts.slice(1).map((p, i) => { const prev = pts[i]; return ; })} {pts.slice(1).map((p, i) => { const prev = pts[i]; const col = zoneColor(p.bpm ?? prev.bpm, maxHrUsed); return ; })} © OpenStreetMap contributors
); } function ZoneBar({ zoneMinutes, w = 320, h = 22 }) { if (!zoneMinutes) return null; const total = ZONE_KEYS.reduce((a, k) => a + (zoneMinutes[k] || 0), 0) || 1; let x = 0; return ( {ZONE_KEYS.map((k, i) => { const bw = ((zoneMinutes[k] || 0) / total) * w; const seg = ; x += bw; return seg; })} ); } // ---- aggregate stat card (weekly / monthly / yearly) ---- function BikeStatCard({ label, s }) { return (
{s.distance_km}km
{s.rides} ride{s.rides === 1 ? "" : "s"} · avg {s.avg_distance_km} km
); } // ---- ride list row ---- function RideRow({ r, onOpen }) { return ( ); } // ---- list screen ---- function BikeList({ summary, err, onOpen, onRetry }) { return (
{err ? (
Couldn't load rides.
) : !summary ? (
Loading rides…
) : ( <> Distance
Rides {summary.rides.length === 0 ? (
No rides detected yet — OwnTracks motion starts the route and Find My adds confirmation when available.
) : summary.rides.map((r) => onOpen(r.id)} />)}
)}
); } // ---- ride detail screen ---- function RideDetail({ detail, err, onBack }) { return (
{err &&
{err}
} {!detail && !err &&
Loading ride…
} {detail && ( <>
{detail.calories_kcal}kcal
Heart-rate zones {detail.zone_minutes ? ( <>
{ZONE_KEYS.map((k, i) => ( ))}
) : (
No heart-rate coverage during this ride.
)}
{detail.detector_evidence && ( Trip detection
{detail.detector_evidence.cycling_points || 0} cycling-motion fixes · {detail.detector_evidence.phone_points || detail.points} precise GPS fixes {detail.detector_evidence.findmy?.confirmed ? ` · Find My confirmed (${detail.detector_evidence.findmy.matches} matches)` : " · Find My pending / not needed"}
)}
Route {detail.track && (
colored by heart-rate zone · ○ start ■ finish
)}
)}
); } // ---- top-level screen: owns its own data (not the shared dashboard snapshot) ---- function Bike() { const [summary, setSummary] = useState(null); const [summaryErr, setSummaryErr] = useState(null); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); const [detailErr, setDetailErr] = useState(null); const loadSummary = useCallback(async () => { setSummaryErr(null); try { const r = await fetch("/api/bike/summary?limit=30", { credentials: "same-origin" }); if (r.ok) setSummary(await r.json()); else setSummaryErr("server error"); } catch (e) { setSummaryErr(String(e)); } }, []); useEffect(() => { loadSummary(); }, [loadSummary]); useEffect(() => { if (selected == null) return; setDetail(null); setDetailErr(null); (async () => { try { const r = await fetch(`/api/bike/rides/${selected}`, { credentials: "same-origin" }); if (r.ok) setDetail(await r.json()); else setDetailErr("Couldn't load this ride."); } catch (e) { setDetailErr(String(e)); } })(); }, [selected]); if (selected != null) { return setSelected(null)} />; } return ; } Object.assign(window, { Bike }); })();