added activity view
This commit is contained in:
@@ -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 %}
|
||||
496
FitnessSync/backend/templates/activity_view.html
Normal file
496
FitnessSync/backend/templates/activity_view.html
Normal 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: '© <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 %}
|
||||
221
FitnessSync/backend/templates/base.html
Normal file
221
FitnessSync/backend/templates/base.html
Normal 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>
|
||||
202
FitnessSync/backend/templates/bike_setups.html
Normal file
202
FitnessSync/backend/templates/bike_setups.html
Normal 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 %}
|
||||
329
FitnessSync/backend/templates/fitbit_health.html
Normal file
329
FitnessSync/backend/templates/fitbit_health.html
Normal 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 %}
|
||||
305
FitnessSync/backend/templates/garmin_health.html
Normal file
305
FitnessSync/backend/templates/garmin_health.html
Normal 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
Reference in New Issue
Block a user