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 %}
|
||||
Reference in New Issue
Block a user