Files
FitTrack2/FitnessSync/backend/templates/activity_view.html
2026-01-13 09:42:16 -08:00

1127 lines
45 KiB
HTML

{% extends "base.html" %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<style>
#map {
height: 500px;
width: 100%;
border-radius: 8px;
margin-bottom: 20px;
z-index: 1;
/* Ensure it stays below navbars if any */
}
.metric-card {
transition: transform 0.2s;
}
.metric-card:hover {
transform: translateY(-5px);
}
</style>
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 id="act-name">Loading...</h2>
<p class="text-muted mb-0">
<span id="act-time">-</span> |
<span id="act-type">-</span> |
<span id="act-id">#</span>
</p>
</div>
<div>
<div class="btn-group me-2">
<a href="#" class="btn btn-outline-secondary disabled" id="nav-prev-type" title="Previous of same type">
<i class="bi bi-chevron-double-left"></i>
</a>
<a href="#" class="btn btn-outline-secondary disabled" id="nav-prev" title="Previous">
<i class="bi bi-chevron-left"></i>
</a>
<a href="#" class="btn btn-outline-secondary disabled" id="nav-next" title="Next">
<i class="bi bi-chevron-right"></i>
</a>
<a href="#" class="btn btn-outline-secondary disabled" id="nav-next-type" title="Next of same type">
<i class="bi bi-chevron-double-right"></i>
</a>
</div>
<button class="btn btn-outline-secondary" onclick="window.close()">
<i class="bi bi-x-lg"></i> Close
</button>
<button class="btn btn-primary" id="download-btn">
<i class="bi bi-download"></i> Download
</button>
<button class="btn btn-outline-warning" id="refresh-btn" onclick="refreshActivity()">
<i class="bi bi-arrow-clockwise"></i> Refresh & Match
</button>
<button class="btn btn-warning" id="estimate-power-btn" onclick="estimatePower()">
<i class="bi bi-lightning-fill"></i> Estimate Power
</button>
<button class="btn btn-success" id="create-segment-btn" onclick="toggleSegmentMode()">
<i class="bi bi-bezier2"></i> Create Segment
</button>
<a href="/discovery?activity_id={{ activity_id }}" class="btn btn-outline-info" title="Discover Segments">
<i class="bi bi-search"></i> Discovery
</a>
</div>
</div>
<!-- Map Section -->
<div class="card mb-4 shadow-sm">
<div class="card-body p-0">
<div id="map"></div>
<div id="no-map-msg" class="text-center p-5 text-muted" style="display:none;">
<i class="bi bi-geo-alt-fill" style="font-size: 2rem;"></i>
<p class="mt-2">No GPS data available for this activity.</p>
</div>
</div>
</div>
<!-- Metrics Overview -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card h-100 bg-light">
<div class="card-body text-center">
<i class="bi bi-cursor text-primary mb-2" style="font-size: 1.5rem;"></i>
<h6 class="card-subtitle text-muted mb-1">Distance</h6>
<h3 class="card-title text-primary" id="metric-dist">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 bg-light">
<div class="card-body text-center">
<i class="bi bi-stopwatch text-success mb-2" style="font-size: 1.5rem;"></i>
<h6 class="card-subtitle text-muted mb-1">Duration</h6>
<h3 class="card-title text-success" id="metric-dur">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 bg-light">
<div class="card-body text-center">
<i class="bi bi-heart text-danger mb-2" style="font-size: 1.5rem;"></i>
<h6 class="card-subtitle text-muted mb-1">Avg HR</h6>
<h3 class="card-title text-danger" id="metric-hr">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 bg-light">
<div class="card-body text-center">
<i class="bi bi-fire text-warning mb-2" style="font-size: 1.5rem;"></i>
<h6 class="card-subtitle text-muted mb-1">Calories</h6>
<h3 class="card-title text-warning" id="metric-cal">-</h3>
</div>
</div>
</div>
</div>
<!-- Detailed Metrics Grid -->
<h4 class="mb-3">Detailed Metrics</h4>
<div class="row g-3">
<!-- Segments Card -->
<div class="col-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Matched Segments</h5>
<!-- Could trigger re-scan here -->
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="efforts-table">
<thead class="table-light">
<tr>
<th>Segment</th>
<th>Time</th>
<th>Awards</th>
<th>Rank</th>
</tr>
</thead>
<tbody>
<tr id="efforts-loading">
<td colspan="4" class="text-center text-muted">Loading segments...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Heart Rate -->
<div class="col-md-4">
<div class="card h-100 metric-card border-danger">
<div class="card-header bg-danger text-white">Heart Rate</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Average:</span> <strong id="m-avg-hr">-</strong>
bpm</div>
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-hr">-</strong> bpm</div>
</div>
</div>
</div>
<!-- Speed/Pace -->
<div class="col-md-4">
<div class="card h-100 metric-card border-primary">
<div class="card-header bg-primary text-white">Speed / Pace</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Avg Speed:</span> <strong
id="m-avg-spd">-</strong> km/h</div>
<div class="d-flex justify-content-between"><span>Max Speed:</span> <strong id="m-max-spd">-</strong>
km/h</div>
</div>
</div>
</div>
<!-- Power -->
<div class="col-md-4">
<div class="card h-100 metric-card border-warning">
<div class="card-header bg-warning text-dark">Power</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Avg Power:</span> <strong
id="m-avg-pwr">-</strong> W</div>
<div class="d-flex justify-content-between mb-2"><span>Max Power:</span> <strong
id="m-max-pwr">-</strong> W</div>
<div class="d-flex justify-content-between mb-2"><span>Norm Power:</span> <strong
id="m-norm-pwr">-</strong> W</div>
<div class="d-flex justify-content-between"><span>VO2 Max:</span> <strong id="m-vo2">-</strong></div>
</div>
</div>
</div>
<!-- Elevation -->
<div class="col-md-4">
<div class="card h-100 metric-card border-success">
<div class="card-header bg-success text-white">Elevation</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Gain:</span> <strong id="m-ele-gain">-</strong> m
</div>
<div class="d-flex justify-content-between"><span>Loss:</span> <strong id="m-ele-loss">-</strong> m
</div>
</div>
</div>
</div>
<!-- Training Effect -->
<div class="col-md-4">
<div class="card h-100 metric-card border-info">
<div class="card-header bg-info text-white">Training Effect</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Aerobic:</span> <strong id="m-aerobic">-</strong>
</div>
<div class="d-flex justify-content-between mb-2"><span>Anaerobic:</span> <strong
id="m-anaerobic">-</strong></div>
<div class="d-flex justify-content-between"><span>TSS:</span> <strong id="m-tss">-</strong></div>
</div>
</div>
</div>
<!-- Cadence -->
<div class="col-md-4">
<div class="card h-100 metric-card border-secondary">
<div class="card-header bg-secondary text-white">Cadence</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Avg:</span> <strong id="m-avg-cad">-</strong>
</div>
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-cad">-</strong></div>
</div>
</div>
</div>
<!-- Respiration -->
<div class="col-md-4">
<div class="card h-100 metric-card border-info">
<div class="card-header bg-info text-white">Respiration</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Avg:</span> <strong id="m-avg-resp">-</strong>
br/min</div>
<div class="d-flex justify-content-between"><span>Max:</span> <strong id="m-max-resp">-</strong> br/min
</div>
</div>
</div>
</div>
<!-- Bike Info (New) -->
<div class="col-md-4">
<div class="card h-100 metric-card border-light shadow-sm">
<div class="card-header bg-light text-dark d-flex justify-content-between align-items-center">
<span>Bike Setup</span>
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="editBikeSetup()">Edit</button>
</div>
<div class="card-body">
<div id="m-bike-info" class="text-center text-muted">No Setup</div>
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="h5 mb-0">Activity Streams</span>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="smooth-toggle" onchange="toggleSmoothing()">
<label class="form-check-label" for="smooth-toggle">Smooth Data</label>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="openChartModal()">
<i class="bi bi-arrows-fullscreen"></i> Full Screen
</button>
</div>
</div>
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="card-title fw-bold mb-1">Cycling Workout Analysis</h4>
<p class="text-muted small mb-0">Toggle metrics to customize view</p>
</div>
<div class="d-flex align-items-center gap-2">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="smooth-toggle"
onchange="toggleSmoothing()">
<label class="form-check-label" for="smooth-toggle">Smooth</label>
</div>
<button class="btn btn-sm btn-outline-secondary" onclick="openChartModal()">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
<!-- Metric Toggles -->
<div class="d-flex flex-wrap gap-2 mb-4" id="chart-toggles">
<!-- Buttons injected by JS -->
</div>
<!-- Chart -->
<div style="height: 600px; position: relative;">
<canvas id="streams-chart"></canvas>
</div>
<!-- Summary Footer -->
<div class="row g-3 mt-4 pt-4 border-top" id="chart-footer">
<!-- Stats injected by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- Full Screen Chart Modal -->
<div class="modal fade" id="chartModal" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Activity Streams (Full Screen)</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3">
<input class="form-check-input" type="checkbox" id="modal-smooth-toggle"
onchange="toggleModalSmoothing()">
<label class="form-check-label" for="modal-smooth-toggle">Smooth Data</label>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body bg-light">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div style="height: 100%; width: 100%;">
<canvas id="modal-chart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
const activityId = "{{ activity_id }}";
let map = null;
document.addEventListener('DOMContentLoaded', async () => {
// Init Map
map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
await loadDetails();
await loadMapData();
document.getElementById('download-btn').onclick = () => {
window.location.href = `/api/activities/download/${activityId}`;
};
loadNavigation();
loadCharts();
});
async function loadNavigation() {
try {
const res = await fetch(`/api/activities/${activityId}/navigation`);
if (res.ok) {
const nav = await res.json();
function setBtn(id, targetId) {
const el = document.getElementById(id);
if (targetId) {
el.href = `/activity/${targetId}`;
el.classList.remove('disabled');
}
}
setBtn('nav-prev', nav.prev_id);
setBtn('nav-next', nav.next_id);
setBtn('nav-prev-type', nav.prev_type_id);
setBtn('nav-next-type', nav.next_type_id);
}
} catch (e) { console.error("Nav load failed", e); }
}
let chartInstance = null;
let modalChartInstance = null;
let streamData = null; // Store fetched data
async function loadCharts() {
try {
const res = await fetch(`/api/activities/${activityId}/streams`);
if (!res.ok) return;
const data = await res.json();
if (!data.time || data.time.length === 0) return;
streamData = data; // Cache for modal
renderChart('streams-chart', data);
renderToggles();
renderFooter(data);
} catch (e) {
console.error("Chart load failed", e);
}
}
let isSmoothingEnabled = false;
// Visibility state (managed manually to sync custom buttons with chart)
let metricVisibility = {
heart_rate: true,
speed: true,
power: true,
altitude: false, // Match user preference
cadence: false,
respiration_rate: false
};
const metricConfig = {
heart_rate: { label: 'Heart Rate', icon: '❤️', color: '#ef4444' },
speed: { label: 'Speed', icon: '🚴', color: '#3b82f6' },
power: { label: 'Power', icon: '⚡', color: '#f97316' },
altitude: { label: 'Elevation', icon: '⛰️', color: '#16a34a' },
cadence: { label: 'Cadence', icon: '🔄', color: '#a855f7' },
respiration_rate: { label: 'Resp', icon: '🫁', color: '#0dcaf0' }
};
function toggleMetric(key) {
metricVisibility[key] = !metricVisibility[key];
renderToggles();
if (chartInstance) updateChartVisibility(chartInstance);
if (modalChartInstance) updateChartVisibility(modalChartInstance);
}
function renderToggles() {
const container = document.getElementById('chart-toggles');
if (!container) return;
let html = '';
Object.keys(metricConfig).forEach(key => {
// Only show toggle if data exists
if (streamData && streamData[key] && streamData[key].some(x => x)) {
const cfg = metricConfig[key];
const active = metricVisibility[key];
const bg = active ? cfg.color : '#e5e7eb';
const fg = active ? 'white' : '#4b5563';
const shadow = active ? '0 4px 6px rgba(0,0,0,0.1)' : 'none';
html += `<button onclick="toggleMetric('${key}')" style="
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
background-color: ${bg};
color: ${fg};
box-shadow: ${shadow};
transition: all 0.2s;
">${cfg.icon} ${cfg.label}</button>`;
}
});
container.innerHTML = html;
}
function renderFooter(data) {
const container = document.getElementById('chart-footer');
if (!container) return;
function avg(arr) {
const valid = arr.filter(x => x !== null);
return valid.length ? (valid.reduce((a, b) => a + b, 0) / valid.length) : 0;
}
const stats = [];
if (data.heart_rate) stats.push({ label: 'Avg HR', val: Math.round(avg(data.heart_rate)) + ' bpm', color: metricConfig.heart_rate.color });
if (data.speed) stats.push({ label: 'Avg Speed', val: (avg(data.speed) * 3.6).toFixed(1) + ' km/h', color: metricConfig.speed.color });
if (data.power) stats.push({ label: 'Avg Power', val: Math.round(avg(data.power)) + ' W', color: metricConfig.power.color });
// Elevation Gain (Approx)
if (data.altitude) {
let gain = 0;
for (let i = 1; i < data.altitude.length; i++) {
if (data.altitude[i] > data.altitude[i - 1]) gain += (data.altitude[i] - data.altitude[i - 1]);
}
stats.push({ label: 'Total Climb', val: Math.round(gain) + ' m', color: metricConfig.altitude.color });
}
if (data.cadence) stats.push({ label: 'Avg Cadence', val: Math.round(avg(data.cadence)) + ' rpm', color: metricConfig.cadence.color });
container.innerHTML = stats.map(s => `
<div class="col-md-2 text-center">
<p style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">${s.label}</p>
<p style="font-size: 18px; font-weight: bold; color: ${s.color}; margin: 0;">${s.val}</p>
</div>
`).join('');
}
function updateChartVisibility(chart) {
chart.data.datasets.forEach(ds => {
ds.hidden = !metricVisibility[ds.rawKey];
});
chart.update();
}
function toggleSmoothing() {
isSmoothingEnabled = document.getElementById('smooth-toggle').checked;
const modalToggle = document.getElementById('modal-smooth-toggle');
if (modalToggle) modalToggle.checked = isSmoothingEnabled;
if (streamData) {
renderChart('streams-chart', streamData);
if (modalChartInstance) renderChart('modal-chart', streamData, true);
}
}
function toggleModalSmoothing() {
isSmoothingEnabled = document.getElementById('modal-smooth-toggle').checked;
const mainToggle = document.getElementById('smooth-toggle');
if (mainToggle) mainToggle.checked = isSmoothingEnabled;
if (streamData) renderChart('modal-chart', streamData, true);
}
function movingAverage(data, windowSize) {
if (!data || data.length === 0) return [];
const result = [];
for (let i = 0; i < data.length; i++) {
let sum = 0;
let count = 0;
const offset = Math.floor(windowSize / 2);
for (let j = i - offset; j < i - offset + windowSize; j++) {
if (j >= 0 && j < data.length && data[j] !== null) {
sum += data[j];
count++;
}
}
result.push(count > 0 ? sum / count : null);
}
return result;
}
function openChartModal() {
if (!streamData) return;
const mt = document.getElementById('modal-smooth-toggle');
if (mt) mt.checked = isSmoothingEnabled;
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
modal.show();
document.getElementById('chartModal').addEventListener('shown.bs.modal', () => {
renderChart('modal-chart', streamData, true);
}, { once: true });
}
function renderChart(canvasId, rawData, isModal = false) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (canvasId === 'streams-chart' && chartInstance) { chartInstance.destroy(); chartInstance = null; }
else if (canvasId === 'modal-chart' && modalChartInstance) { modalChartInstance.destroy(); modalChartInstance = null; }
const data = { ...rawData };
if (isSmoothingEnabled) {
const window = 10;
Object.keys(metricConfig).forEach(k => {
if (data[k]) data[k] = movingAverage(data[k], window);
});
}
const datasets = [];
const commonOptions = { pointRadius: 0, borderWidth: 2, tension: 0.4 };
if (data.heart_rate && data.heart_rate.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Heart Rate',
rawKey: 'heart_rate',
data: data.heart_rate,
borderColor: metricConfig.heart_rate.color,
backgroundColor: metricConfig.heart_rate.color,
yAxisID: 'left',
borderWidth: 2.5,
hidden: !metricVisibility.heart_rate
});
}
if (data.speed && data.speed.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Speed',
rawKey: 'speed',
data: data.speed.map(s => s ? s * 3.6 : null),
borderColor: metricConfig.speed.color,
backgroundColor: metricConfig.speed.color,
yAxisID: 'right',
hidden: !metricVisibility.speed
});
}
if (data.power && data.power.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Power',
rawKey: 'power',
data: data.power,
borderColor: metricConfig.power.color,
backgroundColor: metricConfig.power.color,
yAxisID: 'right',
hidden: !metricVisibility.power
});
}
if (data.altitude && data.altitude.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Elevation',
rawKey: 'altitude',
data: data.altitude,
borderColor: metricConfig.altitude.color,
backgroundColor: metricConfig.altitude.color,
yAxisID: 'right',
borderWidth: 2,
hidden: !metricVisibility.altitude
});
}
if (data.cadence && data.cadence.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Cadence',
rawKey: 'cadence',
data: data.cadence,
borderColor: metricConfig.cadence.color,
backgroundColor: metricConfig.cadence.color,
yAxisID: 'left',
borderDash: [5, 5],
borderWidth: 1.5,
hidden: !metricVisibility.cadence
});
}
if (data.respiration_rate && data.respiration_rate.some(x => x)) {
datasets.push({
...commonOptions,
label: 'Respiration',
rawKey: 'respiration_rate',
data: data.respiration_rate,
borderColor: metricConfig.respiration_rate.color,
backgroundColor: metricConfig.respiration_rate.color,
yAxisID: 'left',
hidden: !metricVisibility.respiration_rate
});
}
const labels = data.time.map(t => {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
return h > 0 ? `${h}h${m}m` : `${m}m`;
});
const newChart = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false }, // Using custom toggles
tooltip: {
backgroundColor: 'white',
titleColor: '#374151',
bodyColor: '#4b5563',
borderColor: '#d1d5db',
borderWidth: 1,
padding: 12,
boxPadding: 4,
callbacks: {
labelColor: function (context) {
return {
borderColor: context.dataset.borderColor,
backgroundColor: context.dataset.borderColor
};
}
}
}
},
scales: {
x: {
ticks: { maxTicksLimit: isModal ? 20 : 10, maxRotation: 0 },
grid: { display: true, drawOnChartArea: true, drawTicks: true, color: '#f3f4f6' }
},
left: {
type: 'linear',
position: 'left',
grid: { display: true, color: '#e5e7eb', borderDash: [3, 3] },
min: 0
},
right: {
type: 'linear',
position: 'right',
grid: { display: false },
min: 0
}
}
}
});
if (canvasId === 'streams-chart') chartInstance = newChart;
else if (canvasId === 'modal-chart') modalChartInstance = newChart;
}
async function loadDetails() {
try {
const res = await fetch(`/api/activities/${activityId}/details`);
if (!res.ok) throw new Error("Failed to load details");
const data = await res.json();
window.currentDbId = data.id; // Store for segment creation
window.currentActivityType = data.activity_type;
// Header
document.getElementById('act-name').textContent = data.activity_name || 'Untitled Activity';
document.getElementById('act-time').textContent = new Date(data.start_time).toLocaleString();
document.getElementById('act-type').textContent = data.activity_type;
document.getElementById('act-id').textContent = data.garmin_activity_id;
// Overview Cards
document.getElementById('metric-dist').textContent = data.distance ? (data.distance / 1000).toFixed(2) + ' km' : '-';
document.getElementById('metric-dur').textContent = formatDuration(data.duration);
document.getElementById('metric-hr').textContent = data.avg_hr ? data.avg_hr + ' bpm' : '-';
document.getElementById('metric-cal').textContent = data.calories || '-';
// Detail Cards
// HR
document.getElementById('m-avg-hr').textContent = data.avg_hr || '-';
document.getElementById('m-max-hr').textContent = data.max_hr || '-';
// Speed
document.getElementById('m-avg-spd').textContent = data.avg_speed ? (data.avg_speed * 3.6).toFixed(1) : '-';
document.getElementById('m-max-spd').textContent = data.max_speed ? (data.max_speed * 3.6).toFixed(1) : '-';
// Power
document.getElementById('m-avg-pwr').textContent = data.avg_power || '-';
document.getElementById('m-max-pwr').textContent = data.max_power || '-';
document.getElementById('m-norm-pwr').textContent = data.norm_power || '-';
document.getElementById('m-vo2').textContent = data.vo2_max || '-';
// Elevation
document.getElementById('m-ele-gain').textContent = data.elevation_gain || '-';
document.getElementById('m-ele-loss').textContent = data.elevation_loss || '-';
// TE
document.getElementById('m-aerobic').textContent = data.aerobic_te || '-';
document.getElementById('m-anaerobic').textContent = data.anaerobic_te || '-';
document.getElementById('m-tss').textContent = data.tss || '-';
// Cadence
document.getElementById('m-avg-cad').textContent = data.avg_cadence || '-';
document.getElementById('m-max-cad').textContent = data.max_cadence || '-';
// Respiration
document.getElementById('m-avg-resp').textContent = data.avg_respiration_rate ? data.avg_respiration_rate.toFixed(1) : '-';
document.getElementById('m-max-resp').textContent = data.max_respiration_rate ? data.max_respiration_rate.toFixed(1) : '-';
// Bike
if (data.bike_setup) {
const b = data.bike_setup;
const txt = b.name ? `<strong>${b.name}</strong><br>${b.frame} ${b.chainring}/${b.rear_cog}` : `${b.frame} ${b.chainring}/${b.rear_cog}`;
document.getElementById('m-bike-info').innerHTML = txt;
}
// Load Efforts
if (window.currentDbId) {
loadEfforts(window.currentDbId);
}
} catch (e) {
console.error(e);
showToast("Error", "Failed to load activity details", "error");
}
}
async function loadMapData() {
try {
const res = await fetch(`/api/activities/${activityId}/geojson`);
if (res.ok) {
const geojson = await res.json();
if (geojson.features && geojson.features.length > 0 && geojson.features[0].geometry.coordinates.length > 0) {
// GeoJSON coords are [lon, lat]. Leaflet wants [lat, lon]
const coords = geojson.features[0].geometry.coordinates;
trackPoints = coords.map(p => [p[1], p[0]]);
const layer = L.geoJSON(geojson, {
style: { color: 'red', weight: 4, opacity: 0.7 }
}).addTo(map);
map.fitBounds(layer.getBounds());
} else {
document.getElementById('map').style.display = 'none';
document.getElementById('no-map-msg').style.display = 'block';
}
} else {
throw new Error("Failed to load map data");
}
} catch (e) {
console.error(e);
document.getElementById('map').style.display = 'none';
document.getElementById('no-map-msg').style.display = 'block';
document.getElementById('no-map-msg').querySelector('p').textContent = "Map data unavailable.";
}
}
function formatDuration(s) { if (!s) return '-'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; return `${h}h ${m}m ${sec}s`; }
// Segment Creation Logic
let segmentMode = false;
let startMarker = null;
let endMarker = null;
let trackPoints = []; // List of [lat, lon] from GeoJSON
let startIndex = 0;
let endIndex = 0;
function toggleSegmentMode() {
segmentMode = !segmentMode;
const btn = document.getElementById('create-segment-btn');
if (segmentMode) {
btn.classList.add('active');
btn.innerHTML = '<i class="bi bi-check-lg"></i> Save Segment';
btn.onclick = saveSegment;
initSegmentMarkers();
} else {
// Cancelled
btn.classList.remove('active');
btn.innerHTML = '<i class="bi bi-bezier2"></i> Create Segment';
btn.onclick = toggleSegmentMode;
removeSegmentMarkers();
}
}
function removeSegmentMarkers() {
if (startMarker) map.removeLayer(startMarker);
if (endMarker) map.removeLayer(endMarker);
startMarker = null;
endMarker = null;
}
function initSegmentMarkers() {
if (trackPoints.length < 2) {
alert("Not enough points to create a segment.");
toggleSegmentMode();
return;
}
// Default positions: 20% and 80%
startIndex = Math.floor(trackPoints.length * 0.2);
endIndex = Math.floor(trackPoints.length * 0.8);
const startIcon = L.divIcon({ className: 'bg-success rounded-circle border border-white', iconSize: [12, 12] });
const endIcon = L.divIcon({ className: 'bg-danger rounded-circle border border-white', iconSize: [12, 12] });
startMarker = L.marker(trackPoints[startIndex], { draggable: true, icon: startIcon }).addTo(map);
endMarker = L.marker(trackPoints[endIndex], { draggable: true, icon: endIcon }).addTo(map);
// Snap logic
function setupDrag(marker, isStart) {
marker.on('drag', function (e) {
const ll = e.latlng;
let closestDist = Infinity;
let closestIdx = -1;
// Simple snap for visual feedback during drag
for (let i = 0; i < trackPoints.length; i++) {
const d = map.distance(ll, trackPoints[i]);
if (d < closestDist) {
closestDist = d;
closestIdx = i;
}
}
// Optional: visual snap? Leaflet handles drag msg.
});
marker.on('dragend', function (e) {
const ll = e.target.getLatLng();
let closestDist = Infinity;
let closestIdx = -1;
// constrain search
let searchStart = 0;
let searchEnd = trackPoints.length;
if (isStart) {
// Start marker: can search 0 to trackPoints.length
// Heuristic: If we are modifying Start, look for points < endIndex (if valid).
if (endIndex > 0) searchEnd = endIndex;
} else {
// End marker
if (startIndex >= 0) searchStart = startIndex;
}
// "Stickiness" logic
const currentIndex = isStart ? startIndex : endIndex;
const indexPenalty = 0.0001;
for (let i = searchStart; i < searchEnd; i++) {
const d_spatial = map.distance(ll, trackPoints[i]);
const d_index = Math.abs(i - currentIndex);
const score = d_spatial + (d_index * indexPenalty);
if (score < closestDist) {
closestDist = score;
closestIdx = i;
}
}
if (closestIdx !== -1) {
marker.setLatLng(trackPoints[closestIdx]);
if (isStart) startIndex = closestIdx;
else endIndex = closestIdx;
}
});
}
setupDrag(startMarker, true);
setupDrag(endMarker, false);
}
async function refreshActivity() {
if (!confirm("Are you sure you want to re-download this activity from Garmin and run bike matching? This will overwrite any manual bike selection.")) {
return;
}
const btn = document.getElementById('refresh-btn');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Refreshing...';
// Helper toast fallback if showToast not defined in this view (it inherits from base.html usually?)
// base.html usually has showToast.
if (typeof showToast === 'function') showToast("Processing...", "Refreshing activity data...", "info");
try {
const res = await fetch(`/api/activities/${activityId}/redownload`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
if (typeof showToast === 'function') showToast("Success", data.message, "success");
else alert(data.message);
// Reload details
loadDetails();
loadCharts();
} else {
throw new Error(data.detail || "Refresh failed");
}
} catch (e) {
console.error(e);
if (typeof showToast === 'function') showToast("Error", e.message, "error");
else alert("Error: " + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = origHtml;
}
}
let allBikes = [];
async function fetchAllBikes() {
try {
const res = await fetch('/api/bike-setups');
if (res.ok) allBikes = await res.json();
} catch (e) { console.error(e); }
}
document.addEventListener('DOMContentLoaded', fetchAllBikes);
function editBikeSetup() {
const container = document.getElementById('m-bike-info');
if (container.querySelector('select')) return;
// Current text
const currentHtml = container.innerHTML;
let initialSelect = `<div class="input-group input-group-sm">
<select class="form-select" id="bike-select">
<option value="">-- No Bike --</option>`;
allBikes.forEach(b => {
initialSelect += `<option value="${b.id}">${b.name || b.frame} (${b.chainring}/${b.rear_cog})</option>`;
});
initialSelect += `</select>
<button class="btn btn-success" onclick="saveBikeSetup()"><i class="bi bi-check"></i></button>
<button class="btn btn-outline-secondary" onclick="loadDetails()"><i class="bi bi-x"></i></button>
</div>`;
container.innerHTML = initialSelect;
}
async function saveBikeSetup() {
const sel = document.getElementById('bike-select');
const newId = sel.value ? parseInt(sel.value) : null;
try {
const res = await fetch(`/api/activities/${activityId}/bike`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bike_setup_id: newId, manual_override: true })
});
if (res.ok) {
if (typeof showToast === 'function') showToast("Success", "Bike setup updated", "success");
else alert("Bike setup updated");
loadDetails();
} else {
const err = await res.json();
alert("Error: " + err.detail);
}
} catch (e) {
console.error(e);
alert("Save failed");
}
}
async function estimatePower() {
if (!confirm("Estimate power for this activity using physics usage calculation? This will update average/max power stats.")) return;
const btn = document.getElementById('estimate-power-btn');
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Estimating...';
try {
const res = await fetch(`/api/activities/${window.currentDbId}/estimate_power`, {
method: 'POST'
});
if (res.ok) {
const data = await res.json();
alert("Power estimation complete! Avg: " + data.stats.avg_power + " W");
loadDetails(); // Refresh stats
loadCharts(); // Refresh charts if stream updated (Service returns stream but we'd need to reload)
} else {
const err = await res.json();
alert("Error: " + err.detail);
}
} catch (e) {
console.error(e);
alert("Estimate failed: " + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = origText;
}
}
async function saveSegment() {
if (startIndex >= endIndex) {
alert("Start point must be before End point.");
return;
}
const name = prompt("Enter Segment Name:");
if (!name) return;
try {
const res = await fetch('/api/segments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
activity_id: window.currentDbId,
activity_type: window.currentActivityType,
start_index: startIndex,
end_index: endIndex
})
});
if (res.ok) {
alert("Segment created!");
toggleSegmentMode(); // Reset UI
} else {
const err = await res.json();
alert("Error: " + err.detail);
}
// Load Segments
loadEfforts(window.currentDbId);
} catch (e) {
console.error(e);
alert("Error loading activity: " + e.message);
}
}
async function loadEfforts(dbId) {
const tbody = document.querySelector('#efforts-table tbody');
try {
const res = await fetch(`/api/activities/${dbId}/efforts`);
if (res.ok) {
const efforts = await res.json();
tbody.innerHTML = '';
if (efforts.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No segments matched.</td></tr>';
return;
}
efforts.forEach(eff => {
const tr = document.createElement('tr');
let awards = '';
if (eff.is_kom) awards += '<span class="badge bg-warning text-dark me-1"><i class="bi bi-trophy-fill"></i> CR</span>';
if (eff.is_pr) awards += '<span class="badge bg-success me-1"><i class="bi bi-award-fill"></i> PR</span>';
tr.innerHTML = `
<td><strong>${eff.segment_name}</strong></td>
<td>${formatDuration(eff.elapsed_time)}</td>
<td>${awards}</td>
<td>${eff.kom_rank ? '#' + eff.kom_rank : '-'}</td>
`;
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Failed to load segments.</td></tr>';
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Error loading segments.</td></tr>';
}
}
// Leaflet Map Init
</script>
{% endblock %}