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 (
+
+
+ {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 (
+
+
+ {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));
}