added activity view

This commit is contained in:
2026-01-09 09:59:36 -08:00
parent c45e41b6a9
commit 55e37fbca8
168 changed files with 8799 additions and 2426 deletions

View File

@@ -1,379 +1,659 @@
<!DOCTYPE html>
<html>
{% extends "base.html" %}
<head>
<title>Activity List - FitnessSync</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
</head>
<body>
<div class="container mt-5">
<h1>Activities</h1>
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/activities">Activities</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/setup">Setup</a>
</li>
</ul>
<!-- Toast container -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="appToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto" id="toast-title">Notification</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" id="toast-body">
{% block content %}
<div class="card mb-3">
<div class="card-body">
<div class="row g-3 align-items-center mb-3">
<div class="col-auto">
<div class="btn-group" role="group">
<button class="btn btn-primary" id="scan-30-btn">
<i class="bi bi-search"></i> Scan for New (30d)
</button>
<button class="btn btn-outline-primary" id="scan-all-btn">
<i class="bi bi-search-heart"></i> Scan All History
</button>
<button class="btn btn-outline-primary" id="sync-10-btn">
<i class="bi bi-cloud-download"></i> Sync 10 Pending
</button>
<button class="btn btn-outline-primary" id="sync-all-btn">
<i class="bi bi-cloud-download-fill"></i> Sync All Pending
</button>
<button class="btn btn-outline-success" id="match-bikes-btn">
<i class="bi bi-bicycle"></i> Match Bikes
</button>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-auto">
<label for="filter-type" class="col-form-label">Type:</label>
</div>
<div class="col-auto">
<select class="form-select" id="filter-type">
<option value="">All</option>
<!-- Options populated via JS or hardcoded common ones -->
<option value="running">Running</option>
<option value="cycling">Cycling</option>
<option value="swimming">Swimming</option>
<option value="walking">Walking</option>
<option value="hiking">Hiking</option>
<option value="gym">Gym</option>
<option value="yoga">Yoga</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary" id="apply-filters-btn">Filter</button>
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-primary" id="download-selected-btn" disabled>
<i class="bi bi-download"></i> Download Selected (Local)
</button>
<button class="btn btn-outline-warning" id="redownload-selected-btn" disabled>
<i class="bi bi-cloud-download"></i> Redownload Selected (Garmin)
</button>
</div>
</div>
<div class="col-auto">
<span class="badge bg-info" id="status-new">New: 0</span>
<span class="badge bg-warning text-dark" id="status-updated">Updated: 0</span>
<span class="badge bg-success" id="status-synced">Synced: 0</span>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="activities-table">
<thead>
<tr>
<th scope="col"><input type="checkbox" id="select-all-checkbox"></th>
<th scope="col" class="sortable" data-sort="start_time">Date <i
class="bi bi-arrow-down-up"></i></th>
<th scope="col" class="sortable" data-sort="activity_name">Name <i
class="bi bi-arrow-down-up"></i></th>
<th scope="col" class="sortable" data-sort="activity_type">Type <i
class="bi bi-arrow-down-up"></i></th>
<th scope="col">Duration</th>
<th scope="col">File Type</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<!-- Rows populated by JS -->
<tr>
<td colspan="8" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
<!-- Simple pagination check/controls component if needed -->
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="text-muted" id="showing-info">Showing 0 activities</span>
<div>
<button class="btn btn-sm btn-outline-secondary" id="prev-page-btn" disabled>Previous</button>
<button class="btn btn-sm btn-outline-secondary" id="next-page-btn">Next</button>
</div>
</div>
<div class="row g-3 align-items-center">
<div class="col-auto">
<label for="filter-type" class="col-form-label">Type:</label>
</div>
<div class="col-auto">
<select class="form-select" id="filter-type">
<option value="">All</option>
<option value="running">Running</option>
<option value="cycling">Cycling</option>
<option value="swimming">Swimming</option>
<option value="walking">Walking</option>
<option value="hiking">Hiking</option>
<option value="gym">Gym</option>
<option value="yoga">Yoga</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-secondary" id="apply-filters-btn">Filter</button>
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-primary" id="download-selected-btn" disabled>
<i class="bi bi-download"></i> Download Selected (Local)
</button>
<button class="btn btn-outline-warning" id="redownload-selected-btn" disabled>
<i class="bi bi-cloud-download"></i> Redownload Selected (Garmin)
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let activities = []; // Store current page data
let currentPage = 0;
let limit = 50;
let currentSort = { field: 'start_time', dir: 'desc' };
let toastInstance = null;
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="activities-table">
<thead>
<tr>
<th scope="col"><input type="checkbox" id="select-all-checkbox"></th>
<th scope="col">ID</th>
<th scope="col" class="sortable" data-sort="start_time">Date <i class="bi bi-arrow-down-up"></i>
</th>
<th scope="col" class="sortable" data-sort="activity_name">Name <i
class="bi bi-arrow-down-up"></i></th>
<th scope="col" class="sortable" data-sort="activity_type">Type <i
class="bi bi-arrow-down-up"></i></th>
<th scope="col">Bike Setup</th>
<th scope="col">Duration</th>
<th scope="col">File Type</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<!-- Rows populated by JS -->
<tr>
<td colspan="9" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
<!-- Simple pagination check/controls component if needed -->
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="text-muted" id="showing-info">Showing 0 activities</span>
<div>
<button class="btn btn-sm btn-outline-secondary" id="prev-page-btn" disabled>Previous</button>
<button class="btn btn-sm btn-outline-secondary" id="next-page-btn">Next</button>
</div>
</div>
</div>
</div>
document.addEventListener('DOMContentLoaded', function () {
const toastEl = document.getElementById('appToast');
toastInstance = new bootstrap.Toast(toastEl);
<!-- Details Modal -->
<div class="modal fade" id="activityDetailsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Activity Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" id="detailsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab"
data-bs-target="#overview" type="button" role="tab">Overview</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="metrics-tab" data-bs-toggle="tab" data-bs-target="#metrics"
type="button" role="tab">Metrics</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="raw-tab" data-bs-toggle="tab" data-bs-target="#raw" type="button"
role="tab">Raw Data</button>
</li>
</ul>
<div class="tab-content" id="detailsTabContent">
<!-- Overview Tab -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="row">
<div class="col-md-6">
<table class="table table-sm">
<tbody>
<tr>
<th>Activity Name</th>
<td id="det-name">-</td>
</tr>
<tr>
<th>Type</th>
<td id="det-type">-</td>
</tr>
<tr>
<th>Bike Setup</th>
<td id="det-bike-setup">-</td>
</tr>
<tr>
<th>Start Time</th>
<td id="det-time">-</td>
</tr>
<tr>
<th>Duration</th>
<td id="det-duration">-</td>
</tr>
<tr>
<th>Garmin ID</th>
<td id="det-id">-</td>
</tr>
<tr>
<th>Downloaded</th>
<td id="det-dl-status">-</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<div class="card bg-light mb-3">
<div class="card-body text-center">
<h3><i class="bi bi-trophy text-warning"></i></h3>
<h5 class="card-title">Summary</h5>
<div class="row text-start mt-3">
<div class="col-6 mb-2"><strong>Distance:</strong> <span
id="det-dist">-</span></div>
<div class="col-6 mb-2"><strong>Calories:</strong> <span
id="det-cal">-</span></div>
<div class="col-6 mb-2"><strong>Steps:</strong> <span
id="det-steps">-</span></div>
<div class="col-6 mb-2"><strong>Avg HR:</strong> <span
id="det-avg-hr">-</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
loadActivities();
<!-- Metrics Tab -->
<div class="tab-pane fade" id="metrics" role="tabpanel">
<div class="row g-3">
<!-- Heart Rate -->
<div class="col-md-4">
<div class="card h-100 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="det-m-avg-hr">-</strong> bpm
</div>
<div class="d-flex justify-content-between">
<span>Max:</span> <strong id="det-m-max-hr">-</strong> bpm
</div>
</div>
</div>
</div>
document.getElementById('prev-page-btn').addEventListener('click', () => changePage(-1));
document.getElementById('next-page-btn').addEventListener('click', () => changePage(1));
document.getElementById('apply-filters-btn').addEventListener('click', () => { currentPage = 0; loadActivities(); });
<!-- Speed/Pace -->
<div class="col-md-4">
<div class="card h-100 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="det-m-avg-sped">-</strong> km/h
</div>
<div class="d-flex justify-content-between">
<span>Max Speed:</span> <strong id="det-m-max-sped">-</strong> km/h
</div>
</div>
</div>
</div>
document.getElementById('select-all-checkbox').addEventListener('change', toggleSelectAll);
<!-- Power -->
<div class="col-md-4">
<div class="card h-100 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="det-m-avg-pwr">-</strong> W
</div>
<div class="d-flex justify-content-between mb-2">
<span>Max Power:</span> <strong id="det-m-max-pwr">-</strong> W
</div>
<div class="d-flex justify-content-between mb-2">
<span>Norm Power:</span> <strong id="det-m-norm-pwr">-</strong> W
</div>
<div class="d-flex justify-content-between">
<span>VO2 Max:</span> <strong id="det-m-vo2">-</strong>
</div>
</div>
</div>
</div>
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (currentSort.field === field) {
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
currentSort.dir = 'asc'; // Default to asc for new column? or desc for dates?
if (field === 'start_time') currentSort.dir = 'desc';
}
// Visual updates
document.querySelectorAll('th.sortable i').forEach(i => i.className = 'bi bi-arrow-down-up text-muted');
const icon = th.querySelector('i');
icon.className = currentSort.dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
icon.classList.remove('text-muted');
<!-- Elevation -->
<div class="col-md-4">
<div class="card h-100 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="det-m-ele-gain">-</strong> m
</div>
<div class="d-flex justify-content-between">
<span>Loss:</span> <strong id="det-m-ele-loss">-</strong> m
</div>
</div>
</div>
</div>
renderTable(); // Re-render client-side sorted for current page, or re-fetch?
// Ideally re-fetch if server-side sort, but let's do client-side for simple pages
// Actually, let's keep it client side for the current page batch for simplicity unless filtering
// But standard is server-side. Let's stick to client sorting of the *fetched* batch for now to avoid complexity in backend API params unless we added them.
// The API `activities.py` reads `limit` and `offset` but doesn't seem to take sort params yet in `list_activities`.
// `query_activities` filters but doesn't sort explicitly by param other than implicitly DB order.
// Let's implement client-side sorting of the current `activities` array.
sortActivities();
renderTable();
});
});
<!-- Training Effect -->
<div class="col-md-4">
<div class="card h-100 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="det-m-aerobic">-</strong>
</div>
<div class="d-flex justify-content-between">
<span>Anaerobic:</span> <strong id="det-m-anaerobic">-</strong>
</div>
<div class="d-flex justify-content-between mt-2">
<span>TSS:</span> <strong id="det-m-tss">-</strong>
</div>
</div>
</div>
</div>
document.getElementById('download-selected-btn').addEventListener('click', downloadSelected);
document.getElementById('redownload-selected-btn').addEventListener('click', redownloadSelected);
});
<!-- Cadence -->
<div class="col-md-4">
<div class="card h-100 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="det-m-avg-cad">-</strong>
</div>
<div class="d-flex justify-content-between">
<span>Max:</span> <strong id="det-m-max-cad">-</strong>
</div>
</div>
</div>
</div>
function showToast(title, body, level = 'info') {
const toastTitle = document.getElementById('toast-title');
const toastBody = document.getElementById('toast-body');
const toastHeader = document.querySelector('.toast-header');
</div>
</div>
toastTitle.textContent = title;
toastBody.textContent = body;
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
<!-- Raw Tab -->
<div class="tab-pane fade" id="raw" role="tabpanel">
<pre id="det-raw-json" class="bg-light p-3 border rounded"
style="max-height: 500px; overflow: auto;"></pre>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
if (level === 'success') toastHeader.classList.add('bg-success', 'text-white');
else if (level === 'error') toastHeader.classList.add('bg-danger', 'text-white');
else if (level === 'warning') toastHeader.classList.add('bg-warning');
else toastHeader.classList.add('bg-info', 'text-white');
<script>
let activities = []; // Store current page data
let currentPage = 0;
let limit = 50;
let currentSort = { field: 'start_time', dir: 'desc' };
let toastInstance = null;
let detailsModal = null;
toastInstance.show();
}
document.addEventListener('DOMContentLoaded', function () {
const toastEl = document.getElementById('appToast');
if (toastEl) toastInstance = new bootstrap.Toast(toastEl);
async function loadActivities() {
const tbody = document.querySelector('#activities-table tbody');
tbody.innerHTML = '<tr><td colspan="8" class="text-center">Loading...</td></tr>';
detailsModal = new bootstrap.Modal(document.getElementById('activityDetailsModal'));
const typeFilter = document.getElementById('filter-type').value;
let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`;
loadActivities();
// If filtering, use query endpoint (simple toggle for now)
if (typeFilter) {
// Note: query endpoint doesn't support pagination in current backend implementation (it returns all)
// We might need to handle this. The `query_activities` returns list.
url = `/api/activities/query?activity_type=${typeFilter}`;
// If using query endpoint, disable pagination buttons as it returns all
document.getElementById('prev-page-btn').disabled = true;
document.getElementById('next-page-btn').disabled = true;
} else {
document.getElementById('prev-page-btn').disabled = currentPage === 0;
document.getElementById('next-page-btn').disabled = false; // logic optimization later
}
document.getElementById('prev-page-btn').addEventListener('click', () => changePage(-1));
document.getElementById('next-page-btn').addEventListener('click', () => changePage(1));
document.getElementById('apply-filters-btn').addEventListener('click', () => { currentPage = 0; loadActivities(); });
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch activities");
activities = await response.json();
document.getElementById('select-all-checkbox').addEventListener('change', toggleSelectAll);
// Sort headers
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (currentSort.field === field) {
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
currentSort.dir = 'asc';
if (field === 'start_time') currentSort.dir = 'desc';
}
// Visual updates
document.querySelectorAll('th.sortable i').forEach(i => i.className = 'bi bi-arrow-down-up text-muted');
const icon = th.querySelector('i');
icon.className = currentSort.dir === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down';
icon.classList.remove('text-muted');
// Initial sort
sortActivities();
renderTable();
});
});
document.getElementById('showing-info').textContent = `Showing ${activities.length} activities ${typeFilter ? '(Filtered)' : `(Page ${currentPage + 1})`}`;
document.getElementById('download-selected-btn').addEventListener('click', downloadSelected);
document.getElementById('redownload-selected-btn').addEventListener('click', redownloadSelected);
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-danger">Error loading activities: ${error.message}</td></tr>`;
showToast("Error", error.message, "error");
document.getElementById('scan-30-btn').addEventListener('click', () => scanActivities(30));
document.getElementById('scan-all-btn').addEventListener('click', () => scanActivities(3650));
document.getElementById('sync-10-btn').addEventListener('click', () => syncPending(10));
document.getElementById('sync-all-btn').addEventListener('click', () => syncPending(null));
document.getElementById('sync-all-btn').addEventListener('click', () => syncPending(null));
document.getElementById('match-bikes-btn').addEventListener('click', triggerBikeMatching);
updateSyncStatus();
});
// ... Helpers same as before ...
// Using base.html showToast if available, else local fallback
// But since we are extending base, we can use showToast from base if it's there.
// However, scoping might be an issue if showToast is in base's script.
// Base has `showToast` in global scope (window), so we can use it.
// I will redefine simplified helpers here or just rely on global.
// Actually, let's redefine simplified helpers for this page's logic to be safe or just call global.
// Since I can't easily see if base's script executes before this block (it usually does if at end of body), I will assume it does.
async function loadActivities() {
const tbody = document.querySelector('#activities-table tbody');
tbody.innerHTML = '<tr><td colspan="9" class="text-center">Loading...</td></tr>';
const typeFilter = document.getElementById('filter-type').value;
let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`;
if (typeFilter) {
url = `/api/activities/query?activity_type=${typeFilter}`;
document.getElementById('prev-page-btn').disabled = true;
document.getElementById('next-page-btn').disabled = true;
} else {
document.getElementById('prev-page-btn').disabled = currentPage === 0;
document.getElementById('next-page-btn').disabled = false;
}
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch activities");
activities = await response.json();
sortActivities();
renderTable();
document.getElementById('showing-info').textContent = `Showing ${activities.length} activities ${typeFilter ? '(Filtered)' : `(Page ${currentPage + 1})`}`;
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error: ${error.message}</td></tr>`;
showToast("Error", error.message, "error");
}
}
function sortActivities() {
activities.sort((a, b) => {
let valA = a[currentSort.field];
let valB = b[currentSort.field];
if (valA === null) valA = "";
if (valB === null) valB = "";
if (valA < valB) return currentSort.dir === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.dir === 'asc' ? 1 : -1;
return 0;
});
}
function renderTable() {
const tbody = document.querySelector('#activities-table tbody');
tbody.innerHTML = '';
if (activities.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">No activities found.</td></tr>';
return;
}
activities.forEach(act => {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="checkbox" class="activity-checkbox" value="${act.garmin_activity_id}"></td>
<td><small style="font-size: 0.8em;">${act.garmin_activity_id}</small></td>
<td>${formatDate(act.start_time)}</td>
<td><a href="/activity/${act.garmin_activity_id}" target="_blank">${act.activity_name || 'Untitled'}</a></td>
<td>${act.activity_type}</td>
<td>${act.bike_setup ? (act.bike_setup.name || act.bike_setup.frame) : (act.activity_type === 'cycling' ? '<span class="text-muted">-</span>' : '')}</td>
<td>${formatDuration(act.duration)}</td>
<td>${act.file_type || '-'}</td>
<td>${formatStatus(act.download_status)}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="downloadFile('${act.garmin_activity_id}')" title="Download Local File">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-outline-warning" onclick="redownload('${act.garmin_activity_id}')" title="Redownload from Garmin">
<i class="bi bi-cloud-download"></i>
</button>
<a href="/activity/${act.garmin_activity_id}" target="_blank" class="btn btn-outline-info" title="View Details">
<i class="bi bi-eye"></i>
</a>
</div>
</td>
`;
tbody.appendChild(row);
});
document.querySelectorAll('.activity-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectionButtons);
});
updateSelectionButtons();
}
function changePage(delta) {
currentPage += delta;
if (currentPage < 0) currentPage = 0;
loadActivities();
}
function updateSelectionButtons() {
const checked = document.querySelectorAll('.activity-checkbox:checked').length;
document.getElementById('download-selected-btn').disabled = checked === 0;
document.getElementById('redownload-selected-btn').disabled = checked === 0;
}
function toggleSelectAll(e) {
const checked = e.target.checked;
document.querySelectorAll('.activity-checkbox').forEach(cb => cb.checked = checked);
updateSelectionButtons();
}
window.downloadFile = function (id) {
window.location.href = `/api/activities/download/${id}`;
};
window.redownload = async function (id) {
showToast("Redownloading...", `Requesting redownload for ${id}`, "info");
try {
const response = await fetch(`/api/activities/${id}/redownload`, { method: 'POST' });
const data = await response.json();
if (response.ok) {
showToast("Success", data.message, "success");
loadActivities();
} else {
throw new Error(data.detail || "Failed");
}
} catch (e) {
showToast("Error", e.message, "error");
}
};
function sortActivities() {
activities.sort((a, b) => {
let valA = a[currentSort.field];
let valB = b[currentSort.field];
function downloadSelected() {
const selected = Array.from(document.querySelectorAll('.activity-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
selected.forEach(id => {
setTimeout(() => window.location.href = `/api/activities/download/${id}`, 500);
});
}
// Handle nulls
if (valA === null) valA = "";
if (valB === null) valB = "";
async function redownloadSelected() {
const selected = Array.from(document.querySelectorAll('.activity-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
if (!confirm(`Redownload ${selected.length} activities from Garmin?`)) return;
if (valA < valB) return currentSort.dir === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.dir === 'asc' ? 1 : -1;
return 0;
});
}
let successCount = 0;
let failCount = 0;
function renderTable() {
const tbody = document.querySelector('#activities-table tbody');
tbody.innerHTML = '';
showToast("Batch Redownload", `Starting batch redownload...`, "info");
if (activities.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No activities found.</td></tr>';
return;
}
activities.forEach(act => {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="checkbox" class="activity-checkbox" value="${act.garmin_activity_id}"></td>
<td>${formatDate(act.start_time)}</td>
<td>${act.activity_name || 'Untitled'}</td>
<td>${act.activity_type}</td>
<td>${formatDuration(act.duration)}</td>
<td>${act.file_type || '-'}</td>
<td>${formatStatus(act.download_status)}</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="downloadFile('${act.garmin_activity_id}')" title="Download Local File">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-outline-warning" onclick="redownload('${act.garmin_activity_id}')" title="Redownload from Garmin">
<i class="bi bi-cloud-download"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// Re-attach checkbox listeners
document.querySelectorAll('.activity-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectionButtons);
});
updateSelectionButtons();
}
function changePage(delta) {
currentPage += delta;
if (currentPage < 0) currentPage = 0;
loadActivities();
}
function updateSelectionButtons() {
const checked = document.querySelectorAll('.activity-checkbox:checked').length;
document.getElementById('download-selected-btn').disabled = checked === 0;
document.getElementById('redownload-selected-btn').disabled = checked === 0;
}
function toggleSelectAll(e) {
const checked = e.target.checked;
document.querySelectorAll('.activity-checkbox').forEach(cb => cb.checked = checked);
updateSelectionButtons();
}
// --- Actions ---
window.downloadFile = function (id) {
window.location.href = `/api/activities/download/${id}`;
};
window.redownload = async function (id) {
// Confirmation removed per user request
showToast("Redownloading...", `Requesting redownload for ${id}`, "info");
for (const id of selected) {
try {
const response = await fetch(`/api/activities/${id}/redownload`, { method: 'POST' });
const data = await response.json();
if (response.ok) {
showToast("Success", data.message, "success");
// Update that specific row in local data
// Actually easier to just reload or find row
loadActivities(); // Refresh to catch status update
} else {
throw new Error(data.detail || "Failed");
}
} catch (e) {
showToast("Error", e.message, "error");
}
};
function downloadSelected() {
const selected = Array.from(document.querySelectorAll('.activity-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
selected.forEach(id => {
// Trigger separate downloads. Browser might block if too many popup/downloads?
// Add tiny delay
setTimeout(() => window.location.href = `/api/activities/download/${id}`, 500);
});
const res = await fetch(`/api/activities/${id}/redownload`, { method: 'POST' });
if (res.ok) successCount++; else failCount++;
} catch (e) { failCount++; }
}
async function redownloadSelected() {
const selected = Array.from(document.querySelectorAll('.activity-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
showToast("Batch Complete", `Success: ${successCount}, Failed: ${failCount}`, failCount > 0 ? "warning" : "success");
loadActivities();
}
if (!confirm(`Redownload ${selected.length} activities from Garmin?`)) return;
async function updateSyncStatus() {
try {
const res = await fetch('/api/activities/sync/status');
if (res.ok) {
const data = await res.json();
document.getElementById('status-new').textContent = `New: ${data.new || 0}`;
document.getElementById('status-updated').textContent = `Updated: ${data.updated || 0}`;
document.getElementById('status-synced').textContent = `Synced: ${data.synced || 0}`;
}
} catch (e) { }
}
let successCount = 0;
let failCount = 0;
async function scanActivities(daysBack) {
showToast("Scanning...", `Starting scan...`, "info");
try {
const res = await fetch(`/api/activities/sync/scan?days_back=${daysBack}`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast("Scan Started", `Job ID: ${data.job_id}`, "success");
setTimeout(updateSyncStatus, 5000);
} else showToast("Error", "Scan failed", "error");
} catch (e) { showToast("Error", e.message, "error"); }
}
showToast("Batch Redownload", `Starting batch redownload of ${selected.length} items...`, "info");
async function syncPending(limit) {
showToast("Syncing...", `Starting sync...`, "info");
try {
let url = '/api/activities/sync/pending';
if (limit) url += `?limit=${limit}`;
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast("Sync Started", `Job ID: ${data.job_id}`, "success");
setTimeout(() => { updateSyncStatus(); loadActivities(); }, 5000);
} else showToast("Error", "Sync failed", "error");
} catch (e) { showToast("Error", e.message, "error"); }
}
for (const id of selected) {
try {
const res = await fetch(`/api/activities/${id}/redownload`, { method: 'POST' });
if (res.ok) successCount++;
else failCount++;
} catch (e) {
failCount++;
}
async function triggerBikeMatching() {
showToast("Matching...", "Starting bike match process...", "info");
try {
const res = await fetch('/api/bike-setups/match-all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast("Success", data.message, "success");
loadActivities();
} else {
throw new Error("Failed");
}
} catch (e) {
showToast("Error", "Matching failed", "error");
}
}
window.showActivityDetails = async function (id) {
// Reset fields
document.querySelectorAll('[id^="det-"]').forEach(el => el.textContent = '-');
document.getElementById('det-raw-json').textContent = 'Loading...';
detailsModal.show();
try {
const res = await fetch(`/api/activities/${id}/details`);
if (!res.ok) throw new Error("Failed to fetch details");
const data = await res.json();
// Populate Overview
document.getElementById('det-name').textContent = data.activity_name || 'Untitled';
document.getElementById('det-type').textContent = data.activity_type;
// Populate Bike Setup
if (data.bike_setup) {
let display = data.bike_setup.name ? `${data.bike_setup.name} (${data.bike_setup.frame} ${data.bike_setup.chainring}/${data.bike_setup.rear_cog})` : `${data.bike_setup.frame} (${data.bike_setup.chainring}/${data.bike_setup.rear_cog})`;
document.getElementById('det-bike-setup').textContent = display;
} else {
document.getElementById('det-bike-setup').innerHTML = '<span class="text-muted">-</span>';
}
showToast("Batch Complete", `Redownloaded: ${successCount}. Failed: ${failCount}.`, failCount > 0 ? "warning" : "success");
loadActivities();
document.getElementById('det-time').textContent = formatDate(data.start_time);
document.getElementById('det-duration').textContent = formatDuration(data.duration);
document.getElementById('det-id').textContent = data.garmin_activity_id;
document.getElementById('det-dl-status').innerHTML = formatStatus(data.download_status);
document.getElementById('det-dist').textContent = data.distance ? (data.distance / 1000).toFixed(2) + ' km' : '-';
document.getElementById('det-cal').textContent = data.calories || '-';
document.getElementById('det-steps').textContent = data.steps || '-';
document.getElementById('det-avg-hr').textContent = data.avg_hr ? data.avg_hr + ' bpm' : '-';
// Populate Metrics
// HR
document.getElementById('det-m-avg-hr').textContent = data.avg_hr || '-';
document.getElementById('det-m-max-hr').textContent = data.max_hr || '-';
// Speed (m/s -> km/h)
document.getElementById('det-m-avg-sped').textContent = data.avg_speed ? (data.avg_speed * 3.6).toFixed(1) : '-';
document.getElementById('det-m-max-sped').textContent = data.max_speed ? (data.max_speed * 3.6).toFixed(1) : '-';
// Power
document.getElementById('det-m-avg-pwr').textContent = data.avg_power || '-';
document.getElementById('det-m-max-pwr').textContent = data.max_power || '-';
document.getElementById('det-m-norm-pwr').textContent = data.norm_power || '-';
document.getElementById('det-m-vo2').textContent = data.vo2_max || '-';
// Elevation
document.getElementById('det-m-ele-gain').textContent = data.elevation_gain || '-';
document.getElementById('det-m-ele-loss').textContent = data.elevation_loss || '-';
// TE
document.getElementById('det-m-aerobic').textContent = data.aerobic_te || '-';
document.getElementById('det-m-anaerobic').textContent = data.anaerobic_te || '-';
document.getElementById('det-m-tss').textContent = data.tss || '-';
// Cadence
document.getElementById('det-m-avg-cad').textContent = data.avg_cadence || '-';
document.getElementById('det-m-max-cad').textContent = data.max_cadence || '-';
// Raw
document.getElementById('det-raw-json').textContent = JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById('det-raw-json').textContent = `Error: ${e.message}`;
showToast("Error", "Failed to load activity details", "error");
}
};
// --- Helpers ---
function formatDate(isoStr) {
if (!isoStr) return '-';
return new Date(isoStr).toLocaleString();
}
function formatDuration(seconds) {
if (!seconds) return '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}
function formatStatus(status) {
if (status === 'downloaded') return '<span class="badge bg-success">Downloaded</span>';
if (status === 'failed') return '<span class="badge bg-danger">Failed</span>';
return '<span class="badge bg-secondary">' + status + '</span>';
}
</script>
</body>
</html>
function formatDate(isoStr) { return !isoStr ? '-' : new Date(isoStr).toLocaleString(); }
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`; }
function formatStatus(s) {
if (s === 'downloaded') return '<span class="badge bg-success">Downloaded</span>';
if (s === 'failed') return '<span class="badge bg-danger">Failed</span>';
return `<span class="badge bg-secondary">${s}</span>`;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,496 @@
{% 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 %}

View File

@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync Dashboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
<style>
/* Shared Styles */
.card {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: none;
}
.nav-tabs .nav-link {
color: #495057;
}
.nav-tabs .nav-link.active {
font-weight: bold;
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<div class="container mt-5">
<div class="row mb-4 align-items-center">
<div class="col-auto">
<a href="/" class="text-decoration-none text-dark">
<h1>Fitbit-Garmin Sync Dashboard</h1>
</a>
</div>
<div class="col">
<!-- Global Sync Status Widget -->
<div id="global-sync-status" class="card shadow-sm"
style="display: none; border-left: 5px solid #0d6efd;">
<div class="card-body py-2 px-3">
<div class="d-flex align-items-center justify-content-between">
<div class="flex-grow-1 me-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong class="small" id="global-job-op">Operation</strong>
<span class="badge bg-secondary ms-2" id="global-queue-badge"
style="display: none;">Queue: 0</span>
</div>
<div class="progress" style="height: 10px;">
<div id="global-job-bar"
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%"></div>
</div>
<small id="global-job-msg" class="text-muted d-block text-truncate"
style="max-width: 400px; font-size: 0.75rem;">Initializing...</small>
</div>
<button class="btn btn-outline-danger btn-sm" id="global-stop-btn">
<i class="bi bi-stop-circle"></i> Stop
</button>
</div>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/activities' %}active{% endif %}"
href="/activities">Activities</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/garmin-health' %}active{% endif %}"
href="/garmin-health">Garmin Health</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/fitbit-health' %}active{% endif %}"
href="/fitbit-health">Fitbit Health</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/bike-setups' %}active{% endif %}" href="/bike-setups">Bike
Setups</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.path == '/setup' %}active{% endif %}" href="/setup">Setup</a>
</li>
</ul>
<!-- Toast container -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="appToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto" id="toast-title">Notification</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" id="toast-body">
</div>
</div>
</div>
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Shared Toast Helper
function showToast(msg, type = 'info') {
const el = document.getElementById('appToast');
if (el) {
const toast = new bootstrap.Toast(el);
const header = el.querySelector('.toast-header');
const body = el.querySelector('.toast-body');
body.textContent = msg;
header.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
if (type === 'success') header.classList.add('bg-success', 'text-white');
if (type === 'danger' || type === 'error') header.classList.add('bg-danger', 'text-white');
if (type === 'warning') header.classList.add('bg-warning');
toast.show();
} else {
console.log("Toast:", msg);
}
}
// Global Sync Status Poller
let globalPollInterval = null;
let globalCurrentJobId = null;
function startGlobalPolling() {
if (globalPollInterval) clearInterval(globalPollInterval);
checkGlobalJobStatus(); // Immediate check
globalPollInterval = setInterval(checkGlobalJobStatus, 2000);
}
async function checkGlobalJobStatus() {
try {
const response = await fetch('/api/jobs/active');
if (response.ok) {
const jobs = await response.json();
const widget = document.getElementById('global-sync-status');
const opEl = document.getElementById('global-job-op');
const msgEl = document.getElementById('global-job-msg');
const barEl = document.getElementById('global-job-bar');
const stopBtn = document.getElementById('global-stop-btn');
const queueBadge = document.getElementById('global-queue-badge');
if (jobs.length > 0) {
const job = jobs[0];
globalCurrentJobId = job.id;
// Show Widget
widget.style.display = 'block';
// Update UI
opEl.textContent = job.operation;
msgEl.textContent = job.message;
barEl.style.width = job.progress + '%';
if (jobs.length > 1) {
queueBadge.style.display = 'inline-block';
queueBadge.textContent = `Queue: ${jobs.length - 1}`;
} else {
queueBadge.style.display = 'none';
}
// Stop Button State
if (job.cancel_requested) {
stopBtn.disabled = true;
stopBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Stopping...';
} else {
stopBtn.disabled = false;
stopBtn.innerHTML = '<i class="bi bi-stop-circle"></i> Stop';
stopBtn.onclick = () => stopGlobalJob(job.id);
}
// Dispatch event for other pages
document.dispatchEvent(new CustomEvent('sync-job-active', { detail: { job: job } }));
} else {
// No jobs
if (widget.style.display !== 'none') {
// Job just finished
showToast('Background Job Completed', 'success');
document.dispatchEvent(new CustomEvent('sync-job-finished'));
}
widget.style.display = 'none';
globalCurrentJobId = null;
}
}
} catch (e) {
console.error("Global polling error", e);
}
}
async function stopGlobalJob(jobId) {
if (!jobId) return;
try {
const response = await fetch(`/api/jobs/${jobId}/stop`, { method: 'POST' });
if (response.ok) {
showToast('Stopping Job...', 'warning');
// Force immediate update to disable button
checkGlobalJobStatus();
}
} catch (e) {
showToast('Failed to stop job', 'danger');
}
}
document.addEventListener('DOMContentLoaded', startGlobalPolling);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Bike Setups</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSetupModal">
<i class="bi bi-plus-lg"></i> Add Setup
</button>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="setupsTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Frame</th>
<th>Chainring</th>
<th>Rear Cog</th>
<th>Gear Ratio</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="setupsTableBody">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div class="modal fade" id="addSetupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="setupModalLabel">Add Bike Setup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="setupForm">
<input type="hidden" id="setupId" name="id">
<div class="mb-3">
<label for="name" class="form-label">Name (Optional)</label>
<input type="text" class="form-control" id="name" name="name" placeholder="e.g. Track Bike">
</div>
<div class="mb-3">
<label for="frame" class="form-label">Frame</label>
<input type="text" class="form-control" id="frame" name="frame" required placeholder="e.g. Dolan Pre Cursa">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="chainring" class="form-label">Chainring</label>
<input type="number" class="form-control" id="chainring" name="chainring" required min="1" placeholder="Teeth">
</div>
<div class="col-md-6 mb-3">
<label for="rearCog" class="form-label">Rear Cog</label>
<input type="number" class="form-control" id="rearCog" name="rear_cog" required min="1" placeholder="Teeth">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="saveSetup()">Save</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentSetups = [];
const setupModal = new bootstrap.Modal(document.getElementById('addSetupModal'));
document.addEventListener('DOMContentLoaded', loadSetups);
async function loadSetups() {
try {
const response = await fetch('/api/bike-setups/');
if (!response.ok) throw new Error('Failed to load setups');
currentSetups = await response.json();
renderTable();
} catch (error) {
showToast(error.message, 'danger');
}
}
function renderTable() {
const tbody = document.getElementById('setupsTableBody');
tbody.innerHTML = '';
currentSetups.forEach(setup => {
const ratio = (setup.chainring / setup.rear_cog).toFixed(2);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${setup.name || '-'}</td>
<td>${setup.frame}</td>
<td>${setup.chainring}t</td>
<td>${setup.rear_cog}t</td>
<td>${ratio}</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" onclick="editSetup(${setup.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSetup(${setup.id})">
<i class="bi bi-trash"></i>
</button>
</td>
`;
tbody.appendChild(tr);
});
}
function resetForm() {
document.getElementById('setupForm').reset();
document.getElementById('setupId').value = '';
document.getElementById('setupModalLabel').textContent = 'Add Bike Setup';
}
// Hook into modal show event to reset form if adding
document.getElementById('addSetupModal').addEventListener('show.bs.modal', function (event) {
if (!event.relatedTarget || event.relatedTarget.getAttribute('data-bs-target')) {
// If triggered by button (not manual show for edit), reset
// Actually better to just check if we set an ID
}
});
// Reset on close
document.getElementById('addSetupModal').addEventListener('hidden.bs.modal', resetForm);
function editSetup(id) {
const setup = currentSetups.find(s => s.id === id);
if (!setup) return;
document.getElementById('setupId').value = setup.id;
document.getElementById('name').value = setup.name || '';
document.getElementById('frame').value = setup.frame;
document.getElementById('chainring').value = setup.chainring;
document.getElementById('rearCog').value = setup.rear_cog;
document.getElementById('setupModalLabel').textContent = 'Edit Bike Setup';
setupModal.show();
}
async function saveSetup() {
const id = document.getElementById('setupId').value;
const data = {
name: document.getElementById('name').value || null,
frame: document.getElementById('frame').value,
chainring: parseInt(document.getElementById('chainring').value),
rear_cog: parseInt(document.getElementById('rearCog').value)
};
if (!data.frame || !data.chainring || !data.rear_cog) {
showToast('Please fill in all required fields', 'warning');
return;
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/api/bike-setups/${id}` : '/api/bike-setups/';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to save setup');
showToast('Setup saved successfully', 'success');
setupModal.hide();
loadSetups();
} catch (error) {
showToast(error.message, 'danger');
}
}
async function deleteSetup(id) {
if (!confirm('Are you sure you want to delete this setup?')) return;
try {
const response = await fetch(`/api/bike-setups/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete setup');
showToast('Setup deleted', 'success');
loadSetups();
} catch (error) {
showToast(error.message, 'danger');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,329 @@
{% extends "base.html" %}
{% block content %}
<div class="row mb-4">
<div class="col-md-8">
<h1>Fitbit Health</h1>
<p class="text-muted">Track your weight and body composition from Fitbit.</p>
</div>
<div class="col-md-4 text-end">
<div class="btn-group" role="group">
<button id="syncBtn30" class="btn btn-primary" onclick="triggerSync(30)">
<i class="bi bi-arrow-repeat"></i> Sync Recent (30d)
</button>
<button id="syncBtnAll" class="btn btn-outline-primary" onclick="triggerSync(3650)">
<i class="bi bi-collection"></i> Sync All
</button>
<button id="compareBtn" class="btn btn-outline-info" onclick="compareWeights()">
<i class="bi bi-bar-chart-steps"></i> Compare vs Garmin
</button>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form id="filterForm" class="row g-3 align-items-end">
<div class="col-md-3">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" required>
</div>
<div class="col-md-3">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" required>
</div>
<div class="col-md-4">
<label class="form-label">Quick Filters</label>
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-outline-secondary" onclick="setRange(30)">30d</button>
<button type="button" class="btn btn-outline-secondary" onclick="setRange(365)">1y</button>
<button type="button" class="btn btn-outline-secondary" onclick="setRange(3650)">All</button>
</div>
</div>
</form>
</div>
</div>
<!-- Detailed Data Table -->
<div class="card">
<div class="card-header">
Weight Logs
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="metricsTable">
<thead>
<tr>
<th>Date</th>
<th>Weight (kg)</th>
<th>BMI</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Comparison Modal -->
<div class="modal fade" id="compareModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fitbit vs Garmin Weight Sync</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="compareLoading" class="text-center">
<div class="spinner-border text-primary" role="status"></div>
<p>Comparing records...</p>
</div>
<div id="compareResults" class="d-none">
<h5>Summary</h5>
<ul class="list-group mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center">
Fitbit Records
<span class="badge bg-primary rounded-pill" id="compFitbit">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Garmin Records
<span class="badge bg-success rounded-pill" id="compGarmin">0</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Missing in Garmin
<span class="badge bg-danger rounded-pill" id="compMissing">0</span>
</li>
</ul>
<div class="d-grid gap-2 mb-3">
<button id="uploadMissingBtn" class="btn btn-warning" onclick="triggerGarminUpload()">
<i class="bi bi-cloud-upload"></i> Upload Missing to Garmin (Batch 50)
</button>
</div>
<h5>Missing Dates</h5>
<p class="small text-muted">These dates exist in Fitbit but not Garmin.</p>
<div id="missingDatesList"
style="max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; padding: 10px;">
<!-- Populated by JS -->
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
// Initialize dates (last 30 days)
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
document.getElementById('endDate').valueAsDate = today;
document.getElementById('startDate').valueAsDate = thirtyDaysAgo;
document.getElementById('filterForm').addEventListener('submit', function (e) {
e.preventDefault();
loadData();
});
function setRange(days) {
const e = new Date();
const s = new Date();
s.setDate(e.getDate() - days);
document.getElementById('endDate').valueAsDate = e;
document.getElementById('startDate').valueAsDate = s;
loadData();
}
async function triggerSync(daysBack) {
const confirmMsg = daysBack > 100 ? "Sync all historical data? This may take a while." : "Sync Fitbit weight for last 30 days?";
if (!confirm(confirmMsg)) return;
const scope = daysBack > 100 ? 'all' : '30d';
try {
const res = await fetch('/api/sync/fitbit/weight', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ scope: scope })
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.detail || 'Sync failed');
}
const data = await res.json();
showToast('Sync started/completed: ' + (data.message || 'Success'));
// Since it's currently sync (based on backend), reload data immediately
loadData();
} catch (e) {
console.error(e);
showToast('Error executing sync: ' + e.message, 'danger');
}
}
async function pollJob(jobId) {
const check = async () => {
try {
const res = await fetch(`/api/jobs/${jobId}`);
if (res.status === 404) {
loadData();
showToast('Sync finished (or job cleared).');
return;
}
const data = await res.json();
if (data.status === 'completed' || data.status === 'failed') {
loadData();
showToast(`Sync ${data.status}: ${data.message || ''}`, data.status === 'failed' ? 'danger' : 'success');
} else {
setTimeout(check, 2000);
}
} catch (e) { console.error(e); }
};
check();
}
async function compareWeights() {
// Show modal
const modal = new bootstrap.Modal(document.getElementById('compareModal'));
modal.show();
document.getElementById('compareLoading').classList.remove('d-none');
document.getElementById('compareResults').classList.add('d-none');
try {
const res = await fetch('/api/sync/compare-weight', { method: 'POST' });
const data = await res.json();
document.getElementById('compFitbit').textContent = data.fitbit_total;
document.getElementById('compGarmin').textContent = data.garmin_total;
document.getElementById('compMissing').textContent = data.missing_in_garmin;
const listDiv = document.getElementById('missingDatesList');
listDiv.innerHTML = '';
if (data.missing_dates && data.missing_dates.length > 0) {
data.missing_dates.forEach(d => {
const div = document.createElement('div');
div.textContent = d;
div.className = 'border-bottom py-1';
listDiv.appendChild(div);
});
} else {
listDiv.innerHTML = '<div class="text-success text-center my-3">All clear! All Fitbit dates are present in Garmin.</div>';
}
document.getElementById('compareLoading').classList.add('d-none');
document.getElementById('compareResults').classList.remove('d-none');
} catch (e) {
console.error(e);
document.getElementById('compareLoading').innerHTML = '<p class="text-danger">Error fetching comparison.</p>';
}
}
async function triggerGarminUpload() {
if (!confirm("This will upload up to 50 unsynced weight records to Garmin Connect. Continue?")) return;
const btn = document.getElementById('uploadMissingBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploading...';
try {
const res = await fetch('/api/sync/garmin/upload_weight', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: 50 })
});
const data = await res.json();
showToast('Upload job started: ' + data.job_id);
pollJob(data.job_id);
} catch (e) {
console.error(e);
showToast('Error starting upload: ' + e.message, 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function loadData() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
const tbody = document.querySelector('#metricsTable tbody');
tbody.innerHTML = '<tr><td colspan="4" class="text-center">Loading...</td></tr>';
try {
const url = `/api/metrics/query?metric_type=weight&start_date=${start}&end_date=${end}&source=fitbit&limit=5000`;
const response = await fetch(url);
const data = await response.json();
tbody.innerHTML = '';
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center">No data found</td></tr>';
return;
}
// Sort by date desc
data.sort((a, b) => new Date(b.date) - new Date(a.date));
data.forEach(item => {
// Parse detailed data if available for BMI
let bmi = '-';
try {
if (item.detailed_data) {
// It might be a string or object depending on serialization
const details = (typeof item.detailed_data === 'string')
? JSON.parse(item.detailed_data)
: item.detailed_data;
if (details && details.bmi) bmi = details.bmi;
}
} catch (e) { }
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.date}</td>
<td>${Number(item.metric_value).toFixed(1)}</td>
<td>${bmi}</td>
<td><span class="badge bg-success">Fitbit</span></td>
`;
tbody.appendChild(tr);
});
} catch (error) {
console.error('Error loading metrics:', error);
tbody.innerHTML = '<tr><td colspan="4" class="text-danger">Error loading data</td></tr>';
}
}
// Initial load
loadData();
function showToast(msg, type = 'success') {
const el = document.getElementById('appToast');
if (el) {
const toast = new bootstrap.Toast(el);
document.querySelector('#appToast .toast-body').textContent = msg;
toast.show();
} else {
alert(msg);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,305 @@
{% extends "base.html" %}
{% block content %}
<div class="row mb-4">
<div class="col-md-8">
<h1>Garmin Health</h1>
<p class="text-muted">Track your daily health metrics from Garmin.</p>
</div>
<div class="col-md-4 text-end">
<div class="btn-group" role="group">
<button id="scanBtn30" class="btn btn-primary" onclick="triggerScan(30)">
<i class="bi bi-arrow-repeat"></i> Sync Recent (30d)
</button>
<button id="scanBtnAll" class="btn btn-outline-secondary" onclick="triggerScan(3650)">
<i class="bi bi-collection"></i> Sync All
</button>
</div>
</div>
</div>
<!-- Sync Status Alert -->
<div id="syncStatusAlert" class="alert alert-info d-none" role="alert">
<div class="d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span id="syncStatusText">Syncing...</span>
</div>
</div>
<!-- Filters -->
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">Filters</div>
<div class="card-body">
<form id="filterForm" class="row g-3">
<div class="col-md-3">
<label for="startDate" class="form-label">Start Date</label>
<input type="date" class="form-control" id="startDate" required>
</div>
<div class="col-md-3">
<label for="endDate" class="form-label">End Date</label>
<input type="date" class="form-control" id="endDate" required>
</div>
<div class="col-12 mb-2">
<small class="text-muted">Quick Ranges:</small>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(30)">Last 30
Days</button>
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(90)">Last 3
Months</button>
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(365)">Last
Year</button>
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(3650)">All
Time</button>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">Metric Types</label>
<button type="button" class="btn btn-sm btn-outline-secondary py-0" style="font-size: 0.75rem;"
onclick="deselectAllMetrics()">Deselect All</button>
</div>
<div id="metricTypeFilters" class="d-flex flex-wrap gap-2">
<!-- Checkboxes generated by JS -->
</div>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">Apply Filters</button>
<button type="button" class="btn btn-outline-secondary" onclick="resetFilters()">Reset</button>
</div>
</form>
</div>
</div>
<!-- Overview Cards (Optional - keep or remove? User asked to update table, kept for context) -->
<!-- Assuming user wants to keep high level summary, leaving it but can be collapsable -->
<!-- Unified Data Table -->
<div class="card">
<div class="card-header">
Health Metrics Data
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-striped" id="metricsTable">
<thead>
<tr>
<th>Date</th>
<th>Metric</th>
<th>Value</th>
<th>Unit</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
<script>
// Config
const AVAILABLE_METRICS = [
{ id: 'steps', label: 'Steps' },
{ id: 'heart_rate', label: 'Heart Rate' },
{ id: 'sleep', label: 'Sleep' },
{ id: 'stress', label: 'Stress' },
{ id: 'body_battery', label: 'Body Battery' },
{ id: 'respiration', label: 'Respiration' },
{ id: 'spo2', label: 'Pulse Ox' },
{ id: 'floors', label: 'Floors' },
{ id: 'sleep_score', label: 'Sleep Score' },
{ id: 'vo2_max', label: 'VO2 Max' },
{ id: 'weight', label: 'Weight' },
{ id: 'muscle_mass', label: 'Muscle Mass' },
{ id: 'bone_mass', label: 'Bone Mass' },
{ id: 'body_fat_pct', label: 'Body Fat %' },
{ id: 'body_water_pct', label: 'Body Water %' }
];
// Initialize
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
document.getElementById('endDate').valueAsDate = today;
document.getElementById('startDate').valueAsDate = thirtyDaysAgo;
// Generate Checkboxes
const filterContainer = document.getElementById('metricTypeFilters');
AVAILABLE_METRICS.forEach(m => {
const div = document.createElement('div');
div.className = 'form-check form-check-inline';
div.innerHTML = `
<input class="form-check-input" type="checkbox" id="check_${m.id}" value="${m.id}" checked>
<label class="form-check-label" for="check_${m.id}">${m.label}</label>
`;
filterContainer.appendChild(div);
});
document.getElementById('filterForm').addEventListener('submit', function (e) {
e.preventDefault();
loadMetrics();
});
function resetFilters() {
document.getElementById('endDate').valueAsDate = new Date();
const d = new Date(); d.setDate(d.getDate() - 30);
document.getElementById('startDate').valueAsDate = d;
document.querySelectorAll('#metricTypeFilters input').forEach(c => c.checked = true);
loadMetrics();
}
function deselectAllMetrics() {
document.querySelectorAll('#metricTypeFilters input').forEach(c => c.checked = false);
}
function setDateRange(days) {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - days);
document.getElementById('endDate').valueAsDate = end;
document.getElementById('startDate').valueAsDate = start;
loadMetrics();
}
async function loadMetrics() {
const start = document.getElementById('startDate').value;
const end = document.getElementById('endDate').value;
const tbody = document.querySelector('#metricsTable tbody');
// Get selected types
const selectedTypes = Array.from(document.querySelectorAll('#metricTypeFilters input:checked')).map(cb => cb.value);
if (selectedTypes.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Please select at least one metric type.</td></tr>';
return;
}
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Loading...</td></tr>';
try {
// Optimized API call: Fetch ALL (limit 5000) for date range, then filter client side
// This avoids N requests. The backend supports fetching all by omitting metric_type.
const url = `/api/metrics/query?start_date=${start}&end_date=${end}&source=garmin&limit=5000`;
const response = await fetch(url);
const data = await response.json();
// Client-side Filter
const filteredData = data.filter(item => selectedTypes.includes(item.metric_type));
tbody.innerHTML = '';
if (filteredData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No data found</td></tr>';
return;
}
// Sort by Date DESC, then by Metric Type
filteredData.sort((a, b) => {
const dateCompare = new Date(b.date) - new Date(a.date);
if (dateCompare !== 0) return dateCompare;
return a.metric_type.localeCompare(b.metric_type);
});
filteredData.forEach(item => {
const tr = document.createElement('tr');
// Format Metric Name
const metricMeta = AVAILABLE_METRICS.find(m => m.id === item.metric_type);
const metricLabel = metricMeta ? metricMeta.label : item.metric_type;
// Format Date (remove time)
let dateDisplay = item.date;
if (dateDisplay.includes('T')) dateDisplay = dateDisplay.split('T')[0];
tr.innerHTML = `
<td>${dateDisplay}</td>
<td><strong>${metricLabel}</strong></td>
<td>${Number(item.metric_value).toFixed(1)}</td>
<td>${item.unit || ''}</td>
<td><span class="badge bg-secondary">${item.source}</span></td>
`;
tbody.appendChild(tr);
});
} catch (error) {
console.error('Error loading metrics:', error);
tbody.innerHTML = '<tr><td colspan="5" class="text-danger">Error loading data</td></tr>';
}
}
// Initial Load
loadMetrics();
// --- Sync Logic (Keep Existing) ---
async function triggerScan(daysBack) {
if (!confirm(`Sync last ${daysBack} days?`)) return;
try {
showToast('Scan started...');
const res = await fetch(`/api/metrics/sync/scan?days_back=${daysBack}`, { method: 'POST' });
const data = await res.json();
await pollJob(data.job_id);
showToast('Scan complete. Downloading data...');
const resSync = await fetch('/api/metrics/sync/pending?limit=1000', { method: 'POST' });
const dataSync = await resSync.json();
showToast('Download started: ' + dataSync.job_id);
await pollJob(dataSync.job_id);
} catch (e) {
console.error(e);
showToast('Error executing sync', 'danger');
}
}
function showToast(msg, type = 'success') {
const el = document.getElementById('appToast');
if (el) {
const toast = new bootstrap.Toast(el);
document.querySelector('#appToast .toast-body').textContent = msg;
toast.show();
} else {
alert(msg);
}
}
function pollJob(jobId) {
return new Promise((resolve, reject) => {
const check = async () => {
try {
const res = await fetch(`/api/jobs/${jobId}`);
if (res.status === 404) {
loadMetrics(); // Update table
showToast('Sync finished.');
resolve('completed');
return;
}
if (!res.ok) {
showToast('Error checking status', 'danger');
reject(new Error('Network error'));
return;
}
const data = await res.json();
if (data.status === 'completed' || data.status === 'failed') {
loadMetrics(); // Update table
showToast(`Sync ${data.status}: ${data.message || ''}`, data.status === 'failed' ? 'danger' : 'success');
if (data.status === 'failed') reject(new Error(data.message));
else resolve(data.status);
} else {
// const statusText = document.getElementById('syncStatusText');
// if (statusText) statusText.textContent = `Syncing... ${data.progress}%`;
setTimeout(check, 2000);
}
} catch (e) {
reject(e);
}
};
check();
});
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff