This commit is contained in:
2026-01-01 07:14:18 -08:00
parent 25745cf6d6
commit c45e41b6a9
100 changed files with 8068 additions and 2424 deletions

View File

@@ -0,0 +1,379 @@
<!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>

View File

@@ -1,15 +1,29 @@
<!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">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync Dashboard</h1>
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/activities">Activities</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/setup">Setup</a>
</li>
</ul>
<!-- Toast container for notifications -->
<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">
@@ -22,13 +36,46 @@
</div>
</div>
<!-- Job Status Banner -->
<div id="job-status-banner" class="alert alert-info mt-3" style="display: none;">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong id="job-operation">Operation</strong>
<div class="progress mt-2" style="width: 300px;">
<div id="job-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%"></div>
</div>
<small id="job-message" class="text-muted">Starting...</small>
</div>
<button class="btn btn-danger btn-sm" id="stop-job-btn">Stop</button>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Activities</h5>
<p class="card-text">Total: <span id="total-activities">0</span></p>
<p class="card-text">Downloaded: <span id="downloaded-activities">0</span></p>
<h5 class="card-title">Last Sync Status</h5>
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm" id="metrics-status-table">
<thead>
<tr>
<th>Type</th>
<th>Source</th>
<th>Found</th>
<th>Synced</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4">No sync data available.</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-2 text-muted small">
<span id="db-stats"></span>
</div>
</div>
</div>
</div>
@@ -37,14 +84,29 @@
<div class="card-body">
<h5 class="card-title">Sync Controls</h5>
<div class="d-grid gap-2">
<button class="btn btn-primary" type="button" id="sync-activities-btn">Sync Activities</button>
<button class="btn btn-info" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
<button class="btn btn-primary" type="button" id="sync-activities-btn">Sync Latest
Activities (30d)</button>
<button class="btn btn-outline-primary" type="button" id="sync-all-activities-btn">Sync All
Historical Activities</button>
<hr>
<button class="btn btn-info text-white" type="button" id="sync-metrics-btn">Sync Latest
Health Metrics (Garmin) (30d)</button>
<button class="btn btn-outline-info" type="button" id="sync-all-metrics-btn">Sync All
Historical Health Metrics (Garmin)</button>
<hr>
<h6 class="text-muted">Fitbit Sync</h6>
<button class="btn btn-success" type="button" id="sync-fitbit-btn">Sync Latest Weight
(Fitbit) (30d)</button>
<button class="btn btn-outline-success" type="button" id="sync-all-fitbit-btn">Sync All
Historical Weight (Fitbit)</button>
<button class="btn btn-warning mt-2" type="button" id="compare-fitbit-btn">Compare Fitbit vs
Garmin</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Recent Sync Logs</h3>
@@ -70,32 +132,43 @@
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Actions</h5>
<h3>Actions</h3>
<div class="card">
<div class="card-body">
<a href="/setup" class="btn btn-primary me-md-2">Setup & Configuration</a>
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
</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 toastInstance = null;
document.addEventListener('DOMContentLoaded', function() {
let isPolling = false;
let currentJobId = null;
document.addEventListener('DOMContentLoaded', function () {
const toastEl = document.getElementById('appToast');
toastInstance = new bootstrap.Toast(toastEl);
loadDashboardData();
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
document.getElementById('sync-metrics-btn').addEventListener('click', syncHealthMetrics);
checkJobStatus(); // Check on load
document.getElementById('sync-activities-btn').addEventListener('click', () => syncActivities(30));
document.getElementById('sync-all-activities-btn').addEventListener('click', () => syncActivities(3650));
document.getElementById('sync-metrics-btn').addEventListener('click', () => syncHealthMetrics(30));
document.getElementById('sync-all-metrics-btn').addEventListener('click', () => syncHealthMetrics(3650));
document.getElementById('sync-fitbit-btn').addEventListener('click', () => syncFitbitWeight('30d'));
document.getElementById('sync-all-fitbit-btn').addEventListener('click', () => syncFitbitWeight('all'));
document.getElementById('compare-fitbit-btn').addEventListener('click', compareWeight);
document.getElementById('stop-job-btn').addEventListener('click', stopCurrentJob);
});
function showToast(title, body, level = 'info') {
@@ -105,7 +178,7 @@
toastTitle.textContent = title;
toastBody.textContent = body;
// Reset header color
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'bg-info', 'text-white');
@@ -121,7 +194,7 @@
toastInstance.show();
}
async function loadDashboardData() {
try {
const response = await fetch('/api/status');
@@ -129,23 +202,41 @@
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
document.getElementById('total-activities').textContent = data.total_activities;
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
document.getElementById('db-stats').innerHTML =
`<strong>DB Total Activities:</strong> ${data.total_activities} | <strong>Downloaded:</strong> ${data.downloaded_activities}`;
const metricsBody = document.querySelector('#metrics-status-table tbody');
metricsBody.innerHTML = '';
if (data.last_sync_stats && data.last_sync_stats.length > 0) {
data.last_sync_stats.forEach(stat => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${stat.type}</td>
<td>${stat.source}</td>
<td>${stat.total}</td>
<td class="${stat.synced > 0 ? 'text-success' : ''}">${stat.synced}</td>
`;
metricsBody.appendChild(row);
});
} else {
metricsBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No detailed sync stats available. Run a sync to populate.</td></tr>';
}
const logsBody = document.querySelector('#sync-logs-table tbody');
logsBody.innerHTML = '';
if (data.recent_logs.length === 0) {
logsBody.innerHTML = '<tr><td colspan="7">No recent sync logs.</td></tr>';
return;
}
data.recent_logs.forEach(log => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${log.operation}</td>
<td><span class="badge bg-${log.status === 'completed' ? 'success' : 'warning'}">${log.status}</span></td>
<td><span class="badge bg-${log.status === 'completed' ? 'success' : (log.status === 'failed' || log.status === 'cancelled' ? 'danger' : 'warning')}">${log.status}</span></td>
<td>${new Date(log.start_time).toLocaleString()}</td>
<td>${log.end_time ? new Date(log.end_time).toLocaleString() : 'N/A'}</td>
<td>${log.records_processed}</td>
@@ -159,51 +250,185 @@
showToast('Error', 'Could not load dashboard data.', 'error');
}
}
async function syncActivities() {
showToast('Syncing...', 'Activity sync has been initiated.', 'info');
async function checkJobStatus() {
try {
const response = await fetch('/api/jobs/active');
if (response.ok) {
const jobs = await response.json();
const banner = document.getElementById('job-status-banner');
if (jobs.length > 0) {
const job = jobs[0]; // Just show first one for now
currentJobId = job.id;
isPolling = true;
banner.style.display = 'block';
document.getElementById('job-operation').textContent = job.operation;
document.getElementById('job-progress-bar').style.width = job.progress + '%';
document.getElementById('job-message').textContent = job.message;
const stopBtn = document.getElementById('stop-job-btn');
if (job.cancel_requested) {
stopBtn.disabled = true;
stopBtn.textContent = "Stopping...";
} else {
stopBtn.disabled = false;
stopBtn.textContent = "Stop";
}
// Disable sync buttons
toggleSyncButtons(true);
setTimeout(checkJobStatus, 1000);
} else {
// Job finished
if (isPolling) {
showToast('Job Finished', 'Background job completed.', 'success');
loadDashboardData();
isPolling = false;
currentJobId = null;
banner.style.display = 'none';
toggleSyncButtons(false);
}
}
}
} catch (e) {
console.error("Polling error", e);
}
}
function toggleSyncButtons(disabled) {
const ids = [
'sync-activities-btn', 'sync-all-activities-btn',
'sync-metrics-btn', 'sync-all-metrics-btn',
'sync-fitbit-btn', 'sync-all-fitbit-btn'
];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = disabled;
});
}
async function stopCurrentJob() {
if (!currentJobId) return;
try {
const response = await fetch(`/api/jobs/${currentJobId}/stop`, { method: 'POST' });
if (response.ok) {
showToast('Stopping', 'Cancellation requested...', 'warning');
document.getElementById('stop-job-btn').textContent = "Stopping...";
document.getElementById('stop-job-btn').disabled = true;
}
} catch (e) {
showToast('Error', 'Failed to stop job', 'error');
}
}
async function syncActivities(daysBack = 30) {
const typeLabel = daysBack > 1000 ? 'Historical' : 'Latest';
showToast(`${typeLabel} Syncing...`, `Starting ${typeLabel} Activity sync...`, 'info');
try {
const response = await fetch('/api/sync/activities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days_back: 30 })
body: JSON.stringify({ days_back: daysBack })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
showToast('Sync Complete', data.message, 'success');
loadDashboardData(); // Refresh data after sync
if (response.ok) {
showToast('Sync Started', data.message, 'success');
checkJobStatus();
} else {
throw new Error(data.detail || data.message || `HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('Error syncing activities:', error);
showToast('Sync Error', `Activity sync failed: ${error.message}`, 'error');
showToast('Sync Error', `Activity sync failed start: ${error.message}`, 'error');
}
}
async function syncHealthMetrics() {
showToast('Syncing...', 'Health metrics sync has been initiated.', 'info');
async function syncHealthMetrics(daysBack = 30) {
const typeLabel = daysBack > 1000 ? 'Historical' : 'Latest';
showToast(`${typeLabel} Syncing...`, `Starting ${typeLabel} Health metrics sync...`, 'info');
try {
const response = await fetch('/api/sync/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days_back: daysBack })
});
const data = await response.json();
if (response.ok) {
showToast('Sync Started', data.message, 'success');
checkJobStatus();
} else {
throw new Error(data.detail || data.message || `HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('Error syncing health metrics:', error);
showToast('Sync Error', `Health metrics sync failed start: ${error.message}`, 'error');
}
}
async function syncFitbitWeight(scope) {
const typeLabel = scope === 'all' ? 'All History' : 'Latest (30d)';
showToast(`Fitbit Syncing...`, `Fitbit Weight sync initiated (${typeLabel}).`, 'info');
try {
const response = await fetch('/api/sync/fitbit/weight', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ scope: scope })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
const errorData = await response.json();
throw new Error(errorData.detail || errorData.message || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
showToast('Sync Complete', data.message, 'success');
loadDashboardData(); // Refresh data after sync
showToast('Fitbit Sync Complete', data.message, 'success');
loadDashboardData();
} catch (error) {
console.error('Error syncing health metrics:', error);
showToast('Sync Error', `Health metrics sync failed: ${error.message}`, 'error');
console.error('Error syncing Fitbit weight:', error);
showToast('Fitbit Sync Error', `Sync failed: ${error.message}`, 'error');
}
}
async function compareWeight() {
showToast('Comparing...', 'Comparing Fitbit and Garmin weight records...', 'info');
try {
const response = await fetch('/api/sync/compare-weight', { method: 'POST' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Show a persistent alert or just a long toast?
// A toast is fine for now, or maybe an alert.
// Let's use a detailed toast.
showToast('Comparison Results',
`Fitbit Total: ${data.fitbit_total}\n` +
`Garmin Total: ${data.garmin_total}\n` +
`Missing in Garmin: ${data.missing_in_garmin}\n` +
`${data.message}`,
data.missing_in_garmin > 0 ? 'warning' : 'success'
);
// Also log to console
console.log("Comparison Data:", data);
} catch (error) {
console.error('Error comparing weight:', error);
showToast('Comparison Error', `Comparison failed: ${error.message}`, 'error');
}
}
</script>
</body>
</html>
</html>

View File

@@ -1,15 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync - Setup</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">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync - Setup</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" href="/activities">Activities</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/setup">Setup</a>
</li>
</ul>
<div class="mb-3">
<button type="button" class="btn btn-info" id="load-from-consul-btn">Load Config from Consul</button>
</div>
@@ -27,7 +41,7 @@
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
@@ -36,46 +50,55 @@
<form id="garmin-credentials-form">
<div class="mb-3">
<label for="garmin-username" class="form-label">Username</label>
<input type="text" class="form-control" id="garmin-username" name="username" required autocomplete="username">
<input type="text" class="form-control" id="garmin-username" name="username" required
autocomplete="username">
</div>
<div class="mb-3">
<label for="garmin-password" class="form-label">Password</label>
<input type="password" class="form-control" id="garmin-password" name="password" required autocomplete="current-password">
<input type="password" class="form-control" id="garmin-password" name="password"
required autocomplete="current-password">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="garmin-china" name="is_china">
<label class="form-check-label" for="garmin-china">Use China domain (garmin.cn)</label>
</div>
<button type="button" class="btn btn-secondary" id="test-garmin-btn">Test Garmin Credentials</button>
<button type="submit" class="btn btn-primary" id="save-garmin-btn" disabled>Save Garmin Credentials</button>
<button type="button" class="btn btn-info" id="test-garmin-token-btn">Test Current Garmin Token</button>
<button type="button" class="btn btn-secondary" id="test-garmin-btn">Test Garmin
Credentials</button>
<button type="submit" class="btn btn-primary" id="save-garmin-btn" disabled>Save Garmin
Credentials</button>
<button type="button" class="btn btn-info" id="test-garmin-token-btn">Test Current Garmin
Token</button>
<button type="button" class="btn btn-danger ms-2" id="clear-garmin-btn">Clear
Credentials</button>
</form>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status-text" class="mt-3">
<p>Current auth state: <span class="badge bg-secondary">Not Tested</span></p>
</div>
<div id="garmin-token-test-result" class="mt-3"></div>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status" class="mt-3">
<p>Loading Garmin authentication status...</p>
</div>
<!-- MFA Section -->
<div id="garmin-mfa-section" class="mt-3" style="display: none;">
<h6>Multi-Factor Authentication (MFA)</h6>
<div class="mb-3">
<label for="mfa-code" class="form-label">Enter Verification Code</label>
<input type="text" class="form-control" id="mfa-code" placeholder="Enter code from your authenticator app or SMS">
<input type="text" class="form-control" id="mfa-code"
placeholder="Enter code from your authenticator app or SMS">
</div>
<button type="button" class="btn btn-primary" id="submit-mfa-btn">Submit Verification Code</button>
<button type="button" class="btn btn-primary" id="submit-mfa-btn">Submit Verification
Code</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
@@ -83,74 +106,115 @@
<form id="fitbit-credentials-form">
<div class="mb-3">
<label for="fitbit-client-id" class="form-label">Client ID</label>
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required autocomplete="username">
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required
autocomplete="username">
</div>
<div class="mb-3">
<label for="fitbit-client-secret" class="form-label">Client Secret</label>
<input type="password" class="form-control" id="fitbit-client-secret" name="client_secret" required autocomplete="new-password">
<input type="password" class="form-control" id="fitbit-client-secret"
name="client_secret" required autocomplete="new-password">
</div>
<button type="button" class="btn btn-secondary" id="test-fitbit-btn">Test Fitbit Credentials</button>
<button type="submit" class="btn btn-primary" id="save-fitbit-btn" disabled>Save Fitbit Credentials</button>
<div class="mb-3">
<label for="fitbit-redirect-uri" class="form-label">Redirect URI</label>
<input type="text" class="form-control" id="fitbit-redirect-uri" name="redirect_uri"
value="http://localhost:8000/fitbit_callback"
placeholder="http://localhost:8000/fitbit_callback">
<div class="form-text">Must match exactly what you entered in the Fitbit Developer
Dashboard. Leave blank if only one is registered.</div>
</div>
<button type="button" class="btn btn-secondary" id="test-fitbit-btn">Test Fitbit
Credentials</button>
<button type="submit" class="btn btn-primary" id="save-fitbit-btn" disabled>Save Fitbit
Credentials</button>
<button type="button" class="btn btn-info" id="test-fitbit-token-btn">Test Current Fitbit
Token</button>
</form>
<div id="fitbit-token-test-result" class="mt-3"></div>
<div class="mt-3">
<div id="auth-url-container" style="display: none;">
<p>After saving credentials, click the link below to authorize:</p>
<a id="auth-link" class="btn btn-secondary" href="#" target="_blank">Authorize with Fitbit</a>
<a id="auth-link" class="btn btn-secondary mb-3" href="#" target="_blank">Authorize with
Fitbit</a>
</div>
</div>
<!-- OAuth Flow Section (Moved here) -->
<div id="fitbit-oauth-flow-section"
style="display: none; border-top: 1px solid #eee; padding-top: 15px;">
<h5>Complete Fitbit OAuth Flow</h5>
<form id="fitbit-callback-form">
<div class="mb-3">
<label for="callback-url" class="form-label">Paste full callback URL from
browser</label>
<input type="url" class="form-control" id="callback-url" name="callback_url"
required>
</div>
<button type="submit" class="btn btn-success">Complete OAuth Flow</button>
</form>
</div>
<!-- Fitbit Authentication Status -->
<div id="fitbit-auth-status" class="mt-3">
<p>Loading Fitbit authentication status...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4" id="fitbit-oauth-flow-section" style="display: none;">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Complete Fitbit OAuth Flow</h5>
<form id="fitbit-callback-form">
<div class="mb-3">
<label for="callback-url" class="form-label">Paste full callback URL from browser</label>
<input type="url" class="form-control" id="callback-url" name="callback_url" required>
</div>
<button type="submit" class="btn btn-success">Complete OAuth Flow</button>
</form>
</div>
</div>
</div>
</div>
<!-- Section removed here -->
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
// Load initial status information
loadStatusInfo();
// Setup form event listeners
document.getElementById('load-from-consul-btn').addEventListener('click', loadFromConsul);
document.getElementById('test-garmin-btn').addEventListener('click', testGarminCredentials);
document.getElementById('test-garmin-token-btn').addEventListener('click', testGarminToken);
document.getElementById('clear-garmin-btn').addEventListener('click', clearGarminCredentials);
document.getElementById('garmin-credentials-form').addEventListener('submit', saveGarminCredentials);
document.getElementById('test-fitbit-btn').addEventListener('click', testFitbitCredentials);
document.getElementById('fitbit-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-callback-form').addEventListener('submit', completeFitbitAuth);
document.getElementById('test-fitbit-token-btn').addEventListener('click', testFitbitToken);
});
async function testFitbitToken() {
const resultDiv = document.getElementById('fitbit-token-test-result');
resultDiv.innerHTML = '<p>Testing token...</p>';
try {
const response = await fetch('/api/setup/fitbit/test-token', { method: 'POST' });
const data = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="alert alert-success"><strong>Success!</strong> ${data.message}</div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">${data.message || 'Failed to test token'}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
}
}
async function testGarminToken() {
const resultDiv = document.getElementById('garmin-token-test-result');
resultDiv.innerHTML = '<p>Testing token...</p>';
try {
const response = await fetch('/api/setup/garmin/test-token', { method: 'POST' });
const data = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="alert alert-success"><pre>${JSON.stringify(data, null, 2)}</pre></div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">${data.detail || 'Failed to test token'}</div>`;
// Show detail (FastAPI standard) or message (our custom response)
resultDiv.innerHTML = `<div class="alert alert-danger">${data.detail || data.message || 'Failed to test token'}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
@@ -158,35 +222,56 @@
}
async function loadFromConsul() {
alert('Attempting to load config from Consul and save to backend...');
// alert('Attempting to load config from Consul...');
console.log('loadFromConsul function called');
const btn = document.getElementById('load-from-consul-btn');
const originalText = btn.innerText;
btn.innerText = 'Loading...';
btn.disabled = true;
try {
const response = await fetch('/api/setup/load-consul-config', { method: 'POST' });
console.log('Response received:', response);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to load config from Consul');
}
const data = await response.json();
if (data.status === "mfa_required") {
alert(data.message);
loadStatusInfo(); // Refresh the status info to potentially show MFA section
} else {
alert(data.message || 'Configuration loaded from Consul successfully.');
loadStatusInfo(); // Refresh the status info
console.log('Config data:', data);
// Populate Garmin Form
if (data.garmin) {
if (data.garmin.username) document.getElementById('garmin-username').value = data.garmin.username;
if (data.garmin.password) document.getElementById('garmin-password').value = data.garmin.password;
if (data.garmin.is_china !== undefined) document.getElementById('garmin-china').checked = data.garmin.is_china;
}
// Populate Fitbit Form
if (data.fitbit) {
if (data.fitbit.client_id) document.getElementById('fitbit-client-id').value = data.fitbit.client_id;
if (data.fitbit.client_secret) document.getElementById('fitbit-client-secret').value = data.fitbit.client_secret;
if (data.fitbit.redirect_uri) document.getElementById('fitbit-redirect-uri').value = data.fitbit.redirect_uri;
}
alert(data.message || 'Configuration loaded. Please review and save your credentials.');
} catch (error) {
console.error('Error loading config from Consul:', error);
alert('Error loading config from Consul: ' + error.message);
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
}
async function loadStatusInfo() {
try {
// Get general status
const statusResponse = await fetch('/api/status');
const statusData = await statusResponse.json();
// Update status info
const statusContainer = document.getElementById('status-info');
statusContainer.innerHTML = `
@@ -204,12 +289,12 @@
</div>
</div>
`;
// Get authentication status from a new API endpoint
const authStatusResponse = await fetch('/api/setup/auth-status');
if (authStatusResponse.ok) {
const authData = await authStatusResponse.json();
// Update Garmin auth status
const garminStatusContainer = document.getElementById('garmin-auth-status');
if (authData.garmin) {
@@ -234,7 +319,7 @@
}
garminStatusContainer.innerHTML = `<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}">${garminStatusHtml}</div>`;
}
// Update Fitbit auth status
const fitbitStatusContainer = document.getElementById('fitbit-auth-status');
if (authData.fitbit) {
@@ -247,13 +332,21 @@
${authData.fitbit.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.fitbit.last_login).toLocaleString()}</p>` : ''}
</div>
`;
// Show/Hide Sync Section
const syncSection = document.getElementById('fitbit-sync-section');
if (authData.fitbit.authenticated) {
syncSection.style.display = 'block';
} else {
syncSection.style.display = 'none';
}
}
}
} catch (error) {
console.error('Error loading status info:', error);
}
}
async function testGarminCredentials() {
const form = document.getElementById('garmin-credentials-form');
const formData = new FormData(form);
@@ -274,9 +367,9 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.status === 'mfa_required') {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-warning">MFA Required</span></p>`;
document.getElementById('garmin-mfa-section').style.display = 'block';
@@ -287,7 +380,7 @@
saveBtn.disabled = false;
alert('Garmin authentication successful. You can now save the credentials.');
} else {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Authentication Failed</span></p>`;
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-danger">Authentication Failed</span></p>`;
alert(data.message || 'Garmin authentication failed.');
}
} catch (error) {
@@ -299,23 +392,23 @@
async function saveGarminCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
username: formData.get('username'),
password: formData.get('password'),
is_china: formData.get('is_china') === 'on' || formData.get('is_china') === 'true'
};
try {
const response = await fetch('/api/setup/garmin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (response.ok) {
alert('Garmin credentials saved successfully');
loadStatusInfo();
@@ -327,7 +420,33 @@
alert('Error saving Garmin credentials: ' + error.message);
}
}
async function clearGarminCredentials() {
if (!confirm('Are you sure you want to clear stored Garmin credentials? This will require re-authentication.')) {
return;
}
try {
const response = await fetch('/api/setup/garmin', {
method: 'DELETE'
});
const data = await response.json();
if (response.ok) {
alert(data.message || 'Garmin credentials cleared.');
loadStatusInfo();
// Reset status text
document.getElementById('garmin-auth-status-text').innerHTML = `<p>Current auth state: <span class="badge bg-secondary">Not Tested</span></p>`;
} else {
alert(data.message || 'Error clearing credentials.');
}
} catch (error) {
console.error('Error clearing Garmin credentials:', error);
alert('Error clearing Garmin credentials: ' + error.message);
}
}
async function testFitbitCredentials() {
const form = document.getElementById('fitbit-credentials-form');
const formData = new FormData(form);
@@ -345,7 +464,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (response.ok) {
@@ -366,13 +485,13 @@
async function saveFitbitCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret')
};
try {
const response = await fetch('/api/setup/fitbit', {
method: 'POST',
@@ -381,9 +500,9 @@
},
body: JSON.stringify(credentials)
});
const data = await response.json();
if(response.ok) {
if (response.ok) {
alert('Fitbit credentials saved successfully');
loadStatusInfo();
} else {
@@ -394,15 +513,55 @@
alert('Error saving Fitbit credentials: ' + error.message);
}
}
async function syncFitbitWeight(scope) {
const resultDiv = document.getElementById('fitbit-sync-result');
resultDiv.innerHTML = `<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Syncing ${scope === '30d' ? 'latest' : 'all'} data...`;
try {
const response = await fetch('/api/sync/fitbit/weight', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ scope: scope })
});
const data = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="alert alert-success mt-2">${data.message}</div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger mt-2">${data.detail || data.message || 'Sync failed'}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger mt-2">Error: ${error.message}</div>`;
}
}
async function completeFitbitAuth(event) {
event.preventDefault();
const formData = new FormData(event.target);
const callbackUrl = formData.get('callback_url');
let code = callbackUrl;
// Try to extract code parameter if it looks like a URL
try {
if (callbackUrl.includes('?')) {
const url = new URL(callbackUrl);
const params = new URLSearchParams(url.search);
if (params.has('code')) {
code = params.get('code');
}
}
} catch (e) {
console.warn("Could not parse URL, assuming input is the code itself", e);
}
const callbackData = {
callback_url: formData.get('callback_url')
code: code
};
try {
const response = await fetch('/api/setup/fitbit/callback', {
method: 'POST',
@@ -411,10 +570,10 @@
},
body: JSON.stringify(callbackData)
});
const data = await response.json();
alert(data.message || 'Fitbit OAuth flow completed successfully');
// Refresh status after completing OAuth
loadStatusInfo();
} catch (error) {
@@ -422,10 +581,10 @@
alert('Error completing Fitbit OAuth: ' + error.message);
}
}
// Handle MFA submission
document.getElementById('submit-mfa-btn').addEventListener('click', submitMFA);
async function submitMFA() {
const mfaCode = document.getElementById('mfa-code').value.trim();
const statusText = document.getElementById('garmin-auth-status-text');
@@ -435,7 +594,7 @@
alert('Please enter the verification code');
return;
}
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-info">Verifying MFA...</span></p>`;
try {
@@ -444,14 +603,14 @@
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
body: JSON.stringify({
verification_code: mfaCode,
session_id: window.garmin_mfa_session_id
})
});
const data = await response.json();
if (response.ok) {
statusText.innerHTML = `<p>Current auth state: <span class="badge bg-success">MFA Verification Successful</span></p>`;
saveBtn.disabled = false;
@@ -471,4 +630,5 @@
}
</script>
</body>
</html>