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 `
+
+
${title}
+
${unit}
+
${escapeHTML(String(latest))}
+
+
`; +} + +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 += ``; + } + + let addFields = ''; + if (type === 'weight') { + addFields = `
+
+
+
+
`; + } else if (type === 'bp') { + addFields = `
+
+
+
+
`; + } else { + addFields = `
+
+
+
`; + } + + const rangeBtns = `
+ + + + +
`; + + modal.innerHTML = ` +

${escapeHTML(marker.name)}

+ + ${rangeBtns} + + + ${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 += ` +
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 = `

🩺 Biometrics

+

${total} measurement${total === 1 ? '' : 's'} tracked (weight ${weightCount}, blood pressure ${bpCount}, pulse ${pulseCount})

`; + + 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 }; +}