Files
FitTrack2/FitnessSync/backend/templates/activities.html
2026-01-01 07:14:18 -08:00

379 lines
18 KiB
HTML

<!DOCTYPE html>
<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">
</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>
</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>
</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;
document.addEventListener('DOMContentLoaded', function () {
const toastEl = document.getElementById('appToast');
toastInstance = new bootstrap.Toast(toastEl);
loadActivities();
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(); });
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'; // 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');
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();
});
});
document.getElementById('download-selected-btn').addEventListener('click', downloadSelected);
document.getElementById('redownload-selected-btn').addEventListener('click', redownloadSelected);
});
function showToast(title, body, level = 'info') {
const toastTitle = document.getElementById('toast-title');
const toastBody = document.getElementById('toast-body');
const toastHeader = document.querySelector('.toast-header');
toastTitle.textContent = title;
toastBody.textContent = body;
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
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');
toastInstance.show();
}
async function loadActivities() {
const tbody = document.querySelector('#activities-table tbody');
tbody.innerHTML = '<tr><td colspan="8" class="text-center">Loading...</td></tr>';
const typeFilter = document.getElementById('filter-type').value;
let url = `/api/activities/list?limit=${limit}&offset=${currentPage * limit}`;
// 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
}
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch activities");
activities = await response.json();
// Initial sort
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="8" class="text-center text-danger">Error loading activities: ${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];
// Handle nulls
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="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");
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);
});
}
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;
let successCount = 0;
let failCount = 0;
showToast("Batch Redownload", `Starting batch redownload of ${selected.length} items...`, "info");
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++;
}
}
showToast("Batch Complete", `Redownloaded: ${successCount}. Failed: ${failCount}.`, failCount > 0 ? "warning" : "success");
loadActivities();
}
// --- 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>