Files
ryan 40b685e6d0 Add mobile-friendly tab layout with bottom nav
Three-panel desktop layout becomes a tab-based single-panel view on mobile
with Rides/Map/Details bottom nav. Tapping a ride auto-navigates to the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-28 10:52:21 -04:00

901 lines
39 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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; }
#user-row { display: none; align-items: center; justify-content: space-between; margin-top: 4px; }
#user-name { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#logout-link { font-size: 11px; color: var(--muted); text-decoration: none; flex-shrink: 0; margin-left: 6px; }
#logout-link:hover { color: var(--accent); }
#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; }
/* Mobile */
#mobile-nav { display: none; }
@media (max-width: 768px) {
#sidebar, #main, #details {
position: fixed;
top: 0; left: 0; right: 0;
bottom: calc(48px + env(safe-area-inset-bottom));
width: 100%;
display: none;
}
#sidebar.mobile-active, #main.mobile-active, #details.mobile-active { display: flex; }
#sidebar { border-right: none; }
#details { border-left: none; }
#mobile-nav {
display: flex;
position: fixed;
bottom: 0; left: 0; right: 0;
height: calc(48px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 100;
}
.mobile-nav-btn {
flex: 1;
background: none;
border: none;
color: var(--muted);
font-size: 10px;
font-family: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
transition: color 0.15s;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
.mobile-nav-btn.active { color: var(--accent); }
.mobile-nav-btn svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
}
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h1>bikeslop</h1>
<p id="ride-count">Loading...</p>
<div id="user-row">
<span id="user-name"></span>
<a id="logout-link" href="/auth/logout">Sign out</a>
</div>
<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>
<nav id="mobile-nav">
<button class="mobile-nav-btn active" data-tab="list" onclick="setMobileTab('list')">
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Rides
</button>
<button class="mobile-nav-btn" data-tab="map" onclick="setMobileTab('map')">
<svg viewBox="0 0 24 24"><polygon points="3,6 9,3 15,6 21,3 21,18 15,21 9,18 3,21"/><line x1="9" y1="3" x2="9" y2="18"/><line x1="15" y1="6" x2="15" y2="21"/></svg>
Map
</button>
<button class="mobile-nav-btn" data-tab="details" onclick="setMobileTab('details')">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Details
</button>
</nav>
<script>
const map = L.map('map', { zoomControl: true }).setView([37.7749, -122.4194], 11);
L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://stadiamaps.com/">Stadia Maps</a> © <a href="https://openmaptiles.org/">OpenMapTiles</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom: 20
}).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);
setMobileTab('map');
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);
});
function setMobileTab(tab) {
if (!window.matchMedia('(max-width: 768px)').matches) return;
document.getElementById('sidebar').classList.toggle('mobile-active', tab === 'list');
document.getElementById('main').classList.toggle('mobile-active', tab === 'map');
document.getElementById('details').classList.toggle('mobile-active', tab === 'details');
document.querySelectorAll('.mobile-nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
if (tab === 'map') setTimeout(() => map.invalidateSize(), 50);
}
setMobileTab('list');
async function initAuth() {
const res = await fetch('/api/me');
if (res.status === 401) {
window.location.href = '/auth/login';
return;
}
const me = await res.json();
const userRow = document.getElementById('user-row');
userRow.style.display = 'flex';
document.getElementById('user-name').textContent = me.display_name || me.email || '';
loadRides();
}
initAuth();
</script>
</body>
</html>