305 lines
12 KiB
HTML
305 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4">
|
|
<div class="col-md-8">
|
|
<h1>Garmin Health</h1>
|
|
<p class="text-muted">Track your daily health metrics from Garmin.</p>
|
|
</div>
|
|
<div class="col-md-4 text-end">
|
|
<div class="btn-group" role="group">
|
|
<button id="scanBtn30" class="btn btn-primary" onclick="triggerScan(30)">
|
|
<i class="bi bi-arrow-repeat"></i> Sync Recent (30d)
|
|
</button>
|
|
<button id="scanBtnAll" class="btn btn-outline-secondary" onclick="triggerScan(3650)">
|
|
<i class="bi bi-collection"></i> Sync All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sync Status Alert -->
|
|
<div id="syncStatusAlert" class="alert alert-info d-none" role="alert">
|
|
<div class="d-flex align-items-center">
|
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
<span id="syncStatusText">Syncing...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<!-- Filters -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">Filters</div>
|
|
<div class="card-body">
|
|
<form id="filterForm" class="row g-3">
|
|
<div class="col-md-3">
|
|
<label for="startDate" class="form-label">Start Date</label>
|
|
<input type="date" class="form-control" id="startDate" required>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label for="endDate" class="form-label">End Date</label>
|
|
<input type="date" class="form-control" id="endDate" required>
|
|
</div>
|
|
<div class="col-12 mb-2">
|
|
<small class="text-muted">Quick Ranges:</small>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(30)">Last 30
|
|
Days</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(90)">Last 3
|
|
Months</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(365)">Last
|
|
Year</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="setDateRange(3650)">All
|
|
Time</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
<label class="form-label mb-0">Metric Types</label>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0" style="font-size: 0.75rem;"
|
|
onclick="deselectAllMetrics()">Deselect All</button>
|
|
</div>
|
|
<div id="metricTypeFilters" class="d-flex flex-wrap gap-2">
|
|
<!-- Checkboxes generated by JS -->
|
|
</div>
|
|
</div>
|
|
<div class="col-12 text-end">
|
|
<button type="submit" class="btn btn-primary">Apply Filters</button>
|
|
<button type="button" class="btn btn-outline-secondary" onclick="resetFilters()">Reset</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Overview Cards (Optional - keep or remove? User asked to update table, kept for context) -->
|
|
<!-- Assuming user wants to keep high level summary, leaving it but can be collapsable -->
|
|
|
|
<!-- Unified Data Table -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
Health Metrics Data
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-striped" id="metricsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Metric</th>
|
|
<th>Value</th>
|
|
<th>Unit</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Populated by JS -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Config
|
|
const AVAILABLE_METRICS = [
|
|
{ id: 'steps', label: 'Steps' },
|
|
{ id: 'heart_rate', label: 'Heart Rate' },
|
|
{ id: 'sleep', label: 'Sleep' },
|
|
{ id: 'stress', label: 'Stress' },
|
|
{ id: 'body_battery', label: 'Body Battery' },
|
|
{ id: 'respiration', label: 'Respiration' },
|
|
{ id: 'spo2', label: 'Pulse Ox' },
|
|
{ id: 'floors', label: 'Floors' },
|
|
{ id: 'sleep_score', label: 'Sleep Score' },
|
|
{ id: 'vo2_max', label: 'VO2 Max' },
|
|
{ id: 'weight', label: 'Weight' },
|
|
{ id: 'muscle_mass', label: 'Muscle Mass' },
|
|
{ id: 'bone_mass', label: 'Bone Mass' },
|
|
{ id: 'body_fat_pct', label: 'Body Fat %' },
|
|
{ id: 'body_water_pct', label: 'Body Water %' }
|
|
];
|
|
|
|
// Initialize
|
|
const today = new Date();
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
|
|
document.getElementById('endDate').valueAsDate = today;
|
|
document.getElementById('startDate').valueAsDate = thirtyDaysAgo;
|
|
|
|
// Generate Checkboxes
|
|
const filterContainer = document.getElementById('metricTypeFilters');
|
|
AVAILABLE_METRICS.forEach(m => {
|
|
const div = document.createElement('div');
|
|
div.className = 'form-check form-check-inline';
|
|
div.innerHTML = `
|
|
<input class="form-check-input" type="checkbox" id="check_${m.id}" value="${m.id}" checked>
|
|
<label class="form-check-label" for="check_${m.id}">${m.label}</label>
|
|
`;
|
|
filterContainer.appendChild(div);
|
|
});
|
|
|
|
document.getElementById('filterForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
loadMetrics();
|
|
});
|
|
|
|
function resetFilters() {
|
|
document.getElementById('endDate').valueAsDate = new Date();
|
|
const d = new Date(); d.setDate(d.getDate() - 30);
|
|
document.getElementById('startDate').valueAsDate = d;
|
|
document.querySelectorAll('#metricTypeFilters input').forEach(c => c.checked = true);
|
|
loadMetrics();
|
|
}
|
|
|
|
function deselectAllMetrics() {
|
|
document.querySelectorAll('#metricTypeFilters input').forEach(c => c.checked = false);
|
|
}
|
|
|
|
function setDateRange(days) {
|
|
const end = new Date();
|
|
const start = new Date();
|
|
start.setDate(end.getDate() - days);
|
|
|
|
document.getElementById('endDate').valueAsDate = end;
|
|
document.getElementById('startDate').valueAsDate = start;
|
|
loadMetrics();
|
|
}
|
|
|
|
async function loadMetrics() {
|
|
const start = document.getElementById('startDate').value;
|
|
const end = document.getElementById('endDate').value;
|
|
const tbody = document.querySelector('#metricsTable tbody');
|
|
|
|
// Get selected types
|
|
const selectedTypes = Array.from(document.querySelectorAll('#metricTypeFilters input:checked')).map(cb => cb.value);
|
|
|
|
if (selectedTypes.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Please select at least one metric type.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">Loading...</td></tr>';
|
|
|
|
try {
|
|
// Optimized API call: Fetch ALL (limit 5000) for date range, then filter client side
|
|
// This avoids N requests. The backend supports fetching all by omitting metric_type.
|
|
const url = `/api/metrics/query?start_date=${start}&end_date=${end}&source=garmin&limit=5000`;
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
// Client-side Filter
|
|
const filteredData = data.filter(item => selectedTypes.includes(item.metric_type));
|
|
|
|
tbody.innerHTML = '';
|
|
if (filteredData.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center">No data found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
// Sort by Date DESC, then by Metric Type
|
|
filteredData.sort((a, b) => {
|
|
const dateCompare = new Date(b.date) - new Date(a.date);
|
|
if (dateCompare !== 0) return dateCompare;
|
|
return a.metric_type.localeCompare(b.metric_type);
|
|
});
|
|
|
|
filteredData.forEach(item => {
|
|
const tr = document.createElement('tr');
|
|
|
|
// Format Metric Name
|
|
const metricMeta = AVAILABLE_METRICS.find(m => m.id === item.metric_type);
|
|
const metricLabel = metricMeta ? metricMeta.label : item.metric_type;
|
|
|
|
// Format Date (remove time)
|
|
let dateDisplay = item.date;
|
|
if (dateDisplay.includes('T')) dateDisplay = dateDisplay.split('T')[0];
|
|
|
|
tr.innerHTML = `
|
|
<td>${dateDisplay}</td>
|
|
<td><strong>${metricLabel}</strong></td>
|
|
<td>${Number(item.metric_value).toFixed(1)}</td>
|
|
<td>${item.unit || ''}</td>
|
|
<td><span class="badge bg-secondary">${item.source}</span></td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading metrics:', error);
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-danger">Error loading data</td></tr>';
|
|
}
|
|
}
|
|
|
|
// Initial Load
|
|
loadMetrics();
|
|
|
|
// --- Sync Logic (Keep Existing) ---
|
|
async function triggerScan(daysBack) {
|
|
if (!confirm(`Sync last ${daysBack} days?`)) return;
|
|
try {
|
|
showToast('Scan started...');
|
|
const res = await fetch(`/api/metrics/sync/scan?days_back=${daysBack}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
|
|
await pollJob(data.job_id);
|
|
|
|
showToast('Scan complete. Downloading data...');
|
|
const resSync = await fetch('/api/metrics/sync/pending?limit=1000', { method: 'POST' });
|
|
const dataSync = await resSync.json();
|
|
showToast('Download started: ' + dataSync.job_id);
|
|
|
|
await pollJob(dataSync.job_id);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Error executing sync', 'danger');
|
|
}
|
|
}
|
|
|
|
function showToast(msg, type = 'success') {
|
|
const el = document.getElementById('appToast');
|
|
if (el) {
|
|
const toast = new bootstrap.Toast(el);
|
|
document.querySelector('#appToast .toast-body').textContent = msg;
|
|
toast.show();
|
|
} else {
|
|
alert(msg);
|
|
}
|
|
}
|
|
|
|
function pollJob(jobId) {
|
|
return new Promise((resolve, reject) => {
|
|
const check = async () => {
|
|
try {
|
|
const res = await fetch(`/api/jobs/${jobId}`);
|
|
if (res.status === 404) {
|
|
loadMetrics(); // Update table
|
|
showToast('Sync finished.');
|
|
resolve('completed');
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
showToast('Error checking status', 'danger');
|
|
reject(new Error('Network error'));
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (data.status === 'completed' || data.status === 'failed') {
|
|
loadMetrics(); // Update table
|
|
showToast(`Sync ${data.status}: ${data.message || ''}`, data.status === 'failed' ? 'danger' : 'success');
|
|
if (data.status === 'failed') reject(new Error(data.message));
|
|
else resolve(data.status);
|
|
} else {
|
|
// const statusText = document.getElementById('syncStatusText');
|
|
// if (statusText) statusText.textContent = `Syncing... ${data.progress}%`;
|
|
setTimeout(check, 2000);
|
|
}
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %} |