7fefb3ac37
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
811 lines
35 KiB
HTML
811 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>bikeslop</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>bikeslop</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>
|