many updates
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user