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