Files
FitTrack2/FitnessSync/backend/templates/activity_view.html
2026-01-09 09:59:36 -08:00

496 lines
20 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>
</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">
<!-- 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>
<!-- 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">Bike Setup</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">
Activity Streams
</div>
<div class="card-body">
<canvas id="streams-chart" style="max-height: 400px;"></canvas>
</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;
async function loadCharts() {
try {
const res = await fetch(`/api/activities/${activityId}/streams`);
if (!res.ok) return; // No streams
const data = await res.json();
if (!data.time || data.time.length === 0) return;
const ctx = document.getElementById('streams-chart').getContext('2d');
// Prepare datasets
const datasets = [];
if (data.heart_rate && data.heart_rate.some(x => x)) {
datasets.push({
label: 'Heart Rate (bpm)',
data: data.heart_rate,
borderColor: 'rgb(220, 53, 69)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
yAxisID: 'y-hr',
tension: 0.2,
pointRadius: 0
});
}
if (data.speed && data.speed.some(x => x)) {
// Convert m/s to km/h
const speedKmh = data.speed.map(s => s ? s * 3.6 : null);
datasets.push({
label: 'Speed (km/h)',
data: speedKmh,
borderColor: 'rgb(13, 110, 253)',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
yAxisID: 'y-speed',
tension: 0.2,
pointRadius: 0
});
}
if (data.power && data.power.some(x => x)) {
datasets.push({
label: 'Power (W)',
data: data.power,
borderColor: 'rgb(255, 193, 7)',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
yAxisID: 'y-power',
tension: 0.2,
pointRadius: 0
});
}
if (data.altitude && data.altitude.some(x => x)) {
datasets.push({
label: 'Elevation (m)',
data: data.altitude,
borderColor: 'rgb(25, 135, 84)',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
yAxisID: 'y-ele',
tension: 0.2,
pointRadius: 0,
fill: true
});
}
if (data.cadence && data.cadence.some(x => x)) {
datasets.push({
label: 'Cadence (rpm)',
data: data.cadence,
borderColor: 'rgb(108, 117, 125)',
backgroundColor: 'rgba(108, 117, 125, 0.1)',
yAxisID: 'y-cad',
tension: 0.2,
pointRadius: 0,
hidden: true
});
}
// Seconds to H:M:S format for labels
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`;
});
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
ticks: { maxTicksLimit: 10 }
},
'y-hr': {
type: 'linear',
display: !!datasets.find(d => d.yAxisID === 'y-hr'),
position: 'right',
title: { display: true, text: 'Heart Rate' },
grid: { drawOnChartArea: false }
},
'y-power': {
type: 'linear',
display: !!datasets.find(d => d.yAxisID === 'y-power'),
position: 'left',
title: { display: true, text: 'Power' }
},
'y-speed': {
type: 'linear',
display: !!datasets.find(d => d.yAxisID === 'y-speed'),
position: 'right',
title: { display: true, text: 'Speed' },
grid: { drawOnChartArea: false }
},
'y-ele': {
type: 'linear',
display: !!datasets.find(d => d.yAxisID === 'y-ele'),
position: 'right',
title: { display: true, text: 'Elevation' },
grid: { drawOnChartArea: false }
},
'y-cad': {
type: 'linear',
display: !!datasets.find(d => d.yAxisID === 'y-cad'),
position: 'right',
title: { display: true, text: 'Cadence' },
grid: { drawOnChartArea: false }
}
}
}
});
} catch (e) {
console.error("Chart load failed", e);
}
}
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();
// 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 || '-';
// 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;
}
} 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) {
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`; }
</script>
{% endblock %}