Initial commit: GPX ride viewer with local-LLM coaching insights

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:05:29 -04:00
commit f2788d9e1d
10 changed files with 2297 additions and 0 deletions
+810
View File
@@ -0,0 +1,810 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bike Ride Insights</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3354;
--accent: #4f8ef7;
--text: #e4e8f0;
--muted: #7a83a8;
--green: #43d98c;
--orange: #f5a524;
--red: #f24343;
--z1: #60a5fa;
--z2: #34d399;
--z3: #fbbf24;
--z4: #f97316;
--z5: #ef4444;
}
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; display: flex; height: 100vh; overflow: hidden; }
/* Sidebar */
#sidebar { width: 280px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
#sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
#sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); }
#sidebar-header p { color: var(--muted); font-size: 11px; margin-top: 2px; }
#ride-list { flex: 1; overflow-y: auto; }
#ride-list::-webkit-scrollbar { width: 4px; }
#ride-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.ride-item { padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.15s; }
.ride-item:hover { background: var(--surface2); }
.ride-item.active { background: var(--surface2); border-left: 3px solid var(--accent); padding-left: 13px; }
.ride-date { font-size: 11px; color: var(--muted); margin-bottom: 3px; }
.ride-name { font-size: 13px; font-weight: 500; color: var(--text); margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ride-stats { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-badge { font-size: 11px; color: var(--muted); }
.stat-badge span { color: var(--text); font-weight: 500; }
.indoor-badge { display: inline-block; font-size: 10px; background: #2e3354; color: var(--muted); padding: 1px 6px; border-radius: 8px; margin-top: 2px; }
.rhr-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.rhr-row label { font-size: 11px; color: var(--muted); flex-shrink: 0; }
.rhr-row input { width: 48px; background: var(--surface2); border: 1px solid var(--border); border-radius: 5px; color: var(--text); font-size: 12px; padding: 2px 6px; text-align: center; }
.rhr-row input:focus { outline: none; border-color: var(--accent); }
.rhr-row span { font-size: 11px; color: var(--muted); }
.rhr-maxhr { font-size: 10px; color: var(--muted); margin-left: auto; }
#unit-toggle { font-size: 10px; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
#unit-toggle:hover { border-color: var(--accent); color: var(--accent); }
#gen-all-btn { margin-top: 8px; width: 100%; padding: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 11px; cursor: pointer; transition: all 0.15s; }
#gen-all-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
#gen-all-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#gen-all-progress { margin-top: 6px; display: none; }
#gen-all-bar-bg { background: var(--surface2); border-radius: 3px; height: 4px; overflow: hidden; }
#gen-all-bar { height: 100%; background: var(--accent); border-radius: 3px; width: 0%; transition: width 0.3s; }
#gen-all-label { font-size: 10px; color: var(--muted); margin-top: 3px; }
#fetch-btn { margin-top: 4px; width: 100%; padding: 6px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 11px; cursor: pointer; transition: all 0.15s; }
#fetch-btn:hover:not(:disabled) { border-color: var(--green); color: var(--text); }
#fetch-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Fetch modal */
#fetch-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; align-items: center; justify-content: center; }
#fetch-modal-overlay.open { display: flex; }
#fetch-modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; width: 300px; display: flex; flex-direction: column; gap: 12px; }
#fetch-modal h2 { font-size: 14px; font-weight: 600; color: var(--text); }
.fetch-field { display: flex; flex-direction: column; gap: 4px; }
.fetch-field label { font-size: 11px; color: var(--muted); }
.fetch-field input { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; padding: 7px 10px; width: 100%; }
.fetch-field input:focus { outline: none; border-color: var(--accent); }
#fetch-status { font-size: 11px; color: var(--muted); min-height: 16px; }
#fetch-status.error { color: var(--red); }
#fetch-mfa-section { display: none; flex-direction: column; gap: 8px; padding-top: 4px; border-top: 1px solid var(--border); }
#fetch-mfa-section p { font-size: 11px; color: var(--muted); }
.fetch-modal-actions { display: flex; gap: 8px; }
.fetch-modal-actions button { flex: 1; padding: 8px; border-radius: 7px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; }
#fetch-submit-btn { background: var(--accent); color: #fff; border-color: var(--accent); }
#fetch-submit-btn:hover:not(:disabled) { opacity: 0.85; }
#fetch-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#fetch-cancel-btn { background: var(--surface2); color: var(--muted); }
#fetch-cancel-btn:hover { color: var(--text); border-color: var(--muted); }
#fetch-mfa-submit-btn { background: var(--accent); color: #fff; border-color: var(--accent); }
#fetch-mfa-submit-btn:hover:not(:disabled) { opacity: 0.85; }
#fetch-mfa-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Main */
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#map-container { flex: 1; min-height: 0; position: relative; }
#map { width: 100%; height: 100%; }
.no-gps-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--surface); flex-direction: column; gap: 8px; }
.no-gps-overlay .icon { font-size: 32px; }
.no-gps-overlay p { color: var(--muted); font-size: 13px; }
#charts-panel { height: 180px; background: var(--surface); border-top: 1px solid var(--border); display: flex; overflow: hidden; }
.chart-wrap { flex: 1; padding: 8px 12px; min-width: 0; position: relative; }
.chart-wrap h3 { font-size: 10px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.chart-wrap canvas { width: 100% !important; }
.chart-divider { width: 1px; background: var(--border); flex-shrink: 0; }
#empty-state { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: var(--muted); }
#empty-state .big { font-size: 32px; }
/* Right panel */
#details { width: 300px; flex-shrink: 0; background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
#details-scroll { flex: 1; overflow-y: auto; padding: 16px; }
#details-scroll::-webkit-scrollbar { width: 4px; }
#details-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
#details-header { margin-bottom: 12px; }
#details-title { font-size: 14px; font-weight: 600; color: var(--text); }
#details-date { font-size: 11px; color: var(--muted); margin-top: 2px; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; }
.stat-card { background: var(--surface2); border-radius: 8px; padding: 10px; }
.stat-card .label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 4px; }
.stat-card .value { font-size: 16px; font-weight: 600; color: var(--text); }
.stat-card .unit { font-size: 10px; color: var(--muted); font-weight: 400; }
.section-title { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; font-weight: 500; }
.hr-zones { margin-bottom: 16px; }
.zone-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.zone-label { width: 60px; font-size: 11px; color: var(--muted); flex-shrink: 0; }
.zone-bar-bg { flex: 1; background: var(--surface2); border-radius: 3px; height: 6px; overflow: hidden; }
.zone-bar { height: 100%; border-radius: 3px; transition: width 0.4s; }
.zone-pct { width: 36px; text-align: right; font-size: 11px; color: var(--muted); }
.zone-z1 { background: var(--z1); }
.zone-z2 { background: var(--z2); }
.zone-z3 { background: var(--z3); }
.zone-z4 { background: var(--z4); }
.zone-z5 { background: var(--z5); }
#insights-section { border-bottom: 1px solid var(--border); padding: 12px 16px; flex-shrink: 0; }
#insights-btn { width: 100%; padding: 9px; background: var(--accent); color: #fff; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
#insights-btn:hover { opacity: 0.85; }
#insights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#ride-funny-title { margin-top: 12px; font-size: 13px; font-style: italic; font-weight: 600; color: var(--accent); min-height: 0; }
#ride-funny-title:empty { display: none; }
#insights-text { margin-top: 6px; font-size: 12px; line-height: 1.7; color: var(--text); white-space: pre-wrap; }
.blinking { display: inline-block; width: 2px; height: 12px; background: var(--accent); animation: blink 0.8s step-end infinite; margin-left: 2px; vertical-align: middle; }
@keyframes blink { 50% { opacity: 0; } }
.no-ride-msg { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 16px; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h1>Bike Rides</h1>
<p id="ride-count">Loading...</p>
<div class="rhr-row">
<label for="rhr-input">Resting HR</label>
<input id="rhr-input" type="number" min="30" max="100" value="60">
<span>bpm</span>
<span class="rhr-maxhr" id="rhr-maxhr"></span>
<button id="unit-toggle" title="Toggle units">km</button>
</div>
<button id="gen-all-btn">Generate all insights</button>
<div id="gen-all-progress">
<div id="gen-all-bar-bg"><div id="gen-all-bar"></div></div>
<div id="gen-all-label"></div>
</div>
<button id="fetch-btn">Fetch new rides from Garmin</button>
</div>
<div id="fetch-modal-overlay">
<div id="fetch-modal">
<h2>Fetch rides from Garmin</h2>
<div class="fetch-field">
<label for="fetch-email">Email</label>
<input id="fetch-email" type="email" placeholder="you@example.com" autocomplete="email">
</div>
<div class="fetch-field">
<label for="fetch-password">Password</label>
<input id="fetch-password" type="password" placeholder="••••••••" autocomplete="current-password">
</div>
<div id="fetch-status"></div>
<div id="fetch-mfa-section">
<p>Garmin sent a verification code to your email or phone.</p>
<div class="fetch-field">
<label for="fetch-mfa-code">Verification code</label>
<input id="fetch-mfa-code" type="text" inputmode="numeric" placeholder="123456" autocomplete="one-time-code">
</div>
<button id="fetch-mfa-submit-btn">Submit code</button>
</div>
<div class="fetch-modal-actions">
<button id="fetch-cancel-btn">Cancel</button>
<button id="fetch-submit-btn">Fetch</button>
</div>
</div>
</div>
<div id="ride-list"></div>
</div>
<div id="main">
<div id="map-container">
<div id="map"></div>
<div class="no-gps-overlay" id="no-gps" style="display:none">
<div class="icon">🚴</div>
<p>Indoor ride — no GPS data</p>
</div>
</div>
<div id="charts-panel">
<div id="empty-state">
<div class="big">🗺</div>
<span>Select a ride to view details</span>
</div>
<div class="chart-wrap" id="ele-chart-wrap" style="display:none">
<h3>Elevation</h3>
<canvas id="ele-chart"></canvas>
</div>
<div class="chart-divider" id="chart-divider" style="display:none"></div>
<div class="chart-wrap" id="hr-chart-wrap" style="display:none">
<h3>Heart Rate</h3>
<canvas id="hr-chart"></canvas>
</div>
</div>
</div>
<div id="details">
<div id="insights-section" style="display:none;padding:12px 16px;border-bottom:1px solid var(--border);">
<button id="insights-btn">Generate AI Insights</button>
</div>
<div id="details-scroll">
<div class="no-ride-msg" id="no-ride-msg">Select a ride from the list</div>
<div id="ride-details" style="display:none">
<div id="details-header">
<div id="details-title"></div>
<div id="details-date"></div>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="label">Distance</div><div class="value" id="d-dist"></div></div>
<div class="stat-card"><div class="label">Duration</div><div class="value" id="d-dur"></div></div>
<div class="stat-card"><div class="label">Avg Speed</div><div class="value" id="d-spd"></div></div>
<div class="stat-card"><div class="label">Elevation ↑</div><div class="value" id="d-ele"></div></div>
<div class="stat-card"><div class="label">Avg HR</div><div class="value" id="d-hr"></div></div>
<div class="stat-card"><div class="label">Max HR</div><div class="value" id="d-maxhr"></div></div>
</div>
<div class="section-title">HR Zones</div>
<div class="hr-zones" id="hr-zones"></div>
<div id="ride-funny-title"></div>
<div id="insights-text"></div>
</div>
</div>
</div>
<script>
const map = L.map('map', { zoomControl: true }).setView([37.7749, -122.4194], 11);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19, subdomains: 'abcd'
}).addTo(map);
let allRides = [];
let currentRide = null;
let currentRideId = null;
let currentPoints = [];
let routeLayer = null;
let eleChart = null;
let hrChart = null;
let globalMaxHr = 200;
// Karvonen: zone boundaries as BPM values
function zoneBoundaries() {
const rhr = parseInt(document.getElementById('rhr-input').value) || 60;
const maxHr = globalMaxHr;
const hrr = maxHr - rhr;
return [
rhr + 0.50 * hrr, // Z1 upper
rhr + 0.60 * hrr, // Z2 upper
rhr + 0.70 * hrr, // Z3 upper
rhr + 0.80 * hrr, // Z4 upper
];
}
function hrColor(hr) {
if (!hr) return '#60a5fa';
const [z1, z2, z3, z4] = zoneBoundaries();
if (hr < z1) return '#60a5fa';
if (hr < z2) return '#34d399';
if (hr < z3) return '#fbbf24';
if (hr < z4) return '#f97316';
return '#ef4444';
}
// Compute zone % distribution from raw HR points using Karvonen boundaries
function computeZones(points) {
const hrs = points.map(p => p.hr).filter(h => h != null);
if (!hrs.length) return null;
const [z1, z2, z3, z4] = zoneBoundaries();
const counts = [0, 0, 0, 0, 0];
for (const h of hrs) {
if (h < z1) counts[0]++;
else if (h < z2) counts[1]++;
else if (h < z3) counts[2]++;
else if (h < z4) counts[3]++;
else counts[4]++;
}
return counts.map(c => Math.round(c / hrs.length * 100));
}
// Unit system
let imperial = localStorage.getItem('units') === 'imperial';
function setImperial(val) {
imperial = val;
localStorage.setItem('units', imperial ? 'imperial' : 'metric');
document.getElementById('unit-toggle').textContent = imperial ? 'mi' : 'km';
}
function fmtDist(km) {
if (!imperial) return `${km}<span class="unit"> km</span>`;
return `${(km * 0.621371).toFixed(2)}<span class="unit"> mi</span>`;
}
function fmtDistShort(km) {
if (!imperial) return `${km}km`;
return `${(km * 0.621371).toFixed(1)}mi`;
}
function fmtSpeed(kmh) {
if (!imperial) return `${kmh}<span class="unit"> km/h</span>`;
return `${(kmh * 0.621371).toFixed(1)}<span class="unit"> mph</span>`;
}
function fmtEle(m) {
if (!imperial) return `${m}<span class="unit"> m</span>`;
return `${Math.round(m * 3.28084)}<span class="unit"> ft</span>`;
}
function fmtDuration(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
// Persist RHR in localStorage
const rhrInput = document.getElementById('rhr-input');
rhrInput.value = localStorage.getItem('rhr') || 60;
rhrInput.addEventListener('change', () => {
localStorage.setItem('rhr', rhrInput.value);
if (currentPoints.length) {
renderZones(currentPoints);
renderMap(currentPoints);
}
});
// Load ride list
async function loadRides() {
const res = await fetch('/api/rides');
allRides = await res.json();
globalMaxHr = Math.max(...allRides.map(r => r.max_hr || 0), 160);
document.getElementById('ride-count').textContent = `${allRides.length} rides`;
document.getElementById('rhr-maxhr').textContent = `max ${globalMaxHr}`;
renderRideList();
}
function renderRideList() {
const list = document.getElementById('ride-list');
list.innerHTML = '';
for (const r of allRides) {
const el = document.createElement('div');
el.className = 'ride-item';
el.dataset.id = r.id;
const date = r.start_time ? fmtDate(r.start_time) : 'Unknown date';
const dist = r.has_gps ? `<span>${fmtDistShort(r.distance_km)}</span>` : '';
const dur = r.duration_s ? `<span>${fmtDuration(r.duration_s)}</span>` : '';
const hr = r.avg_hr ? `<span>${r.avg_hr}bpm</span>` : '';
const indoor = !r.has_gps ? '<span class="indoor-badge">Indoor</span>' : '';
el.innerHTML = `
<div class="ride-date">${date}</div>
<div class="ride-name">${r.name}</div>
<div class="ride-stats">
${dist ? `<div class="stat-badge">${dist}</div>` : ''}
${dur ? `<div class="stat-badge">${dur}</div>` : ''}
${hr ? `<div class="stat-badge">${hr}</div>` : ''}
</div>
${indoor}
`;
el.addEventListener('click', () => selectRide(r.id));
list.appendChild(el);
}
}
async function selectRide(id) {
if (currentRideId === id) return;
currentRideId = id;
// Update active state
document.querySelectorAll('.ride-item').forEach(el => {
el.classList.toggle('active', el.dataset.id === id);
});
// Fetch full ride metadata
const res = await fetch(`/api/rides/${encodeURIComponent(id)}`);
currentRide = await res.json();
renderDetails(currentRide);
if (currentRide.has_gps) {
// Fetch points
const pRes = await fetch(`/api/rides/${encodeURIComponent(id)}/points`);
currentPoints = await pRes.json();
renderMap(currentPoints);
renderCharts(currentPoints);
renderZones(currentPoints);
document.getElementById('no-gps').style.display = 'none';
document.getElementById('ele-chart-wrap').style.display = '';
document.getElementById('hr-chart-wrap').style.display = '';
document.getElementById('chart-divider').style.display = '';
document.getElementById('empty-state').style.display = 'none';
} else {
currentPoints = [];
clearMap();
clearCharts();
document.getElementById('no-gps').style.display = 'flex';
document.getElementById('ele-chart-wrap').style.display = 'none';
document.getElementById('hr-chart-wrap').style.display = 'none';
document.getElementById('chart-divider').style.display = 'none';
document.getElementById('empty-state').style.display = 'none';
}
// Show cached insight or reset
const insightEl = document.getElementById('insights-text');
const insightBtn = document.getElementById('insights-btn');
if (currentRide.cached_insight) {
renderInsight(currentRide.cached_insight);
insightBtn.textContent = 'Regenerate';
} else {
renderInsight('');
insightBtn.textContent = 'Generate AI Insights';
}
insightBtn.disabled = false;
}
function renderDetails(ride) {
document.getElementById('no-ride-msg').style.display = 'none';
document.getElementById('ride-details').style.display = '';
document.getElementById('insights-section').style.display = '';
document.getElementById('details-title').textContent = ride.name;
const dateStr = ride.start_time ? `${fmtDate(ride.start_time)} · ${fmtTime(ride.start_time)}` : '';
document.getElementById('details-date').textContent = dateStr;
document.getElementById('d-dist').innerHTML = ride.has_gps ? fmtDist(ride.distance_km) : '—';
document.getElementById('d-dur').textContent = ride.duration_s ? fmtDuration(ride.duration_s) : '—';
document.getElementById('d-spd').innerHTML = ride.has_gps ? fmtSpeed(ride.avg_speed_kmh) : '—';
document.getElementById('d-ele').innerHTML = ride.has_gps ? fmtEle(ride.elevation_gain_m) : '—';
document.getElementById('d-hr').innerHTML = ride.avg_hr ? `${ride.avg_hr}<span class="unit"> bpm</span>` : '—';
document.getElementById('d-maxhr').innerHTML = ride.max_hr ? `${ride.max_hr}<span class="unit"> bpm</span>` : '—';
}
function renderZones(points) {
const pcts = computeZones(points);
const bounds = zoneBoundaries();
const labels = [
`Z1 <${Math.round(bounds[0])}`,
`Z2 ${Math.round(bounds[0])}${Math.round(bounds[1])}`,
`Z3 ${Math.round(bounds[1])}${Math.round(bounds[2])}`,
`Z4 ${Math.round(bounds[2])}${Math.round(bounds[3])}`,
`Z5 >${Math.round(bounds[3])}`,
];
const classes = ['zone-z1', 'zone-z2', 'zone-z3', 'zone-z4', 'zone-z5'];
const zonesEl = document.getElementById('hr-zones');
if (!pcts) { zonesEl.innerHTML = ''; return; }
zonesEl.innerHTML = labels.map((label, i) => `
<div class="zone-row">
<div class="zone-label">${label}</div>
<div class="zone-bar-bg"><div class="zone-bar ${classes[i]}" style="width:${pcts[i]}%"></div></div>
<div class="zone-pct">${pcts[i]}%</div>
</div>
`).join('');
}
function clearMap() {
if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; }
}
function renderMap(points) {
clearMap();
if (!points.length) return;
// Draw segments colored by HR
const layers = [];
for (let i = 1; i < points.length; i++) {
const p0 = points[i - 1], p1 = points[i];
const color = hrColor(p1.hr || p0.hr);
layers.push(L.polyline([[p0.lat, p0.lon], [p1.lat, p1.lon]], { color, weight: 4, opacity: 0.9 }));
}
// Start/end markers
const startPt = points[0], endPt = points[points.length - 1];
const startIcon = L.divIcon({ html: '<div style="background:#43d98c;width:10px;height:10px;border-radius:50%;border:2px solid white;"></div>', iconSize: [10, 10], iconAnchor: [5, 5], className: '' });
const endIcon = L.divIcon({ html: '<div style="background:#f24343;width:10px;height:10px;border-radius:50%;border:2px solid white;"></div>', iconSize: [10, 10], iconAnchor: [5, 5], className: '' });
routeLayer = L.layerGroup([...layers, L.marker([startPt.lat, startPt.lon], { icon: startIcon }), L.marker([endPt.lat, endPt.lon], { icon: endIcon })]).addTo(map);
const latlngs = points.map(p => [p.lat, p.lon]);
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
}
function clearCharts() {
if (eleChart) { eleChart.destroy(); eleChart = null; }
if (hrChart) { hrChart.destroy(); hrChart = null; }
}
function renderCharts(points) {
clearCharts();
if (!points.length) return;
// Downsample to at most 500 points for chart performance
const step = Math.max(1, Math.floor(points.length / 500));
const sampled = points.filter((_, i) => i % step === 0);
// Compute cumulative distance for x-axis
let dist = 0;
const xLabels = [], eleData = [], hrData = [];
for (let i = 0; i < sampled.length; i++) {
if (i > 0) {
const p0 = sampled[i - 1], p1 = sampled[i];
const d = haversine(p0.lat, p0.lon, p1.lat, p1.lon);
dist += d / 1000;
}
xLabels.push(imperial ? (dist * 0.621371).toFixed(1) : dist.toFixed(1));
eleData.push(imperial ? +(sampled[i].ele * 3.28084).toFixed(0) : sampled[i].ele);
hrData.push(sampled[i].hr);
}
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, backgroundColor: '#1a1d27', titleColor: '#7a83a8', bodyColor: '#e4e8f0', borderColor: '#2e3354', borderWidth: 1 } },
scales: {
x: { ticks: { color: '#7a83a8', maxTicksLimit: 6, font: { size: 10 } }, grid: { color: '#1e2235' }, title: { display: true, text: imperial ? 'mi' : 'km', color: '#7a83a8', font: { size: 10 } } },
y: { ticks: { color: '#7a83a8', font: { size: 10 } }, grid: { color: '#1e2235' } },
},
elements: { point: { radius: 0 } },
};
const eleCtx = document.getElementById('ele-chart').getContext('2d');
eleChart = new Chart(eleCtx, {
type: 'line',
data: {
labels: xLabels,
datasets: [{ data: eleData, borderColor: '#4f8ef7', backgroundColor: 'rgba(79,142,247,0.1)', fill: true, borderWidth: 1.5, tension: 0.3 }],
},
options: { ...chartDefaults, scales: { ...chartDefaults.scales, y: { ...chartDefaults.scales.y, title: { display: true, text: imperial ? 'ft' : 'm', color: '#7a83a8', font: { size: 10 } } } } },
});
const hrCtx = document.getElementById('hr-chart').getContext('2d');
hrChart = new Chart(hrCtx, {
type: 'line',
data: {
labels: xLabels,
datasets: [{ data: hrData, borderColor: '#f97316', backgroundColor: 'rgba(249,115,22,0.1)', fill: true, borderWidth: 1.5, tension: 0.3 }],
},
options: { ...chartDefaults, scales: { ...chartDefaults.scales, y: { ...chartDefaults.scales.y, title: { display: true, text: 'bpm', color: '#7a83a8', font: { size: 10 } } } } },
});
}
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371000;
const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180, dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function renderInsight(text) {
const titleEl = document.getElementById('ride-funny-title');
const bodyEl = document.getElementById('insights-text');
if (text.startsWith('TITLE: ')) {
const nl = text.indexOf('\n');
titleEl.textContent = nl === -1 ? text.slice(7) : text.slice(7, nl);
bodyEl.textContent = nl === -1 ? '' : text.slice(nl).trimStart();
} else {
titleEl.textContent = '';
bodyEl.textContent = text;
}
}
// AI Insights
document.getElementById('insights-btn').addEventListener('click', async () => {
if (!currentRideId) return;
const btn = document.getElementById('insights-btn');
const textEl = document.getElementById('insights-text');
btn.disabled = true;
btn.textContent = 'Generating...';
textEl.innerHTML = '<span class="blinking"></span>';
try {
const rhr = parseInt(document.getElementById('rhr-input').value) || 60;
const res = await fetch(`/api/rides/${encodeURIComponent(currentRideId)}/insights?rhr=${rhr}`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = '';
renderInsight('');
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
renderInsight(text);
}
renderInsight(text);
if (currentRide) currentRide.cached_insight = text;
} catch (e) {
textEl.textContent = `Error: ${e.message}`;
} finally {
btn.disabled = false;
btn.textContent = 'Regenerate';
}
});
// Generate All
document.getElementById('gen-all-btn').addEventListener('click', async () => {
const btn = document.getElementById('gen-all-btn');
const progress = document.getElementById('gen-all-progress');
const bar = document.getElementById('gen-all-bar');
const label = document.getElementById('gen-all-label');
const rhr = parseInt(document.getElementById('rhr-input').value) || 60;
btn.disabled = true;
progress.style.display = '';
bar.style.width = '0%';
label.textContent = 'Starting...';
try {
const res = await fetch(`/api/insights/generate-all?rhr=${rhr}`, { method: 'POST' });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const evt = JSON.parse(line.slice(6));
if (evt.status === 'complete') {
bar.style.width = '100%';
label.textContent = `Done — ${evt.total} insights generated`;
btn.textContent = 'Generate all insights';
} else if (evt.status === 'done' || evt.status === 'error') {
const pct = Math.round(evt.index / evt.total * 100);
bar.style.width = pct + '%';
label.textContent = `${evt.index} / ${evt.total}`;
// If this ride is currently selected, refresh the insight panel
if (evt.status === 'done' && evt.ride_id === currentRideId) {
const r = await fetch(`/api/rides/${encodeURIComponent(evt.ride_id)}`);
currentRide = await r.json();
renderInsight(currentRide.cached_insight || '');
document.getElementById('insights-btn').textContent = 'Regenerate';
}
}
}
}
} catch (e) {
label.textContent = `Error: ${e.message}`;
} finally {
btn.disabled = false;
}
});
// Fetch rides from Garmin
const fetchBtn = document.getElementById('fetch-btn');
const fetchOverlay = document.getElementById('fetch-modal-overlay');
const fetchSubmitBtn = document.getElementById('fetch-submit-btn');
const fetchCancelBtn = document.getElementById('fetch-cancel-btn');
const fetchStatus = document.getElementById('fetch-status');
const fetchMfaSection = document.getElementById('fetch-mfa-section');
const fetchMfaSubmitBtn = document.getElementById('fetch-mfa-submit-btn');
let pendingSessionId = null;
function openFetchModal() {
document.getElementById('fetch-email').value = '';
document.getElementById('fetch-password').value = '';
document.getElementById('fetch-mfa-code').value = '';
fetchStatus.textContent = '';
fetchStatus.className = 'fetch-status';
fetchMfaSection.style.display = 'none';
fetchSubmitBtn.disabled = false;
fetchSubmitBtn.textContent = 'Fetch';
fetchCancelBtn.disabled = false;
fetchOverlay.classList.add('open');
document.getElementById('fetch-email').focus();
}
function closeFetchModal() {
fetchOverlay.classList.remove('open');
}
fetchBtn.addEventListener('click', openFetchModal);
fetchCancelBtn.addEventListener('click', closeFetchModal);
fetchOverlay.addEventListener('click', e => { if (e.target === fetchOverlay) closeFetchModal(); });
fetchSubmitBtn.addEventListener('click', async () => {
const email = document.getElementById('fetch-email').value.trim();
const password = document.getElementById('fetch-password').value;
if (!email || !password) { fetchStatus.textContent = 'Email and password required.'; fetchStatus.className = 'error'; return; }
fetchSubmitBtn.disabled = true;
fetchCancelBtn.disabled = true;
fetchStatus.className = '';
fetchStatus.textContent = 'Connecting…';
pendingSessionId = null;
try {
const res = await fetch('/api/fetch-rides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const evt = JSON.parse(line.slice(6));
if (evt.status === 'auth') {
fetchStatus.textContent = 'Logging in to Garmin…';
} else if (evt.status === 'mfa_required') {
pendingSessionId = evt.session_id;
fetchStatus.textContent = 'Waiting for verification code…';
fetchMfaSection.style.display = 'flex';
document.getElementById('fetch-mfa-code').focus();
} else if (evt.status === 'fetching') {
fetchMfaSection.style.display = 'none';
fetchStatus.textContent = 'Checking for new rides…';
} else if (evt.status === 'downloading') {
fetchStatus.textContent = `Downloading ${evt.index}/${evt.total}: ${evt.name}`;
} else if (evt.status === 'complete') {
fetchStatus.textContent = evt.added > 0
? `Done — added ${evt.added} new ride${evt.added !== 1 ? 's' : ''} (${evt.skipped} already up to date)`
: `Already up to date (${evt.skipped} ride${evt.skipped !== 1 ? 's' : ''} checked)`;
await loadRides();
fetchSubmitBtn.disabled = false;
fetchSubmitBtn.textContent = 'Fetch again';
fetchCancelBtn.disabled = false;
fetchCancelBtn.textContent = 'Close';
} else if (evt.status === 'error') {
fetchStatus.textContent = evt.message;
fetchStatus.className = 'error';
fetchSubmitBtn.disabled = false;
fetchSubmitBtn.textContent = 'Retry';
fetchCancelBtn.disabled = false;
}
}
}
} catch (e) {
fetchStatus.textContent = `Error: ${e.message}`;
fetchStatus.className = 'error';
fetchSubmitBtn.disabled = false;
fetchCancelBtn.disabled = false;
}
});
fetchMfaSubmitBtn.addEventListener('click', async () => {
const code = document.getElementById('fetch-mfa-code').value.trim();
if (!code) return;
fetchMfaSubmitBtn.disabled = true;
fetchStatus.textContent = 'Submitting code…';
try {
const res = await fetch('/api/mfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: pendingSessionId, code }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
fetchMfaSection.style.display = 'none';
} catch (e) {
fetchStatus.textContent = `Error submitting code: ${e.message}`;
fetchStatus.className = 'error';
fetchMfaSubmitBtn.disabled = false;
}
});
// Unit toggle
document.getElementById('unit-toggle').textContent = imperial ? 'mi' : 'km';
document.getElementById('unit-toggle').addEventListener('click', () => {
setImperial(!imperial);
renderRideList();
if (currentRide) renderDetails(currentRide);
if (currentPoints && currentPoints.length) renderCharts(currentPoints);
});
loadRides();
</script>
</body>
</html>