diff --git a/js/biometrics-view.js b/js/biometrics-view.js
new file mode 100644
index 0000000..e1787ed
--- /dev/null
+++ b/js/biometrics-view.js
@@ -0,0 +1,380 @@
+// biometrics-view.js — category view + detail modal for weight/BP/pulse
+
+import { state } from './state.js';
+import { escapeHTML, showNotification } from './utils.js';
+import { saveImportedData } from './data.js';
+import { createLineChart } from './charts.js';
+
+function ensureBio() {
+ if (!state.importedData) state.importedData = { entries: [] };
+ if (!state.importedData.biometrics) state.importedData.biometrics = { weight: [], bp: [], pulse: [] };
+ const b = state.importedData.biometrics;
+ if (!Array.isArray(b.weight)) b.weight = [];
+ if (!Array.isArray(b.bp)) b.bp = [];
+ if (!Array.isArray(b.pulse)) b.pulse = [];
+ return b;
+}
+
+function sortByDateAsc(arr) {
+ return [...arr].sort((a, b) => a.date.localeCompare(b.date));
+}
+
+function filterRowsByRange(rows, range = 'all') {
+ if (!rows.length || range === 'all') return rows;
+ const months = range === '1m' ? 1 : range === '3m' ? 3 : range === '9m' ? 9 : 0;
+ if (!months) return rows;
+ const end = new Date(rows[rows.length - 1].date + 'T00:00:00');
+ const start = new Date(end);
+ start.setMonth(start.getMonth() - months);
+ const min = start.toISOString().slice(0, 10);
+ return rows.filter(r => r.date >= min);
+}
+
+function markerFor(type, range = 'all') {
+ const b = ensureBio();
+ if (type === 'weight') {
+ const rows = filterRowsByRange(sortByDateAsc(b.weight || []), range);
+ const values = rows.map(e => {
+ if (state.unitSystem === 'US') return e.unit === 'lbs' ? e.value : e.value * 2.205;
+ return e.unit === 'lbs' ? e.value / 2.205 : e.value;
+ });
+ return {
+ type,
+ name: 'Weight',
+ unit: state.unitSystem === 'US' ? 'lbs' : 'kg',
+ values,
+ chartDates: rows.map(e => e.date),
+ dateLabels: rows.map(e => new Date(e.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })),
+ refMin: null,
+ refMax: null,
+ };
+ }
+ if (type === 'bp') {
+ const rows = filterRowsByRange(sortByDateAsc(b.bp || []), range);
+ return {
+ type,
+ name: 'Blood Pressure',
+ unit: 'mmHg',
+ values: rows.map(e => e.sys),
+ chartDates: rows.map(e => e.date),
+ dateLabels: rows.map(e => new Date(e.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })),
+ refMin: 90,
+ refMax: 120,
+ };
+ }
+ const rows = filterRowsByRange(sortByDateAsc(b.pulse || []), range);
+ return {
+ type,
+ name: 'Pulse',
+ unit: 'bpm',
+ values: rows.map(e => e.value),
+ chartDates: rows.map(e => e.date),
+ dateLabels: rows.map(e => new Date(e.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })),
+ refMin: 50,
+ refMax: 90,
+ };
+}
+
+function latestLabel(type) {
+ const b = ensureBio();
+ if (type === 'weight') {
+ if (!b.weight.length) return '—';
+ const x = [...b.weight].sort((a, c) => c.date.localeCompare(a.date))[0];
+ return `${x.value} ${x.unit}`;
+ }
+ if (type === 'bp') {
+ if (!b.bp.length) return '—';
+ const x = [...b.bp].sort((a, c) => c.date.localeCompare(a.date))[0];
+ return `${x.sys}/${x.dia}`;
+ }
+ if (!b.pulse.length) return '—';
+ const x = [...b.pulse].sort((a, c) => c.date.localeCompare(a.date))[0];
+ return `${x.value}`;
+}
+
+function cardHtml(type, title, unit, latest) {
+ return `
`;
+}
+
+export function renderBiometricsCategoryView() {
+ const b = ensureBio();
+ const hasAny = b.weight.length || b.bp.length || b.pulse.length;
+ if (!hasAny) {
+ return `🩺
+
No biometrics data yet
Import biometrics or sync from Withings in Settings to see trends here.
`;
+ }
+
+ const range = window._bioCategoryRange || 'all';
+ const w = markerFor('weight', range);
+ const bp = markerFor('bp', range);
+ const p = markerFor('pulse', range);
+
+ const rangeBtns = `
+
+
+
+
+
`;
+
+ const html = `${rangeBtns}
+ ${cardHtml('weight', 'Weight', state.unitSystem === 'US' ? 'lbs' : 'kg', latestLabel('weight'))}
+ ${cardHtml('bp', 'Blood Pressure', 'mmHg', latestLabel('bp'))}
+ ${cardHtml('pulse', 'Pulse', 'bpm', latestLabel('pulse'))}
+
`;
+
+ setTimeout(() => {
+ if (w.values.length) createLineChart('bio-weight', w, w.dateLabels, w.chartDates, null);
+ if (bp.values.length) createLineChart('bio-bp', bp, bp.dateLabels, bp.chartDates, null);
+ if (p.values.length) createLineChart('bio-pulse', p, p.dateLabels, p.chartDates, null);
+ }, 0);
+
+ return html;
+}
+
+export function setBiometricsCategoryRange(range) {
+ window._bioCategoryRange = range;
+ window.navigate?.('biometrics');
+}
+
+export function showBiometricDetailModal(type, range = (window._bioModalRange || 'all')) {
+ window._bioModalRange = range;
+ renderBioModal(type, range);
+}
+
+function renderBioModal(type, range = 'all') {
+ const marker = markerFor(type, range);
+ const modal = document.getElementById('detail-modal');
+ const overlay = document.getElementById('modal-overlay');
+ if (!modal || !overlay) return;
+
+ const b = ensureBio();
+ const rows = sortByDateAsc(type === 'weight' ? b.weight : type === 'bp' ? b.bp : b.pulse);
+ const today = new Date().toISOString().slice(0, 10);
+
+ let rowsHtml = '';
+ for (const r of [...rows].reverse()) {
+ const dateLabel = new Date(r.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ const val = type === 'weight' ? `${r.value} ${r.unit}` : type === 'bp' ? `${r.sys}/${r.dia} mmHg` : `${r.value} bpm`;
+ rowsHtml += `
+
+
${dateLabel}
+
${escapeHTML(val)}
+
${escapeHTML(r.source || 'manual')}
+
`;
+ }
+
+ let addFields = '';
+ if (type === 'weight') {
+ addFields = `
+
+
+
+
`;
+ } else if (type === 'bp') {
+ addFields = ``;
+ } else {
+ addFields = ``;
+ }
+
+ const rangeBtns = `
+
+
+
+
+
`;
+
+ modal.innerHTML = `
+ ${escapeHTML(marker.name)}
+ ${escapeHTML(marker.unit)}
+ ${rangeBtns}
+
+ ${rowsHtml || '
No entries yet
'}
+ ${addFields}
+
+
+
+
`;
+
+ overlay.classList.add('show');
+ setTimeout(() => {
+ createLineChart('modal', marker, marker.dateLabels, marker.chartDates, null);
+ }, 50);
+}
+
+export function saveBiometricEntry(type) {
+ const b = ensureBio();
+ const date = document.getElementById('bio-date')?.value;
+ if (!date) { showNotification('Enter date', 'error'); return; }
+
+ if (type === 'weight') {
+ const v = parseFloat(document.getElementById('bio-val')?.value);
+ const unit = document.getElementById('bio-unit')?.value || 'kg';
+ if (!v || v <= 0) { showNotification('Enter valid weight', 'error'); return; }
+ b.weight = b.weight.filter(e => e.date !== date || e.source === 'withings');
+ b.weight.push({ date, value: v, unit, source: 'manual' });
+ b.weight.sort((a, c) => a.date.localeCompare(c.date));
+ } else if (type === 'bp') {
+ const sys = parseInt(document.getElementById('bio-sys')?.value, 10);
+ const dia = parseInt(document.getElementById('bio-dia')?.value, 10);
+ if (!sys || !dia || sys <= 0 || dia <= 0) { showNotification('Enter valid BP values', 'error'); return; }
+ b.bp = b.bp.filter(e => e.date !== date);
+ b.bp.push({ date, sys, dia, source: 'manual' });
+ b.bp.sort((a, c) => a.date.localeCompare(c.date));
+ } else {
+ const v = parseInt(document.getElementById('bio-val')?.value, 10);
+ if (!v || v <= 0) { showNotification('Enter valid pulse', 'error'); return; }
+ b.pulse = b.pulse.filter(e => e.date !== date);
+ b.pulse.push({ date, value: v, source: 'manual' });
+ b.pulse.sort((a, c) => a.date.localeCompare(c.date));
+ }
+
+ if (window.recordChange) window.recordChange('biometrics');
+ saveImportedData();
+ window.buildSidebar?.();
+ showBiometricDetailModal(type, window._bioModalRange || 'all');
+}
+
+export function deleteBiometricEntry(type, date) {
+ const b = ensureBio();
+ if (type === 'weight') b.weight = b.weight.filter(e => e.date !== date || e.source === 'withings');
+ else if (type === 'bp') b.bp = b.bp.filter(e => e.date !== date);
+ else b.pulse = b.pulse.filter(e => e.date !== date);
+ if (window.recordChange) window.recordChange('biometrics');
+ saveImportedData();
+ window.buildSidebar?.();
+ showBiometricDetailModal(type, window._bioModalRange || 'all');
+}
+
+export function editBiometricEntry(type, date, event) {
+ const b = ensureBio();
+ const el = event?.target?.closest('.mv-value');
+ if (!el || el.querySelector('input')) return;
+
+ const saveAll = () => {
+ if (window.recordChange) window.recordChange('biometrics');
+ saveImportedData();
+ window.buildSidebar?.();
+ showBiometricDetailModal(type, window._bioModalRange || 'all');
+ };
+
+ if (type === 'weight') {
+ const row = (b.weight || []).find(e => e.date === date);
+ if (!row) return;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.step = '0.1';
+ input.value = row.value;
+ input.className = 'ref-edit-input';
+ input.style.cssText = 'width:100px;text-align:center;font-size:inherit';
+ el.textContent = '';
+ el.appendChild(input);
+ input.focus();
+ input.select();
+ const save = () => {
+ const v = parseFloat(input.value);
+ if (!v || v <= 0) { showBiometricDetailModal(type, window._bioModalRange || 'all'); return; }
+ row.value = v;
+ row.source = 'manual';
+ saveAll();
+ };
+ input.addEventListener('blur', save);
+ input.addEventListener('keydown', e => {
+ if (e.key === 'Enter') input.blur();
+ else if (e.key === 'Escape') showBiometricDetailModal(type, window._bioModalRange || 'all');
+ });
+ return;
+ }
+
+ if (type === 'bp') {
+ const row = (b.bp || []).find(e => e.date === date);
+ if (!row) return;
+ const wrap = document.createElement('div');
+ wrap.style.cssText = 'display:flex;gap:6px;align-items:center;justify-content:center';
+ const sys = document.createElement('input');
+ sys.type = 'number';
+ sys.value = row.sys;
+ sys.className = 'ref-edit-input';
+ sys.style.cssText = 'width:58px;text-align:center;font-size:inherit';
+ const sep = document.createElement('span');
+ sep.textContent = '/';
+ const dia = document.createElement('input');
+ dia.type = 'number';
+ dia.value = row.dia;
+ dia.className = 'ref-edit-input';
+ dia.style.cssText = 'width:58px;text-align:center;font-size:inherit';
+ wrap.appendChild(sys); wrap.appendChild(sep); wrap.appendChild(dia);
+ el.textContent = '';
+ el.appendChild(wrap);
+ sys.focus();
+ sys.select();
+
+ let done = false;
+ const finish = (cancel = false) => {
+ if (done) return;
+ done = true;
+ if (cancel) { showBiometricDetailModal(type, window._bioModalRange || 'all'); return; }
+ const s = parseInt(sys.value, 10);
+ const d = parseInt(dia.value, 10);
+ if (!s || !d || s <= 0 || d <= 0) { showBiometricDetailModal(type, window._bioModalRange || 'all'); return; }
+ row.sys = s;
+ row.dia = d;
+ row.source = 'manual';
+ saveAll();
+ };
+
+ sys.addEventListener('keydown', e => {
+ if (e.key === 'Enter') dia.focus();
+ else if (e.key === 'Escape') finish(true);
+ });
+ dia.addEventListener('keydown', e => {
+ if (e.key === 'Enter') finish(false);
+ else if (e.key === 'Escape') finish(true);
+ });
+ sys.addEventListener('blur', () => setTimeout(() => {
+ if (!el.contains(document.activeElement)) finish(false);
+ }, 0));
+ dia.addEventListener('blur', () => setTimeout(() => {
+ if (!el.contains(document.activeElement)) finish(false);
+ }, 0));
+ return;
+ }
+
+ const row = (b.pulse || []).find(e => e.date === date);
+ if (!row) return;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.value = row.value;
+ input.className = 'ref-edit-input';
+ input.style.cssText = 'width:100px;text-align:center;font-size:inherit';
+ el.textContent = '';
+ el.appendChild(input);
+ input.focus();
+ input.select();
+ const save = () => {
+ const v = parseInt(input.value, 10);
+ if (!v || v <= 0) { showBiometricDetailModal(type, window._bioModalRange || 'all'); return; }
+ row.value = v;
+ row.source = 'manual';
+ saveAll();
+ };
+ input.addEventListener('blur', save);
+ input.addEventListener('keydown', e => {
+ if (e.key === 'Enter') input.blur();
+ else if (e.key === 'Escape') showBiometricDetailModal(type, window._bioModalRange || 'all');
+ });
+}
+
+Object.assign(window, { showBiometricDetailModal, saveBiometricEntry, deleteBiometricEntry, editBiometricEntry, setBiometricsCategoryRange });
diff --git a/js/main.js b/js/main.js
index df35d43..34ed09b 100644
--- a/js/main.js
+++ b/js/main.js
@@ -26,6 +26,7 @@ import './export.js';
import './chat.js';
import './image-utils.js';
import './settings.js';
+import { maybeHandleWithingsOAuthCallback } from './withings-weight.js';
import './glossary.js';
import './feedback.js';
import './tour.js';
@@ -47,10 +48,16 @@ document.addEventListener("DOMContentLoaded", async () => {
// Initialize folder backup (restore persisted handle, check permission)
await initFolderBackup();
- // Handle OpenRouter OAuth callback (?code=...)
+ // Handle OAuth callbacks
const urlParams = new URLSearchParams(window.location.search);
const oauthCode = urlParams.get('code');
- if (oauthCode) {
+ const oauthState = urlParams.get('state');
+
+ // Withings callback takes precedence when state key matches saved withings OAuth state
+ const handledWithings = await maybeHandleWithingsOAuthCallback();
+
+ // OpenRouter callback
+ if (!handledWithings && oauthCode && !oauthState) {
history.replaceState(null, '', window.location.pathname);
try {
const key = await exchangeOpenRouterCode(oauthCode);
diff --git a/js/nav.js b/js/nav.js
index bcb0f34..691798f 100644
--- a/js/nav.js
+++ b/js/nav.js
@@ -5,6 +5,51 @@ import { escapeHTML, escapeAttr, hashString } from './utils.js';
import { getActiveData, countFlagged, filterDatesByRange } from './data.js';
import { getProfiles } from './profile.js';
+function _scrollElementIntoView(el) {
+ if (!el) return;
+ // Prefer native behavior first
+ try { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {}
+
+ // Fallback for container-scrolling layouts
+ let parent = el.parentElement;
+ while (parent) {
+ const style = getComputedStyle(parent);
+ const canScroll = /(auto|scroll)/.test(style.overflowY || '');
+ if (canScroll && parent.scrollHeight > parent.clientHeight) {
+ const top = Math.max(0, el.offsetTop - 60);
+ parent.scrollTo({ top, behavior: 'smooth' });
+ return;
+ }
+ parent = parent.parentElement;
+ }
+
+ // Window fallback
+ const y = Math.max(0, el.getBoundingClientRect().top + window.scrollY - 60);
+ window.scrollTo({ top: y, behavior: 'smooth' });
+}
+
+function scrollDashboardSection(sectionId, afterScroll) {
+ const tryScroll = () => {
+ const el = document.getElementById(sectionId);
+ if (!el) return false;
+ _scrollElementIntoView(el);
+ if (typeof afterScroll === 'function') afterScroll(el);
+ return true;
+ };
+
+ if (tryScroll()) return;
+ requestAnimationFrame(() => {
+ if (tryScroll()) return;
+ setTimeout(() => { if (tryScroll()) return; }, 120);
+ setTimeout(() => { tryScroll(); }, 320);
+ });
+}
+
+function openDashboardSection(sectionId, afterScroll) {
+ window.navigate('dashboard');
+ scrollDashboardSection(sectionId, afterScroll);
+}
+
function _buildNavItem(key, cat) {
const markers = Object.values(cat.markers).filter(m => !m.hidden);
const withData = markers.filter(m => m.values && m.values.some(v => v !== null)).length;
@@ -42,7 +87,7 @@ export function buildSidebar(data) {
const gParts = [];
if (genetics.snps && Object.keys(genetics.snps).length > 0) gParts.push(Object.keys(genetics.snps).length);
if (genetics.mtdna) gParts.push(genetics.mtdna.haplogroup);
- html += `
+ html += `
\uD83E\uDDEC Genetics ${gParts.join(' ')}
`;
}
@@ -63,6 +108,22 @@ export function buildSidebar(data) {
// Render blood work categories
html += ``;
+
+ // Biometrics appears as its own category item (only when data exists)
+ const biometrics = state.importedData?.biometrics;
+ const hasBiometricsData = !!(biometrics && (
+ (Array.isArray(biometrics.weight) && biometrics.weight.length > 0) ||
+ (Array.isArray(biometrics.bp) && biometrics.bp.length > 0) ||
+ (Array.isArray(biometrics.pulse) && biometrics.pulse.length > 0)
+ ));
+ if (hasBiometricsData) {
+ const bioCount = (Array.isArray(biometrics.weight) ? biometrics.weight.length : 0)
+ + (Array.isArray(biometrics.bp) ? biometrics.bp.length : 0)
+ + (Array.isArray(biometrics.pulse) ? biometrics.pulse.length : 0);
+ html += `
+ \uD83E\uDE7A Biometrics ${bioCount}
`;
+ }
+
for (const item of bloodWork) html += item.html;
// Render specialty groups
@@ -202,4 +263,4 @@ export function closeMobileSidebar() {
document.getElementById('sidebar-backdrop').classList.remove('show');
}
-Object.assign(window, { buildSidebar, filterSidebar, toggleNavGroup, toggleGroupAIContext, renderProfileDropdown, renderProfileButton, getAvatarColor, toggleMobileSidebar, closeMobileSidebar });
+Object.assign(window, { buildSidebar, filterSidebar, toggleNavGroup, toggleGroupAIContext, renderProfileDropdown, renderProfileButton, getAvatarColor, toggleMobileSidebar, closeMobileSidebar, scrollDashboardSection, openDashboardSection });
diff --git a/js/settings.js b/js/settings.js
index be25998..85aac4b 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -9,6 +9,7 @@ import { getOllamaConfig, checkOllama, checkOpenAICompatible, saveOllamaConfig,
import { detectHardware, assessModel, assessFitness, getBestModel, getUpgradeSuggestion, saveHardwareOverride, getHardwareOverride } from './hardware.js';
import { renderEncryptionSection, renderBackupSection, loadBackupSnapshots, updateKeyCache } from './crypto.js';
import { isSyncEnabled, enableSync, disableSync, getMnemonic, restoreFromMnemonic, getSyncRelay, setSyncRelay, checkRelayConnection, isMessengerEnabled, getMessengerToken, generateMessengerToken, revokeMessengerToken, pushContextToGateway } from './sync.js';
+import { getWithingsConfig, saveWithingsConfig, getWithingsAuthUrl, syncWithingsWeight, getWithingsLastSync, removeWithingsWeightData } from './withings-weight.js';
// ═══════════════════════════════════════════════
@@ -169,6 +170,12 @@ export function openSettingsModal(tab) {
${renderBackupSection()}
+ Withings Integration
+
+
+ ${renderWithingsSection()}
+
+
Imported Data
@@ -1628,6 +1635,10 @@ Object.assign(window, {
refreshModelAdvisor,
applyHardwareOverride: applyHardwareOverrideFn,
clearHardwareOverride: clearHardwareOverrideFn,
+ saveWithingsCredentials,
+ authorizeWithings,
+ triggerWithingsWeightSync,
+ deleteWithingsWeightData,
});
function refreshModelAdvisor() {
@@ -1652,3 +1663,103 @@ function clearHardwareOverrideFn() {
const details = window._lastOllamaModelDetails || [];
if (details.length) renderModelAdvisor(details, document.getElementById('local-ai-model-select'), !!window._lastIsOllamaServer);
}
+
+// ═══════════════════════════════════════════════
+// WITHINGS (weight-only)
+// ═══════════════════════════════════════════════
+
+function formatLastSync(ts) {
+ if (!ts) return 'Never';
+ try { return new Date(ts).toLocaleString(); } catch { return ts; }
+}
+
+function renderWithingsSection() {
+ const cfg = getWithingsConfig() || {};
+ const connected = !!cfg.accessToken;
+ const lastSync = getWithingsLastSync();
+
+ return `
+
+
Status
+
${connected ? '● Connected' : '● Not connected'}
+
+
Last sync: ${formatLastSync(lastSync)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Imports only weight into existing biometrics structure (kg/lbs converted to current unit system). No other Withings measurements are imported.
+
+
`;
+}
+
+function refreshWithingsSection() {
+ const el = document.getElementById('withings-section');
+ if (el) el.innerHTML = renderWithingsSection();
+}
+
+function saveWithingsCredentials() {
+ const clientId = document.getElementById('withings-client-id')?.value?.trim();
+ const clientSecret = document.getElementById('withings-client-secret')?.value?.trim();
+ const redirectUri = document.getElementById('withings-redirect-uri')?.value?.trim();
+
+ if (!clientId || !clientSecret || !redirectUri) {
+ showNotification('Please fill all Withings credential fields', 'error');
+ return;
+ }
+
+ const prev = getWithingsConfig() || {};
+ saveWithingsConfig({ ...prev, clientId, clientSecret, redirectUri });
+ showNotification('Withings credentials saved', 'success');
+ refreshWithingsSection();
+}
+
+function authorizeWithings() {
+ const cfg = getWithingsConfig();
+ if (!cfg?.clientId || !cfg?.clientSecret || !cfg?.redirectUri) {
+ showNotification('Save Withings credentials first', 'error');
+ return;
+ }
+ const url = getWithingsAuthUrl(cfg);
+ window.location.href = url;
+}
+
+async function triggerWithingsWeightSync() {
+ try {
+ const r = await syncWithingsWeight();
+ showNotification(`Withings sync complete (${r.count} day${r.count === 1 ? '' : 's'} updated)`, 'success');
+ if (window.showDashboard) window.showDashboard();
+ refreshDataEntriesSection();
+ refreshWithingsSection();
+ } catch (e) {
+ showNotification(`Withings sync failed: ${e.message}`, 'error');
+ }
+}
+
+async function deleteWithingsWeightData() {
+ const ok = confirm('Delete all Withings-imported weight rows and disconnect Withings?');
+ if (!ok) return;
+ try {
+ const r = await removeWithingsWeightData();
+ showNotification(`Removed ${r.removed} Withings weight entr${r.removed === 1 ? 'y' : 'ies'}`, 'success');
+ if (window.showDashboard) window.showDashboard();
+ refreshDataEntriesSection();
+ refreshWithingsSection();
+ } catch (e) {
+ showNotification(`Delete failed: ${e.message}`, 'error');
+ }
+}
diff --git a/js/views.js b/js/views.js
index a1db0f9..422e3eb 100644
--- a/js/views.js
+++ b/js/views.js
@@ -7,6 +7,7 @@ import { getChartColors } from './theme.js';
import { getActiveData, filterDatesByRange, destroyAllCharts, getEffectiveRange, getEffectiveRangeForDate, getLatestValueIndex, getAllFlaggedMarkers, statusIcon, detectTrendAlerts, getKeyTrendMarkers, getFocusCardFingerprint, saveImportedData, recalculateHOMAIR, updateHeaderDates, renderDateRangeFilter, renderChartLayersDropdown, convertDisplayToSI } from './data.js';
import { profileStorageKey } from './profile.js';
import { createLineChart, getMarkerDescription, getNotesForChart, getSupplementsForChart, refBandPlugin, noteAnnotationPlugin, supplementBarPlugin, phaseBandPlugin } from './charts.js';
+import { renderBiometricsCategoryView } from './biometrics-view.js';
import { renderSupplementsSection } from './supplements.js';
import { renderGeneticsSection } from './dna.js';
import { renderMenstrualCycleSection } from './cycle.js';
@@ -32,6 +33,7 @@ export function navigate(category, data) {
if (category === "dashboard") showDashboard(data);
else if (category === "correlations") showCorrelations(data);
else if (category === "compare") showCompare(data);
+ else if (category === "biometrics") showBiometrics(data);
else showCategory(category, data);
}
@@ -42,7 +44,12 @@ export function navigate(category, data) {
export function showDashboard(data) {
if (!data) data = getActiveData();
const main = document.getElementById("main-content");
- const hasData = data.dates.length > 0 || Object.values(data.categories).some(c => c.singlePoint && c.singleDate);
+ const hasBiometricsData = !!(state.importedData?.biometrics && (
+ (Array.isArray(state.importedData.biometrics.weight) && state.importedData.biometrics.weight.length > 0) ||
+ (Array.isArray(state.importedData.biometrics.bp) && state.importedData.biometrics.bp.length > 0) ||
+ (Array.isArray(state.importedData.biometrics.pulse) && state.importedData.biometrics.pulse.length > 0)
+ ));
+ const hasData = data.dates.length > 0 || Object.values(data.categories).some(c => c.singlePoint && c.singleDate) || hasBiometricsData;
// Show/hide import FAB based on whether dashboard has data
const importFab = document.getElementById('import-fab');
@@ -507,6 +514,24 @@ export function dismissOnboarding() {
// CATEGORY VIEWS
// ═══════════════════════════════════════════════
+export function showBiometrics(preData) {
+ const rawData = preData || getActiveData();
+ const data = filterDatesByRange(rawData);
+ const main = document.getElementById("main-content");
+ const b = state.importedData?.biometrics || {};
+ const weightCount = Array.isArray(b.weight) ? b.weight.length : 0;
+ const bpCount = Array.isArray(b.bp) ? b.bp.length : 0;
+ const pulseCount = Array.isArray(b.pulse) ? b.pulse.length : 0;
+ const total = weightCount + bpCount + pulseCount;
+
+ let html = ``;
+
+ html += renderBiometricsCategoryView();
+
+ main.innerHTML = html;
+}
+
export function showCategory(categoryKey, preData) {
const rawData = preData || getActiveData();
const data = filterDatesByRange(rawData);
diff --git a/js/withings-weight.js b/js/withings-weight.js
new file mode 100644
index 0000000..28df520
--- /dev/null
+++ b/js/withings-weight.js
@@ -0,0 +1,228 @@
+// withings-weight.js — Withings OAuth + weight-only import into biometrics
+
+import { state } from './state.js';
+import { showNotification } from './utils.js';
+import { saveImportedData } from './data.js';
+
+const WITHINGS_ENDPOINTS = {
+ AUTHORIZE: 'https://account.withings.com/oauth2_user/authorize2',
+ TOKEN: 'https://wbsapi.withings.net/v2/oauth2',
+ MEASURE_GETMEAS: 'https://wbsapi.withings.net/measure'
+};
+
+const STORAGE_KEY = (profileId) => `withings_${profileId}`;
+const LAST_SYNC_KEY = (profileId) => `withings_last_sync_${profileId}`;
+const OAUTH_STATE_KEY = 'withings_oauth_state';
+
+function profileId() {
+ return state.currentProfile || 'default';
+}
+
+export function getWithingsConfig() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY(profileId()));
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveWithingsConfig(cfg) {
+ localStorage.setItem(STORAGE_KEY(profileId()), JSON.stringify(cfg));
+}
+
+export function clearWithingsConfig() {
+ localStorage.removeItem(STORAGE_KEY(profileId()));
+ localStorage.removeItem(LAST_SYNC_KEY(profileId()));
+}
+
+export function getWithingsLastSync() {
+ return localStorage.getItem(LAST_SYNC_KEY(profileId())) || null;
+}
+
+function setWithingsLastSync(tsIso) {
+ localStorage.setItem(LAST_SYNC_KEY(profileId()), tsIso);
+}
+
+export function getWithingsAuthUrl(config) {
+ const st = Math.random().toString(36).slice(2) + Date.now().toString(36);
+ sessionStorage.setItem(OAUTH_STATE_KEY, st);
+ const redirectUri = config.redirectUri || `${window.location.origin}${window.location.pathname}`;
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.clientId,
+ scope: 'user.metrics',
+ redirect_uri: redirectUri,
+ state: st,
+ });
+ return `${WITHINGS_ENDPOINTS.AUTHORIZE}?${params.toString()}`;
+}
+
+async function exchangeCodeForToken(code, config) {
+ const redirectUri = config.redirectUri || `${window.location.origin}${window.location.pathname}`;
+ const body = new URLSearchParams({
+ action: 'requesttoken',
+ grant_type: 'authorization_code',
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ code,
+ redirect_uri: redirectUri
+ });
+
+ const resp = await fetch(WITHINGS_ENDPOINTS.TOKEN, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString()
+ });
+ const data = await resp.json();
+ if (data.status !== 0) throw new Error(`Withings token exchange failed (${data.status})`);
+
+ const expiresAt = Date.now() + ((data.body?.expires_in || 0) * 1000);
+ saveWithingsConfig({
+ ...config,
+ accessToken: data.body.access_token,
+ refreshToken: data.body.refresh_token,
+ tokenExpires: expiresAt,
+ userId: data.body.userid
+ });
+}
+
+async function refreshToken(config) {
+ const body = new URLSearchParams({
+ action: 'requesttoken',
+ grant_type: 'refresh_token',
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ refresh_token: config.refreshToken
+ });
+
+ const resp = await fetch(WITHINGS_ENDPOINTS.TOKEN, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString()
+ });
+ const data = await resp.json();
+ if (data.status !== 0) throw new Error(`Withings token refresh failed (${data.status})`);
+
+ const expiresAt = Date.now() + ((data.body?.expires_in || 0) * 1000);
+ const updated = {
+ ...config,
+ accessToken: data.body.access_token,
+ refreshToken: data.body.refresh_token,
+ tokenExpires: expiresAt,
+ };
+ saveWithingsConfig(updated);
+ return updated.accessToken;
+}
+
+async function getValidAccessToken() {
+ const cfg = getWithingsConfig();
+ if (!cfg?.accessToken) throw new Error('Withings is not connected');
+
+ const needsRefresh = !cfg.tokenExpires || (Date.now() + 5 * 60 * 1000) >= cfg.tokenExpires;
+ if (needsRefresh && cfg.refreshToken) return refreshToken(cfg);
+ return cfg.accessToken;
+}
+
+export async function maybeHandleWithingsOAuthCallback() {
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ const stateParam = params.get('state');
+ const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
+ const cfg = getWithingsConfig();
+
+ if (!code || !expectedState || stateParam !== expectedState || !cfg?.clientId || !cfg?.clientSecret) return false;
+
+ try {
+ await exchangeCodeForToken(code, cfg);
+ showNotification('Withings connected', 'success');
+ } catch (e) {
+ showNotification(`Withings authorization failed: ${e.message}`, 'error');
+ } finally {
+ sessionStorage.removeItem(OAUTH_STATE_KEY);
+ const clean = new URL(window.location.href);
+ clean.searchParams.delete('code');
+ clean.searchParams.delete('state');
+ window.history.replaceState({}, '', clean.toString());
+ }
+
+ return true;
+}
+
+function withingsToWeightKg(meas) {
+ if (meas?.type !== 1) return null; // weight
+ if (typeof meas.value !== 'number' || typeof meas.unit !== 'number') return null;
+ const v = meas.value * Math.pow(10, meas.unit);
+ return Number.isFinite(v) ? v : null;
+}
+
+function toDisplayWeight(kg) {
+ if (state.unitSystem === 'US') return { value: +(kg * 2.205).toFixed(1), unit: 'lbs' };
+ return { value: +kg.toFixed(1), unit: 'kg' };
+}
+
+export async function syncWithingsWeight() {
+ const token = await getValidAccessToken();
+ const params = new URLSearchParams({ action: 'getmeas', access_token: token });
+
+ const lastSync = getWithingsLastSync();
+ if (lastSync) {
+ const ts = Math.floor(new Date(lastSync).getTime() / 1000);
+ if (Number.isFinite(ts) && ts > 0) params.set('lastupdate', String(ts));
+ }
+
+ const resp = await fetch(`${WITHINGS_ENDPOINTS.MEASURE_GETMEAS}?${params.toString()}`);
+ const data = await resp.json();
+ if (data.status !== 0) throw new Error(`Withings fetch failed (${data.status})`);
+
+ const groups = data.body?.measuregrps || [];
+ if (!state.importedData) state.importedData = { entries: [] };
+ if (!state.importedData.biometrics) state.importedData.biometrics = { weight: [], bp: [], pulse: [] };
+ if (!Array.isArray(state.importedData.biometrics.weight)) state.importedData.biometrics.weight = [];
+
+ // Map each day to latest measurement in that day (no cross-day copying)
+ const latestByDate = new Map();
+ for (const grp of groups) {
+ if (!Array.isArray(grp?.measures) || typeof grp?.date !== 'number') continue;
+ const date = new Date(grp.date * 1000).toISOString().slice(0, 10);
+ for (const meas of grp.measures) {
+ const kg = withingsToWeightKg(meas);
+ if (kg == null) continue;
+ const prev = latestByDate.get(date);
+ if (!prev || grp.date >= prev.ts) latestByDate.set(date, { ts: grp.date, kg });
+ }
+ }
+
+ let addedOrUpdated = 0;
+ const arr = state.importedData.biometrics.weight;
+ for (const [date, { kg }] of latestByDate.entries()) {
+ const disp = toDisplayWeight(kg);
+ const existingIdx = arr.findIndex(e => e.date === date && e.source === 'withings');
+ const record = { date, value: disp.value, unit: disp.unit, source: 'withings' };
+ if (existingIdx >= 0) arr[existingIdx] = record;
+ else {
+ // keep one entry per day if existing manual value is present, preserve manual by only replacing withings-sourced rows
+ arr.push(record);
+ }
+ addedOrUpdated += 1;
+ }
+
+ arr.sort((a, b) => a.date.localeCompare(b.date));
+ setWithingsLastSync(new Date().toISOString());
+ await saveImportedData();
+
+ return { success: true, count: addedOrUpdated };
+}
+
+export async function removeWithingsWeightData() {
+ if (!state.importedData?.biometrics?.weight) {
+ clearWithingsConfig();
+ return { removed: 0 };
+ }
+ const before = state.importedData.biometrics.weight.length;
+ state.importedData.biometrics.weight = state.importedData.biometrics.weight.filter(e => e.source !== 'withings');
+ const removed = before - state.importedData.biometrics.weight.length;
+ clearWithingsConfig();
+ await saveImportedData();
+ return { removed };
+}