/* ============================================================
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 (
© 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 (
);
}
// ---- 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 });
})();