diff --git a/app.json b/app.json index 48e6d18..9715c01 100644 --- a/app.json +++ b/app.json @@ -15,6 +15,19 @@ }, "android": { "package": "xyz.rehbock.health", + "permissions": [ + "android.permission.health.READ_HEART_RATE", + "android.permission.health.READ_RESTING_HEART_RATE", + "android.permission.health.READ_HEART_RATE_VARIABILITY", + "android.permission.health.READ_OXYGEN_SATURATION", + "android.permission.health.READ_SLEEP", + "android.permission.health.READ_STEPS", + "android.permission.health.READ_RESPIRATORY_RATE", + "android.permission.health.READ_DISTANCE", + "android.permission.health.READ_ACTIVE_CALORIES_BURNED", + "android.permission.health.READ_TOTAL_CALORIES_BURNED", + "android.permission.health.READ_FLOORS_CLIMBED" + ], "adaptiveIcon": { "backgroundColor": "#0d0d0f", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -30,6 +43,15 @@ "plugins": [ "expo-router", "expo-secure-store", + "react-native-health-connect", + [ + "expo-build-properties", + { + "android": { + "minSdkVersion": 26 + } + } + ], "./plugins/with-release-signing.js", [ "expo-splash-screen", diff --git a/package-lock.json b/package-lock.json index 23566d5..da177e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-dom": "19.2.3", "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", + "react-native-health-connect": "^4.1.3", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -36,6 +37,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "expo-build-properties": "^57.0.9", "typescript": "~6.0.3" } }, @@ -3811,6 +3813,21 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.9.tgz", + "integrity": "sha512-IX8Nz85yNDZyqBK7zbLEfn4ijMGaF6TmLjCY2KE0UKltUoV78DhgN6ksPZbDbPDvqLpU2fMY/5OD4CbYB7hHwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz", @@ -6575,6 +6592,36 @@ "react-native": "*" } }, + "node_modules/react-native-health-connect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/react-native-health-connect/-/react-native-health-connect-4.1.3.tgz", + "integrity": "sha512-osiWh4RD4tNHdHeggzOeGZFeEVaLQQr0+Yezq61WsPRvN1Yb6eA10rMfSU0+dqcfm6ZvIXSr2tYns+Yg1s37nw==", + "license": "MIT", + "workspaces": [ + "example", + "docs" + ], + "engines": { + "node": ">= 16.0.0" + }, + "funding": { + "url": "https://github.com/matinzd/react-native-health-connect?sponsor=1" + }, + "peerDependencies": { + "@expo/config-plugins": ">= 6.0.2", + "expo": "*", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@expo/config-plugins": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", diff --git a/package.json b/package.json index ce5d6fe..db8d6bc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-dom": "19.2.3", "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", + "react-native-health-connect": "^4.1.3", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "expo-build-properties": "^57.0.9", "typescript": "~6.0.3" }, "scripts": { diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 21a87bf..3289291 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -22,6 +22,7 @@ function Gate() { + diff --git a/src/app/fitbit.tsx b/src/app/fitbit.tsx new file mode 100644 index 0000000..f806b5d --- /dev/null +++ b/src/app/fitbit.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Platform, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; +import { DailyBars, DailyLine } from '../components/charts'; +import { ErrBox, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui'; +import { load } from '../lib/api'; +import { fmtDY, hm, num } from '../lib/format'; +import { healthConnectStatus, healthConnectSupported, syncFitbit } from '../lib/fitbit'; +import { C } from '../lib/theme'; +import { useLoad } from '../lib/use-load'; + +interface DayVal { day: string; value: number } +interface DayAvg { day: string; avg: number; n: number } + +interface Summary { + days: number; + steps: DayVal[]; + hr_daily: { day: string; min: number; max: number; avg: number; n: number }[]; + resting_hr: DayAvg[]; + hrv: DayAvg[]; + spo2: DayAvg[]; + respiratory_rate: DayAvg[]; + sleep: { + id: string; started_at: string; ended_at: string; total_seconds: number; + sleep_seconds: number; deep_seconds: number; rem_seconds: number; + light_seconds: number; awake_seconds: number; + }[]; + sample_counts: Record; +} + +export default function Fitbit() { + const { width } = useWindowDimensions(); + const chartW = Math.min(608, width - 32) - 28; + const { data, err, loading, refresh } = useLoad(() => load('fitbitSummary')); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(''); + const [hcStatus, setHcStatus] = useState(''); + const autoSynced = useRef(false); + + useEffect(() => { + healthConnectStatus().then(setHcStatus); + }, []); + + const runSync = async () => { + if (syncing) return; + setSyncing(true); + try { + const res = await syncFitbit(setSyncMsg); + setSyncMsg(res.message); + if (res.ok && (res.samples || res.daily || res.sleep)) refresh(); + } catch (e) { + setSyncMsg(`Sync failed: ${e instanceof Error ? e.message : e}`); + } finally { + setSyncing(false); + } + }; + + // auto-sync once per screen visit on Android + useEffect(() => { + if (hcStatus === 'available' && !autoSynced.current) { + autoSynced.current = true; + runSync(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hcStatus]); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const lastVal = (arr: DayAvg[]) => (arr.length ? arr[arr.length - 1] : null); + const stepsToday = data.steps.length ? data.steps[data.steps.length - 1] : null; + const rhr = lastVal(data.resting_hr); + const hrv = lastVal(data.hrv); + const spo2 = lastVal(data.spo2); + const lastSleep = data.sleep.length ? data.sleep[data.sleep.length - 1] : null; + const empty = !data.steps.length && !data.hr_daily.length && !data.sleep.length; + + body = ( + <> + {empty ? ( + + + No Fitbit data yet + + + Make sure the Google Health app is installed, the Fitbit Air is paired, and + Google Health is sharing to Health Connect (Profile → Privacy → Health Connect). + Then tap Sync below. + + + ) : ( + + + + + + {lastSleep ? ( + + ) : null} + + )} + + {data.steps.length ? ( + <> + + + + + + ) : null} + + {data.resting_hr.length > 1 ? ( + <> + + + + + + ) : null} + + {data.hrv.length > 1 ? ( + <> + + + + + + ) : null} + + {data.spo2.length > 1 ? ( + <> + + + + + + ) : null} + + {data.sleep.length ? ( + <> + + {[...data.sleep].reverse().slice(0, 14).map((sl) => ( + + + + {hm(sl.sleep_seconds)} + + {fmtDY(sl.started_at)} + + + {sl.deep_seconds ? `deep ${hm(sl.deep_seconds)} · ` : ''} + {sl.rem_seconds ? `REM ${hm(sl.rem_seconds)} · ` : ''} + {sl.light_seconds ? `light ${hm(sl.light_seconds)} · ` : ''} + awake {hm(sl.awake_seconds)} + + + ))} + + ) : null} + + + + {Object.entries(data.sample_counts).map(([k, v]) => ( + + {k.replace(/_/g, ' ')}: {num(v)} + + ))} + + + Fitbit Air → Google Health → Health Connect → health.rehbock.xyz. Data syncs from + this phone when you open this screen; the server is the system of record. + + + ); + } + + return ( + + {/* sync bar */} + + + + {Platform.OS === 'android' ? 'Health Connect' : 'Sync (Android phone only)'} + + + {healthConnectSupported() + ? hcStatus === 'available' + ? syncMsg || 'Ready' + : hcStatus === 'needs-update' + ? 'Health Connect needs an update' + : hcStatus || 'Checking…' + : 'Viewing server data — sync runs on the Android phone'} + + + {healthConnectSupported() && ( + + + {syncing ? 'Syncing…' : 'Sync'} + + + )} + + {body} + + ); +} + +const s = StyleSheet.create({ + syncBar: { + flexDirection: 'row', alignItems: 'center', gap: 12, + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, + borderRadius: 12, padding: 12, marginBottom: 14, + }, + syncBtn: { + backgroundColor: C.accent, borderRadius: 10, paddingVertical: 9, paddingHorizontal: 18, + }, + emptyBox: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, + borderRadius: 12, padding: 16, marginBottom: 4, + }, + row: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12, + paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, + }, + debugBox: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, + borderRadius: 12, padding: 12, + }, +}); diff --git a/src/app/index.tsx b/src/app/index.tsx index 7c24d1a..8a4bee4 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -17,7 +17,7 @@ export default function Today() { const { data, loading, refresh } = useLoad(() => loadAll({ meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi', - sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', + sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', fitbit: 'fitbitSummary', } as const), ); @@ -30,6 +30,11 @@ export default function Today() { sc: SleepCycleExport | null; dexa: DexaResponse | null; med: MeditationStats | null; + fitbit: { + steps: { day: string; value: number }[]; + resting_hr: { day: string; avg: number }[]; + hrv: { day: string; avg: number }[]; + } | null; } | undefined; @@ -89,6 +94,21 @@ export default function Today() { ); } else cards.push(); + { + const fb = r.fitbit; + const steps = fb?.steps?.length ? fb.steps[fb.steps.length - 1] : null; + const hrv = fb?.hrv?.length ? fb.hrv[fb.hrv.length - 1] : null; + cards.push( + , + ); + } + cards.push( , diff --git a/src/app/sleep.tsx b/src/app/sleep.tsx index a121a7a..53d2d83 100644 --- a/src/app/sleep.tsx +++ b/src/app/sleep.tsx @@ -13,15 +13,19 @@ export default function Sleep() { const { width } = useWindowDimensions(); const chartW = Math.min(608, width - 32) - 28; const { data, err, loading, refresh } = useLoad(() => - Promise.all([load('sleepApi'), load('sleepCycle')]), + Promise.all([ + load('sleepApi'), + load('sleepCycle'), + load<{ sleep: import('../lib/shape').HcSleepRow[] }>('fitbitSummary').catch(() => null), + ]), ); let body: React.ReactNode = null; if (loading && !data) body = ; else if (err) body = ; else if (data) { - const [api, sc] = data; - const nights = shapeSleep(api, sc); + const [api, sc, fb] = data; + const nights = shapeSleep(api, sc, fb?.sleep ?? []); const recent = nights.slice(-30); const last7 = nights.slice(-7); const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) => @@ -56,8 +60,9 @@ export default function Sleep() { /> - Sources: Eight Sleep pod (through Feb 2026, incl. HRV/HR) and Sleep Cycle app export - (through {sc.exported_at}). {num(nights.length)} nights total on record. + Sources: Fitbit Air (via Health Connect), Eight Sleep pod (through Feb 2026, incl. + HRV/HR), Sleep Cycle export (through {sc.exported_at}). {num(nights.length)} nights + total on record. ); diff --git a/src/components/charts.tsx b/src/components/charts.tsx index f970a0f..b3547b5 100644 --- a/src/components/charts.tsx +++ b/src/components/charts.tsx @@ -157,6 +157,108 @@ export function VolumeChart({ weeks, width }: { weeks: Week[]; width: number }) ); } +/* generic daily bars (steps, kcal…) — tap for value */ +export function DailyBars({ + data, width, unit, color = C.s1, fmt = (v: number) => kfmt(v), +}: { + data: { day: string; value: number }[]; + width: number; + unit: string; + color?: string; + fmt?: (v: number) => string; +}) { + const [sel, setSel] = useState(null); + if (!data.length) return null; + const H = 130, padL = 34, padB = 16, W = width; + const iw = W - padL, n = data.length, bw = Math.max(3, iw / n - 2); + const maxV = Math.max(...data.map((d) => d.value), 1); + const y = (v: number) => H - padB - (v / maxV) * (H - padB - 8); + const selD = sel != null ? data[sel] : null; + return ( + + + {[0, 0.5, 1].map((f) => ( + + + + {fmt(maxV * f)} + + + ))} + {data.map((d, i) => { + const x = padL + (i + 0.5) * (iw / n) - bw / 2; + return ( + setSel(i === sel ? null : i)}> + + + + ); + })} + {fmtD(data[0].day)} + + {fmtD(data[n - 1].day)} + + + {selD && ( + + )} + + ); +} + +/* daily line with dots (resting HR, HRV, SpO2…) — tap a point for value */ +export function DailyLine({ + data, width, unit, color = C.s3, decimals = 0, +}: { + data: { day: string; avg: number }[]; + width: number; + unit: string; + color?: string; + decimals?: number; +}) { + const [sel, setSel] = useState(null); + if (data.length < 2) return null; + const H = 110, padL = 34, padB = 16, padT = 8, W = width; + const iw = W - padL, n = data.length; + const vs = data.map((d) => d.avg); + const min = Math.min(...vs), max = Math.max(...vs); + const span = max - min || 1; + const x = (i: number) => padL + (i + 0.5) * (iw / n); + const y = (v: number) => H - padB - ((v - min) / span) * (H - padB - padT); + const path = data.map((d, i) => (i ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(d.avg).toFixed(1)).join(' '); + const selD = sel != null ? data[sel] : null; + return ( + + + {[min, max].map((v) => ( + + + + {v.toFixed(decimals)} + + + ))} + + {data.map((d, i) => ( + setSel(i === sel ? null : i)}> + + + + ))} + {fmtD(data[0].day)} + + {fmtD(data[n - 1].day)} + + + {selD && ( + + )} + + ); +} + /* biomarker sparkline */ export function Sparkline({ pts, flagged, width, diff --git a/src/lib/api.tsx b/src/lib/api.tsx index b204148..eed9fd3 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -14,8 +14,27 @@ export const EP = { medStats: `${BASE}/api/meditation/v1/stats`, medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`, traits: `${BASE}/ancestry-traits.json`, + fitbitSummary: `${BASE}/api/health/v1/fitbit/summary?days=30`, + fitbitLatest: `${BASE}/api/health/v1/fitbit/latest`, } as const; +export const FITBIT_SYNC_URL = `${BASE}/api/health/v1/fitbit/sync`; + +/* authenticated POST (fitbit sync) */ +export async function postJson(url: string, body: unknown): Promise { + const r = await fetch(url, { + method: 'POST', + headers: { Authorization: 'Basic ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.status === 401 || r.status === 403) { + onUnauthorized?.(); + throw new Error('unauthorized'); + } + if (!r.ok) throw new Error(`${url} → ${r.status}`); + return r.json(); +} + export type EpKey = keyof typeof EP; const AUTH_KEY = 'hd.auth'; diff --git a/src/lib/fitbit.ts b/src/lib/fitbit.ts new file mode 100644 index 0000000..d50d187 --- /dev/null +++ b/src/lib/fitbit.ts @@ -0,0 +1,189 @@ +/* Fitbit Air → Health Connect → health.rehbock.xyz sync. + Android-only: react-native-health-connect is loaded lazily so the module's + absence (iOS, Expo Go) never crashes the app. */ +import { Platform } from 'react-native'; +import { FITBIT_SYNC_URL, load, postJson } from './api'; + +export interface SyncResult { + ok: boolean; + samples: number; + daily: number; + sleep: number; + message: string; +} + +interface Sample { type: string; t: string; value: number } +interface DailyRow { type: string; day: string; value: number } +interface SleepRow { + id: string; started_at: string; ended_at: string; + total_seconds: number; sleep_seconds: number; + deep_seconds: number; rem_seconds: number; light_seconds: number; awake_seconds: number; +} + +const READ_PERMISSIONS = [ + 'HeartRate', 'RestingHeartRate', 'HeartRateVariabilityRmssd', 'OxygenSaturation', + 'SleepSession', 'Steps', 'RespiratoryRate', 'Distance', + 'ActiveCaloriesBurned', 'TotalCaloriesBurned', 'FloorsClimbed', +] as const; + +const FIRST_SYNC_LOOKBACK_DAYS = 60; +const OVERLAP_MS = 6 * 3600 * 1000; // re-read a little so late-arriving records land + +function hc() { + // require at call time — the native module only exists in the Android build + return require('react-native-health-connect'); +} + +export function healthConnectSupported(): boolean { + return Platform.OS === 'android'; +} + +export async function healthConnectStatus(): Promise<'available' | 'needs-update' | 'unavailable' | 'unsupported'> { + if (!healthConnectSupported()) return 'unsupported'; + try { + const { getSdkStatus, SdkAvailabilityStatus } = hc(); + const s = await getSdkStatus(); + if (s === SdkAvailabilityStatus.SDK_AVAILABLE) return 'available'; + if (s === SdkAvailabilityStatus.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) return 'needs-update'; + return 'unavailable'; + } catch { + return 'unavailable'; + } +} + +async function readAll(recordType: string, startTime: string, endTime: string): Promise { + const { readRecords } = hc(); + const out: any[] = []; + let pageToken: string | undefined; + do { + const res = await readRecords(recordType, { + timeRangeFilter: { operator: 'between', startTime, endTime }, + pageSize: 1000, + ...(pageToken ? { pageToken } : {}), + }); + out.push(...(res.records || [])); + pageToken = res.pageToken || undefined; + } while (pageToken && out.length < 200000); + return out; +} + +const localDay = (iso: string) => { + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +/* HC sleep stage constants (androidx.health.connect SleepStageType) */ +const STAGE = { AWAKE: 1, SLEEPING: 2, OUT_OF_BED: 3, LIGHT: 4, DEEP: 5, REM: 6, AWAKE_IN_BED: 7 }; + +export async function syncFitbit(onProgress?: (msg: string) => void): Promise { + if (!healthConnectSupported()) + return { ok: false, samples: 0, daily: 0, sleep: 0, message: 'Health Connect is Android-only' }; + + const { initialize, requestPermission } = hc(); + const say = (m: string) => onProgress?.(m); + + say('Initializing Health Connect…'); + const ok = await initialize(); + if (!ok) return { ok: false, samples: 0, daily: 0, sleep: 0, message: 'Health Connect init failed' }; + + say('Requesting permissions…'); + await requestPermission(READ_PERMISSIONS.map((r) => ({ accessType: 'read', recordType: r }))); + + say('Checking server watermark…'); + const latest = await load<{ + samples: Record; + daily: Record; + sleep: string | null; + }>('fitbitLatest'); + + const now = new Date(); + const endTime = now.toISOString(); + const fallback = new Date(now.getTime() - FIRST_SYNC_LOOKBACK_DAYS * 86400000).toISOString(); + const sinceFor = (watermark: string | null) => + watermark ? new Date(new Date(watermark).getTime() - OVERLAP_MS).toISOString() : fallback; + + const samples: Sample[] = []; + const daily = new Map(); + const sleep: SleepRow[] = []; + + // continuous heart rate (records carry arrays of samples) + say('Reading heart rate…'); + for (const rec of await readAll('HeartRate', sinceFor(latest.samples.heart_rate), endTime)) + for (const s of rec.samples || []) + samples.push({ type: 'heart_rate', t: s.time, value: s.beatsPerMinute }); + + say('Reading vitals…'); + for (const rec of await readAll('RestingHeartRate', sinceFor(latest.samples.resting_hr), endTime)) + samples.push({ type: 'resting_hr', t: rec.time, value: rec.beatsPerMinute }); + for (const rec of await readAll('HeartRateVariabilityRmssd', sinceFor(latest.samples.hrv_rmssd), endTime)) + samples.push({ type: 'hrv_rmssd', t: rec.time, value: rec.heartRateVariabilityMillis }); + for (const rec of await readAll('OxygenSaturation', sinceFor(latest.samples.spo2), endTime)) + samples.push({ type: 'spo2', t: rec.time, value: rec.percentage }); + for (const rec of await readAll('RespiratoryRate', sinceFor(latest.samples.respiratory_rate), endTime)) + samples.push({ type: 'respiratory_rate', t: rec.time, value: rec.rate }); + + // interval records → per-local-day sums + say('Reading activity…'); + const addDaily = (type: string, day: string, value: number) => { + const k = `${type}|${day}`; + const cur = daily.get(k); + if (cur) cur.value += value; + else daily.set(k, { type, day, value }); + }; + const dailyStart = (t: string | null) => sinceFor(t); + for (const rec of await readAll('Steps', dailyStart(latest.daily.steps ? latest.daily.steps + 'T00:00:00Z' : null), endTime)) + addDaily('steps', localDay(rec.startTime), rec.count || 0); + for (const rec of await readAll('Distance', dailyStart(latest.daily.distance_m ? latest.daily.distance_m + 'T00:00:00Z' : null), endTime)) + addDaily('distance_m', localDay(rec.startTime), rec.distance?.inMeters ?? 0); + for (const rec of await readAll('ActiveCaloriesBurned', dailyStart(latest.daily.active_kcal ? latest.daily.active_kcal + 'T00:00:00Z' : null), endTime)) + addDaily('active_kcal', localDay(rec.startTime), rec.energy?.inKilocalories ?? 0); + for (const rec of await readAll('TotalCaloriesBurned', dailyStart(latest.daily.total_kcal ? latest.daily.total_kcal + 'T00:00:00Z' : null), endTime)) + addDaily('total_kcal', localDay(rec.startTime), rec.energy?.inKilocalories ?? 0); + for (const rec of await readAll('FloorsClimbed', dailyStart(latest.daily.floors ? latest.daily.floors + 'T00:00:00Z' : null), endTime)) + addDaily('floors', localDay(rec.startTime), rec.floors || 0); + + say('Reading sleep…'); + for (const rec of await readAll('SleepSession', sinceFor(latest.sleep), endTime)) { + const startMs = new Date(rec.startTime).getTime(); + const endMs = new Date(rec.endTime).getTime(); + let deep = 0, rem = 0, light = 0, awake = 0, asleep = 0; + for (const st of rec.stages || []) { + const sec = (new Date(st.endTime).getTime() - new Date(st.startTime).getTime()) / 1000; + if (st.stage === STAGE.DEEP) deep += sec; + else if (st.stage === STAGE.REM) rem += sec; + else if (st.stage === STAGE.LIGHT || st.stage === STAGE.SLEEPING) light += sec; + else if (st.stage === STAGE.AWAKE || st.stage === STAGE.AWAKE_IN_BED || st.stage === STAGE.OUT_OF_BED) awake += sec; + } + asleep = deep + rem + light; + const total = (endMs - startMs) / 1000; + if (asleep === 0) asleep = Math.max(0, total - awake); // unstaged session + sleep.push({ + id: rec.metadata?.id || `${rec.startTime}-${rec.endTime}`, + started_at: rec.startTime, ended_at: rec.endTime, + total_seconds: Math.round(total), sleep_seconds: Math.round(asleep), + deep_seconds: Math.round(deep), rem_seconds: Math.round(rem), + light_seconds: Math.round(light), awake_seconds: Math.round(awake), + }); + } + + say('Uploading…'); + let ns = 0, nd = 0, nl = 0; + const dailyRows = [...daily.values()]; + const CHUNK = 4000; + for (let i = 0; i < Math.max(1, Math.ceil(samples.length / CHUNK)); i++) { + const chunk = samples.slice(i * CHUNK, (i + 1) * CHUNK); + const body = { + samples: chunk, + daily: i === 0 ? dailyRows : [], + sleep: i === 0 ? sleep : [], + }; + if (!chunk.length && i > 0) break; + const res = await postJson<{ samples: number; daily: number; sleep: number }>(FITBIT_SYNC_URL, body); + ns += res.samples; nd += res.daily; nl += res.sleep; + } + + return { + ok: true, samples: ns, daily: nd, sleep: nl, + message: `Synced ${ns} samples, ${nd} daily rows, ${nl} sleep sessions`, + }; +} diff --git a/src/lib/shape.ts b/src/lib/shape.ts index 2b10cf6..d240d99 100644 --- a/src/lib/shape.ts +++ b/src/lib/shape.ts @@ -3,7 +3,17 @@ import type { Week, Workout, } from './types'; -export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[] { +export interface HcSleepRow { + id: string; started_at: string; ended_at: string; + total_seconds: number; sleep_seconds: number; + deep_seconds: number; rem_seconds: number; light_seconds: number; awake_seconds: number; +} + +export function shapeSleep( + api: SleepApiResponse, + sc: SleepCycleExport, + fitbit: HcSleepRow[] = [], +): Night[] { const byDate = new Map(); (sc.sessions || []).forEach((r) => { const o: Record = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]])); @@ -39,6 +49,23 @@ export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[] src: 'Eight Sleep', }); }); + // Fitbit Air wins where sources overlap — it's the current device. + fitbit.forEach((f) => { + const date = f.ended_at.slice(0, 10); + byDate.set(date, { + date, + total: f.total_seconds, + asleep: f.sleep_seconds, + deep: f.deep_seconds, + rem: f.rem_seconds, + light: f.light_seconds, + awake: f.awake_seconds, + quality: null, + hrv: null, + hr: null, + src: 'Fitbit Air', + }); + }); return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1)); }