adding fitbit data capture

This commit is contained in:
2026-01-12 15:13:50 -08:00
parent 09653d7415
commit 9fa3380730
8 changed files with 717 additions and 20 deletions

179
templates/admin/fitbit.html Normal file
View File

@@ -0,0 +1,179 @@
{% extends "admin/index.html" %}
{% block admin_content %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Fitbit Connection</h5>
{% if is_connected %}
<span class="badge bg-success">Connected</span>
{% else %}
<span class="badge bg-secondary">Disconnected</span>
{% endif %}
</div>
<div class="card-body">
<!-- Configuration Form -->
<form action="/admin/fitbit/config" method="post" class="mb-4">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Client ID</label>
<input type="text" class="form-control" name="client_id" value="{{ config.client_id or '' }}"
required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Client Secret</label>
<input type="text" class="form-control" name="client_secret"
value="{{ config.client_secret or '' }}" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Redirect URI</label>
<input type="text" class="form-control" name="redirect_uri"
value="{{ config.redirect_uri or 'http://localhost:8080/fitbit-callback' }}" required>
</div>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm">Update Configuration</button>
</form>
<hr>
{% if not is_connected %}
<div class="alert alert-info">
<strong>Connect to Fitbit:</strong>
<ol>
<li>Click "Get Authorization URL" below.</li>
<li>Visit the URL in your browser and authorize the app.</li>
<li>You will be redirected to a URL (likely failing to load). Copy the entire URL.</li>
<li>Paste it in the box below and click "Complete Connection".</li>
</ol>
</div>
<div class="mb-3">
<button id="get-auth-url-btn" class="btn btn-primary">Get Authorization URL</button>
<div id="auth-url-container" class="mt-2" style="display:none;">
<textarea class="form-control" rows="2" readonly id="auth-url-display"></textarea>
<a href="#" target="_blank" id="auth-link" class="btn btn-sm btn-link">Open Link</a>
</div>
</div>
<form action="/admin/fitbit/auth/exchange" method="post">
<div class="input-group">
<input type="text" class="form-control" name="code_input"
placeholder="Paste full redirected URL or code here..." required>
<button class="btn btn-success" type="submit">Complete Connection</button>
</div>
</form>
{% else %}
<div class="d-flex align-items-center gap-3">
<button class="btn btn-primary sync-btn" data-scope="30d">
<i class="bi bi-arrow-repeat"></i> Sync Last 30 Days
</button>
<button class="btn btn-secondary sync-btn" data-scope="all">
<i class="bi bi-clock-history"></i> Sync All History
</button>
<span id="sync-status" class="text-muted"></span>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Recent Weight Logs</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Date</th>
<th>Weight (kg)</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.date }}</td>
<td>{{ log.weight }}</td>
<td>{{ log.source }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted">No logs found. Sync to import data.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Auth URL handler
const authBtn = document.getElementById('get-auth-url-btn');
if (authBtn) {
authBtn.addEventListener('click', async () => {
try {
const response = await fetch('/admin/fitbit/auth_url');
const data = await response.json();
if (data.status === 'success') {
const container = document.getElementById('auth-url-container');
const display = document.getElementById('auth-url-display');
const link = document.getElementById('auth-link');
display.value = data.url;
link.href = data.url;
container.style.display = 'block';
} else {
alert('Error: ' + data.message);
}
} catch (e) {
alert('Request failed: ' + e);
}
});
}
// Sync handler
const syncBtns = document.querySelectorAll('.sync-btn');
syncBtns.forEach(btn => {
btn.addEventListener('click', async () => {
const scope = btn.dataset.scope;
const statusFn = document.getElementById('sync-status');
// Disable all sync buttons
syncBtns.forEach(b => b.disabled = true);
statusFn.textContent = scope === 'all' ? 'Syncing history (this may take a while)...' : 'Syncing...';
statusFn.className = 'text-muted';
try {
const formData = new FormData();
formData.append('scope', scope);
const response = await fetch('/admin/fitbit/sync', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success' || data.status === 'warning') {
statusFn.textContent = data.message;
statusFn.className = data.status === 'warning' ? 'text-warning' : 'text-success';
setTimeout(() => location.reload(), 2000); // Reload to show data
} else {
statusFn.textContent = 'Error: ' + data.message;
statusFn.className = 'text-danger';
}
} catch (e) {
statusFn.textContent = 'Failed: ' + e;
statusFn.className = 'text-danger';
} finally {
syncBtns.forEach(b => b.disabled = false);
}
});
});
});
</script>
{% endblock %}

View File

@@ -13,6 +13,9 @@
<li class="nav-item" role="presentation">
<a class="nav-link" id="llm-config-tab" href="/admin/llm_config">LLM Config</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="fitbit-tab" href="/admin/fitbit">Fitbit</a>
</li>
</ul>
<div class="tab-content mt-3">

View File

@@ -100,30 +100,82 @@
resizeChart();
chart = new Chart(ctx, {
type: 'bar', // Switch to bar chart
type: 'bar',
data: {
labels: labels,
datasets: [
{
type: 'line',
label: 'Weight (lbs)',
data: data.map(item => item.weight_lbs),
borderColor: '#0d6efd', // Bootstrap primary (Blue)
backgroundColor: '#0d6efd',
borderWidth: 2,
pointRadius: function (context) {
const index = context.dataIndex;
const item = data[index]; // Access data array from outer scope
// Show dot if it's a real weight measurement
if (item.weight_is_real) return 4;
// "Or the first point if no datapoints in the view"
// Check if ANY point in the view is real
const anyReal = data.some(d => d.weight_is_real);
if (!anyReal) {
// Make sure we only show ONE dot (the first one / oldest date)
// Data is sorted by date ascending in frontend (index 0 is oldest)
if (index === 0 && item.weight_lbs !== null) return 4;
}
return 0; // Hide dot for inferred points
},
yAxisID: 'y1',
datalabels: {
display: true,
align: 'top',
formatter: function (value, context) {
// Only show label if radius > 0
const index = context.dataIndex;
const item = data[index];
// Same logic as pointRadius
let show = false;
if (item.weight_is_real) show = true;
else {
const anyReal = data.some(d => d.weight_is_real);
if (!anyReal && index === 0 && item.weight_lbs !== null) show = true;
}
return show ? (value ? value + ' lbs' : '') : '';
},
color: '#0d6efd',
font: { weight: 'bold' }
},
spanGaps: true
},
{
label: 'Net Carbs',
data: netCarbsCals,
backgroundColor: 'rgba(255, 193, 7, 0.8)', // Bootstrap warning (Yellow)
borderColor: '#ffc107',
borderWidth: 1
borderWidth: 1,
yAxisID: 'y'
},
{
label: 'Fat',
data: fatCals,
backgroundColor: 'rgba(220, 53, 69, 0.8)', // Bootstrap danger (Red)
borderColor: '#dc3545',
borderWidth: 1
borderWidth: 1,
yAxisID: 'y'
},
{
label: 'Protein',
data: proteinCals,
backgroundColor: 'rgba(25, 135, 84, 0.8)', // Bootstrap success (Green)
borderColor: '#198754',
borderWidth: 1
borderWidth: 1,
yAxisID: 'y'
}
]
},
@@ -133,14 +185,26 @@
scales: {
y: {
beginAtZero: true,
stacked: true, // Enable stacking for Y axis
stacked: true,
title: {
display: true,
text: 'Calories'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Weight (lbs)'
},
grid: {
drawOnChartArea: false // only want the grid lines for one axis to show up
}
},
x: {
stacked: true, // Enable stacking for X axis
stacked: true,
title: {
display: true,
text: 'Date'
@@ -156,8 +220,11 @@
label += ': ';
}
if (context.parsed.y !== null) {
if (context.dataset.type === 'line') {
return label + context.parsed.y + ' lbs';
}
const dayData = data[context.dataIndex];
const macroKey = MACRO_KEYS[context.datasetIndex];
const macroKey = MACRO_KEYS[context.datasetIndex - 1]; // Offset by 1 due to weight dataset
const grams = dayData[macroKey];
label += Math.round(context.parsed.y) + ' cals (' + Math.round(grams) + 'g)';
}
@@ -172,6 +239,8 @@
size: 11
},
display: function (context) {
if (context.dataset.type === 'line') return false; // Handled separately
const dayData = data[context.dataIndex];
const pC = dayData.protein * 4;
const fC = dayData.fat * 9;
@@ -182,6 +251,8 @@
return calcTotal > 0 && (value / calcTotal) > 0.05;
},
formatter: function (value, context) {
if (context.dataset.type === 'line') return '';
const dayData = data[context.dataIndex];
const pC = dayData.protein * 4;
const fC = dayData.fat * 9;
@@ -190,7 +261,7 @@
const totalCals = calcTotal || 1;
const percent = Math.round((value / totalCals) * 100);
const macroKey = MACRO_KEYS[context.datasetIndex];
const macroKey = MACRO_KEYS[context.datasetIndex - 1]; // Offset by 1
const grams = Math.round(dayData[macroKey]);
return grams + 'g\n' + percent + '%';