many updates

This commit is contained in:
2026-01-13 09:42:16 -08:00
parent 4bb86b603e
commit 362f4cb5aa
81 changed files with 3106 additions and 336 deletions

View File

@@ -45,6 +45,65 @@
<option value="yoga">Yoga</option>
</select>
</div>
<div class="col-auto">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="metricFilterDropdown"
data-bs-toggle="dropdown" aria-expanded="false">
Metric Filters
</button>
<ul class="dropdown-menu p-3" aria-labelledby="metricFilterDropdown" style="min-width: 250px;">
<li>
<h6 class="dropdown-header">Required Metrics</h6>
</li>
<li>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="filter-has-hr">
<label class="form-check-label" for="filter-has-hr">Has Heart Rate</label>
</div>
</li>
<li>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="filter-has-power">
<label class="form-check-label" for="filter-has-power">Has Power</label>
</div>
</li>
<li>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="filter-has-cadence">
<label class="form-check-label" for="filter-has-cadence">Has Cadence</label>
</div>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<h6 class="dropdown-header">Power Type</h6>
</li>
<li>
<div class="form-check">
<input class="form-check-input" name="est-power-filter" type="radio"
id="filter-power-any" checked>
<label class="form-check-label" for="filter-power-any">Any</label>
</div>
</li>
<li>
<div class="form-check">
<input class="form-check-input" name="est-power-filter" type="radio"
id="filter-power-real">
<label class="form-check-label" for="filter-power-real">Real Power Only</label>
</div>
</li>
<li>
<div class="form-check">
<input class="form-check-input" name="est-power-filter" type="radio"
id="filter-power-est">
<label class="form-check-label" for="filter-power-est">Estimated Only</label>
</div>
</li>
</ul>
</div>
</div>
<div class="col-auto">
<label for="filter-bike" class="col-form-label">Bike:</label>
</div>
@@ -392,12 +451,26 @@
let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`;
// If any filter is active, force query mode
if (typeFilter || bikeFilter) {
// If any filter is active, force query mode
const hasHr = document.getElementById('filter-has-hr').checked;
const hasPower = document.getElementById('filter-has-power').checked;
const hasCadence = document.getElementById('filter-has-cadence').checked;
const powerReal = document.getElementById('filter-power-real').checked;
const powerEst = document.getElementById('filter-power-est').checked;
if (typeFilter || bikeFilter || hasHr || hasPower || hasCadence || powerReal || powerEst) {
url = `/api/activities/query?`;
const params = new URLSearchParams();
if (typeFilter) params.append('activity_type', typeFilter);
if (bikeFilter) params.append('bike_setup_id', bikeFilter);
if (hasHr) params.append('has_hr', 'true');
if (hasPower) params.append('has_power', 'true');
if (hasCadence) params.append('has_cadence', 'true');
if (powerReal) params.append('is_estimated_power', 'false');
if (powerEst) params.append('is_estimated_power', 'true');
url += params.toString();
document.getElementById('prev-page-btn').disabled = true;

View File

@@ -65,6 +65,9 @@
<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>
@@ -231,6 +234,19 @@
</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">
@@ -249,11 +265,78 @@
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header">
Activity Streams
<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">
<canvas id="streams-chart" style="max-height: 400px;"></canvas>
<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>
@@ -309,156 +392,338 @@
}
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; // No streams
if (!res.ok) return;
const data = await res.json();
if (!data.time || data.time.length === 0) return;
streamData = data; // Cache for modal
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 }
}
}
}
});
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';
@@ -494,6 +759,9 @@
// 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) {
@@ -798,6 +1066,7 @@
body: JSON.stringify({
name: name,
activity_id: window.currentDbId,
activity_type: window.currentActivityType,
start_index: startIndex,
end_index: endIndex
})

View File

@@ -440,5 +440,23 @@
alert('Error saving segment: ' + err.message);
}
}
// Handle Query Params
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const actId = urlParams.get('activity_id');
if (actId) {
// Switch to single tab
const tabEl = document.getElementById('single-tab');
const tab = new bootstrap.Tab(tabEl);
tab.show();
// Fill ID
document.getElementById('activityId').value = actId;
// Trigger search
document.getElementById('singleSearchBtn').click();
}
});
</script>
{% endblock %}

View File

@@ -225,8 +225,8 @@
</div>
<div class="mb-3">
<label class="form-label">Interval (Minutes)</label>
<input type="number" class="form-control" id="edit-job-interval" min="1" required>
<div class="form-text">How often this job should run.</div>
<input type="number" class="form-control" id="edit-job-interval" min="0" required>
<div class="form-text">How often this job should run. Set to 0 for Manual Only (Adhoc).</div>
</div>
<div class="mb-3">
<label class="form-label">Parameters (JSON)</label>
@@ -261,13 +261,7 @@
<div class="mb-3">
<label class="form-label">Job Type</label>
<select class="form-select" id="create-job-type" onchange="updateParamsHelp()">
<option value="fitbit_weight_sync">Fitbit Weight Sync</option>
<option value="activity_sync">Garmin Activities Sync</option>
<option value="metrics_sync">Garmin Metrics Sync</option>
<option value="health_scan">Health Data Scan</option>
<option value="health_sync_pending">Sync Pending Health</option>
<option value="garmin_weight_upload">Upload Weight to Garmin</option>
<option value="activity_backfill_full">Activity Backfill (Full)</option>
<option value="" disabled selected>Loading...</option>
</select>
</div>
<div class="mb-3">
@@ -277,7 +271,8 @@
</div>
<div class="mb-3">
<label class="form-label">Interval (Minutes)</label>
<input type="number" class="form-control" id="create-job-interval" min="1" value="60" required>
<input type="number" class="form-control" id="create-job-interval" min="0" value="60" required>
<div class="form-text">Set to 0 for Manual Only.</div>
</div>
<div class="mb-3">
@@ -366,6 +361,7 @@
loadJobs();
if (typeof updateParamsHelp === 'function') updateParamsHelp(); // Init helper text
loadDashboardData();
populateJobTypes();
// Auto-refresh dashboard data every 3 seconds
setInterval(loadDashboardData, 3000);
@@ -851,7 +847,7 @@
const paramsStr = document.getElementById('create-job-params').value;
if (!name) { alert("Name is required"); return; }
if (interval < 1) { alert("Interval must be > 0"); return; }
if (interval < 0) { alert("Interval must be >= 0"); return; }
let params = {};
try {
@@ -992,8 +988,8 @@
const enabled = document.getElementById('edit-job-enabled').checked;
const paramsStr = document.getElementById('edit-job-params').value;
if (interval < 1) {
alert("Interval must be at least 1 minute");
if (interval < 0) {
alert("Interval must be at least 0 minutes");
return;
}
@@ -1029,5 +1025,32 @@
alert("Error: " + e.message);
}
}
async function populateJobTypes() {
const select = document.getElementById('create-job-type');
try {
const response = await fetch('/api/scheduling/available-types');
if (!response.ok) throw new Error('Failed to fetch types');
const types = await response.json();
select.innerHTML = '';
types.forEach(type => {
const option = document.createElement('option');
option.value = type;
// Simple label transformation: underscores to spaces, Title Case
option.text = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
select.appendChild(option);
});
// Trigger help update for first item
if (types.length > 0) {
select.selectedIndex = 0;
updateParamsHelp();
}
} catch (e) {
console.error("Error loading job types", e);
select.innerHTML = '<option disabled>Error loading types</option>';
}
}
</script>
{% endblock %}

View File

@@ -82,11 +82,14 @@
<table class="table table-sm table-striped" id="leaderboard-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all-efforts"
onclick="toggleAllEfforts(this)"></th>
<th>Rank</th>
<th>Date</th>
<th>Time</th>
<th>Avg HR</th>
<th>Watts</th>
<th>Max Watts</th>
</tr>
</thead>
<tbody>
@@ -96,12 +99,45 @@
</tbody>
</table>
</div>
<div class="mt-3">
<button class="btn btn-primary" onclick="compareSelectedEfforts()" id="btn-compare"
disabled>
<i class="bi bi-bar-chart-line"></i> Compare Selected
</button>
<button class="btn btn-outline-secondary" onclick="exportSelectedEfforts()" id="btn-export"
disabled>
<i class="bi bi-download"></i> Export JSON
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Comparison Modal -->
<div class="modal fade" id="compareModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Effort Comparison</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-bordered table-striped text-center align-middle" id="comparison-table">
<!-- Filled by JS -->
</table>
</div>
<div class="mt-4" style="height: 300px;">
<canvas id="comparisonChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
@@ -113,7 +149,7 @@
if (!confirm("This will rescan ALL activities for all segments. It may take a while. Continue?")) return;
try {
const response = await fetch('/api/segments/scan', { method: 'POST' });
const response = await fetch('/api/segments/scan?force=true', { method: 'POST' });
if (!response.ok) throw new Error("Scan failed");
const data = await response.json();
alert("Scan started! Background Job ID: " + data.job_id);
@@ -181,6 +217,7 @@
let map = null;
let elevationChart = null;
let comparisonChart = null;
function viewSegment(seg) {
const modal = new bootstrap.Modal(document.getElementById('viewSegmentModal'));
@@ -312,14 +349,21 @@
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
<input type="checkbox" class="effort-checkbox form-check-input"
value="${effort.id}"
onchange="updateActionButtons()">
</td>
<td>${rank}</td>
<td>${date}</td>
<td>${timeStr}</td>
<td>${effort.avg_hr || '-'}</td>
<td>${effort.avg_power || '-'}</td>
<td>${effort.max_power || '-'}</td>
`;
tbody.appendChild(tr);
});
updateActionButtons();
} catch (e) {
console.error(e);
@@ -327,6 +371,222 @@
}
}
function toggleAllEfforts(source) {
document.querySelectorAll('.effort-checkbox').forEach(cb => {
cb.checked = source.checked;
});
updateActionButtons();
}
function updateActionButtons() {
const selected = document.querySelectorAll('.effort-checkbox:checked').length;
document.getElementById('btn-compare').disabled = selected < 2;
document.getElementById('btn-export').disabled = selected < 1;
}
async function compareSelectedEfforts() {
const checkboxes = document.querySelectorAll('.effort-checkbox:checked');
const ids = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (ids.length < 2) return;
try {
const res = await fetch('/api/segments/efforts/compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids)
});
if (!res.ok) throw new Error("Comparison failed");
const data = await res.json();
renderComparisonTable(data);
new bootstrap.Modal(document.getElementById('compareModal')).show();
} catch (e) {
alert("Error: " + e.message);
}
}
function renderComparisonTable(data) {
const table = document.getElementById('comparison-table');
const efforts = data.efforts;
const winners = data.winners;
// Define rows to display
const rows = [
{ key: 'date', label: 'Date', format: v => new Date(v).toLocaleDateString() },
{ key: 'elapsed_time', label: 'Time', format: v => new Date(v * 1000).toISOString().substr(11, 8) },
{ key: 'avg_power', label: 'Avg Power (W)' },
{ key: 'max_power', label: 'Max Power (W)' },
{ key: 'avg_hr', label: 'Avg HR (bpm)' },
{ key: 'watts_per_kg', label: 'Watts/kg' },
{ key: 'avg_speed', label: 'Speed (m/s)', format: v => v ? v.toFixed(2) : '-' },
{ key: 'avg_cadence', label: 'Cadence' },
{ key: 'avg_respiration_rate', label: 'Respiration (br/min)', format: v => v ? v.toFixed(1) : '-' },
{ key: 'avg_temperature', label: 'Temp (C)', format: v => v ? v.toFixed(1) : '-' },
{ key: 'body_weight', label: 'Body Weight (kg)', format: v => v ? v.toFixed(2) : '-' },
{ key: 'bike_weight', label: 'Bike Weight (kg)', format: v => v ? v.toFixed(2) : '-' },
{ key: 'total_weight', label: 'Total Weight (kg)', format: v => v ? v.toFixed(2) : '-' },
{ key: 'bike_name', label: 'Bike' }
];
let html = '<thead><tr><th>Metric</th>';
efforts.forEach(e => {
html += `<th>${e.activity_name}</th>`;
});
html += '</tr></thead><tbody>';
rows.forEach(row => {
html += `<tr><td class="fw-bold text-start">${row.label}</td>`;
efforts.forEach(e => {
let val = e[row.key];
let displayVal = val;
if (val === null || val === undefined) displayVal = '-';
else if (row.format) displayVal = row.format(val);
// Highlight winner
let bgClass = '';
if (winners[row.key] === e.effort_id) {
bgClass = 'table-success fw-bold';
}
html += `<td class="${bgClass}">${displayVal}</td>`;
});
html += '</tr>';
});
html += '</tbody>';
table.innerHTML = html;
}
async function exportSelectedEfforts() {
const checkboxes = document.querySelectorAll('.effort-checkbox:checked');
const ids = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (ids.length === 0) return;
try {
const res = await fetch('/api/segments/efforts/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids)
});
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "efforts_analysis.json";
document.body.appendChild(a);
a.click();
a.remove();
} catch (e) {
alert("Error: " + e.message);
}
}
function renderComparisonChart(efforts) {
if (comparisonChart) {
comparisonChart.destroy();
comparisonChart = null;
}
const ctx = document.getElementById('comparisonChart').getContext('2d');
// Prepare datasets
// We will plot formatted 'Watts/kg' and 'Avg Speed' side by side?
// Or normalized?
// Let's plot Power, HR, and Watts/kg as bars.
// Since scales are different, we might need multiple axes or just plot one metric?
// User asked for "charts" (plural?).
// Getting simple: Grouped Bar Chart for Power and HR (scale 0-300ish).
// Watts/kg is small (0-10).
// Let's use two y-axes: Left for Power/HR, Right for W/kg
const labels = efforts.map(e => e.activity_name);
comparisonChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Avg Power (W)',
data: efforts.map(e => e.avg_power),
backgroundColor: 'rgba(255, 99, 132, 0.6)',
yAxisID: 'y'
},
{
label: 'Avg HR (bpm)',
data: efforts.map(e => e.avg_hr),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
yAxisID: 'y'
},
{
label: 'Watts/kg',
type: 'line', // Line overlay for W / kg
data: efforts.map(e => e.watts_per_kg),
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 2,
yAxisID: 'y1'
},
{
label: 'Avg Speed (m/s)',
data: efforts.map(e => e.avg_speed),
backgroundColor: 'rgba(153, 102, 255, 0.6)',
yAxisID: 'y2',
hidden: false
},
{
label: 'Avg Cadence (rpm)',
data: efforts.map(e => e.avg_cadence),
backgroundColor: 'rgba(255, 159, 64, 0.6)',
yAxisID: 'y',
hidden: true
},
{
label: 'Avg Respiration (br/min)',
data: efforts.map(e => e.avg_respiration_rate),
backgroundColor: 'rgba(201, 203, 207, 0.6)',
yAxisID: 'y',
hidden: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Power (W) / HR (bpm)' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Watts/kg' }
},
y2: {
type: 'linear',
display: true,
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: 'Speed' }
}
}
}
});
}
document.addEventListener('DOMContentLoaded', loadSegments);
</script>
{% endblock %}