Fitbit Air: Health Connect sync + vitals dashboard
All checks were successful
Build & Release APK / build (push) Successful in 2m5s

- react-native-health-connect (config plugin, minSdk 26, 11 read perms)
- sync-on-open + manual sync: incremental via server watermark, chunked
  POST to /api/health/v1/fitbit/sync (HR, resting HR, HRV, SpO2, resp
  rate, steps/distance/kcal/floors dailies, staged sleep sessions)
- Fitbit screen: steps/RHR/HRV/SpO2 tiles + daily charts + sleep list +
  per-stream record counts (answers the which-types-does-Google-Health-
  share question empirically)
- Sleep tab now merges Fitbit Air nights (wins over older sources)
- Today screen Fitbit card

Server side (deployed separately on VPS): hc_samples/hc_daily/hc_sleep
tables + sync/latest/summary endpoints in health-api; Caddy routes POST
/api/health/v1/fitbit/* behind the same basic_auth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 22:25:07 -07:00
parent adb8905ebf
commit c48212bfff
11 changed files with 678 additions and 7 deletions

View File

@@ -15,6 +15,19 @@
}, },
"android": { "android": {
"package": "xyz.rehbock.health", "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": { "adaptiveIcon": {
"backgroundColor": "#0d0d0f", "backgroundColor": "#0d0d0f",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
@@ -30,6 +43,15 @@
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-secure-store", "expo-secure-store",
"react-native-health-connect",
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 26
}
}
],
"./plugins/with-release-signing.js", "./plugins/with-release-signing.js",
[ [
"expo-splash-screen", "expo-splash-screen",

47
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-health-connect": "^4.1.3",
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0", "react-native-screens": "~4.26.0",
@@ -36,6 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"expo-build-properties": "^57.0.9",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} }
}, },
@@ -3811,6 +3813,21 @@
"react-native": "*" "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": { "node_modules/expo-constants": {
"version": "57.0.9", "version": "57.0.9",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz",
@@ -6575,6 +6592,36 @@
"react-native": "*" "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": { "node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1", "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", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",

View File

@@ -22,6 +22,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-health-connect": "^4.1.3",
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0", "react-native-screens": "~4.26.0",
@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"expo-build-properties": "^57.0.9",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {

View File

@@ -22,6 +22,7 @@ function Gate() {
<Stack.Screen name="index" options={{ title: 'Health' }} /> <Stack.Screen name="index" options={{ title: 'Health' }} />
<Stack.Screen name="blood" options={{ title: 'Bloodwork' }} /> <Stack.Screen name="blood" options={{ title: 'Bloodwork' }} />
<Stack.Screen name="sleep" options={{ title: 'Sleep' }} /> <Stack.Screen name="sleep" options={{ title: 'Sleep' }} />
<Stack.Screen name="fitbit" options={{ title: 'Fitbit Air' }} />
<Stack.Screen name="train" options={{ title: 'Training' }} /> <Stack.Screen name="train" options={{ title: 'Training' }} />
<Stack.Screen name="body" options={{ title: 'Body Composition' }} /> <Stack.Screen name="body" options={{ title: 'Body Composition' }} />
<Stack.Screen name="mind" options={{ title: 'Meditation' }} /> <Stack.Screen name="mind" options={{ title: 'Meditation' }} />

237
src/app/fitbit.tsx Normal file
View File

@@ -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<string, number>;
}
export default function Fitbit() {
const { width } = useWindowDimensions();
const chartW = Math.min(608, width - 32) - 28;
const { data, err, loading, refresh } = useLoad(() => load<Summary>('fitbitSummary'));
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState('');
const [hcStatus, setHcStatus] = useState<string>('');
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 = <Loading />;
else if (err) body = <ErrBox msg={err} />;
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 ? (
<View style={s.emptyBox}>
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 6 }}>
No Fitbit data yet
</Text>
<Text style={{ color: C.muted, fontSize: 12.5, lineHeight: 18 }}>
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.
</Text>
</View>
) : (
<TileGrid>
<Tile label="Steps" value={stepsToday ? num(Math.round(stepsToday.value)) : '—'}
sub={stepsToday ? fmtDY(stepsToday.day) : undefined} />
<Tile label="Resting HR" value={rhr ? String(Math.round(rhr.avg)) : '—'} unit="bpm"
sub={rhr ? fmtDY(rhr.day) : undefined} />
<Tile label="HRV (RMSSD)" value={hrv ? String(Math.round(hrv.avg)) : '—'} unit="ms"
sub={hrv ? fmtDY(hrv.day) : undefined} />
<Tile label="SpO2" value={spo2 ? spo2.avg.toFixed(1) : '—'} unit="%"
sub={spo2 ? fmtDY(spo2.day) : undefined} />
{lastSleep ? (
<Tile label="Last sleep" value={hm(lastSleep.sleep_seconds)}
sub={fmtDY(lastSleep.started_at)} />
) : null}
</TileGrid>
)}
{data.steps.length ? (
<>
<Sec title={`Steps · ${data.days} days`} />
<Panel title="Daily steps" sub="tap a bar">
<DailyBars data={data.steps} width={chartW} unit="Steps" />
</Panel>
</>
) : null}
{data.resting_hr.length > 1 ? (
<>
<Sec title="Resting heart rate" />
<Panel title="Daily resting HR (bpm)" sub="tap a point">
<DailyLine data={data.resting_hr} width={chartW} unit="bpm" color={C.s2} />
</Panel>
</>
) : null}
{data.hrv.length > 1 ? (
<>
<Sec title="Heart rate variability" />
<Panel title="Nightly HRV RMSSD (ms)" sub="tap a point">
<DailyLine data={data.hrv} width={chartW} unit="ms" color={C.s3} />
</Panel>
</>
) : null}
{data.spo2.length > 1 ? (
<>
<Sec title="Blood oxygen" />
<Panel title="Nightly SpO2 (%)" sub="tap a point">
<DailyLine data={data.spo2} width={chartW} unit="%" color={C.s1} decimals={1} />
</Panel>
</>
) : null}
{data.sleep.length ? (
<>
<Sec title="Sleep sessions" count={data.sleep.length} />
{[...data.sleep].reverse().slice(0, 14).map((sl) => (
<View key={sl.id} style={s.row}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: C.text, fontSize: 13.5, fontWeight: '500' }}>
{hm(sl.sleep_seconds)}
</Text>
<Text style={{ color: C.muted, fontSize: 11 }}>{fmtDY(sl.started_at)}</Text>
</View>
<Text style={{ color: C.muted, fontSize: 12, marginTop: 3 }}>
{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)}
</Text>
</View>
))}
</>
) : null}
<Sec title="Data on record" />
<View style={s.debugBox}>
{Object.entries(data.sample_counts).map(([k, v]) => (
<Text key={k} style={{ color: v ? C.muted : C.dim, fontSize: 12, marginVertical: 1 }}>
{k.replace(/_/g, ' ')}: {num(v)}
</Text>
))}
</View>
<SrcNote>
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.
</SrcNote>
</>
);
}
return (
<Screen onRefresh={refresh}>
{/* sync bar */}
<View style={s.syncBar}>
<View style={{ flex: 1 }}>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
{Platform.OS === 'android' ? 'Health Connect' : 'Sync (Android phone only)'}
</Text>
<Text style={{ color: C.muted, fontSize: 11.5, marginTop: 2 }}>
{healthConnectSupported()
? hcStatus === 'available'
? syncMsg || 'Ready'
: hcStatus === 'needs-update'
? 'Health Connect needs an update'
: hcStatus || 'Checking…'
: 'Viewing server data — sync runs on the Android phone'}
</Text>
</View>
{healthConnectSupported() && (
<Pressable
style={[s.syncBtn, (syncing || hcStatus !== 'available') && { opacity: 0.5 }]}
disabled={syncing || hcStatus !== 'available'}
onPress={runSync}>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 13 }}>
{syncing ? 'Syncing…' : 'Sync'}
</Text>
</Pressable>
)}
</View>
{body}
</Screen>
);
}
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,
},
});

View File

@@ -17,7 +17,7 @@ export default function Today() {
const { data, loading, refresh } = useLoad(() => const { data, loading, refresh } = useLoad(() =>
loadAll({ loadAll({
meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi', meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi',
sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', fitbit: 'fitbitSummary',
} as const), } as const),
); );
@@ -30,6 +30,11 @@ export default function Today() {
sc: SleepCycleExport | null; sc: SleepCycleExport | null;
dexa: DexaResponse | null; dexa: DexaResponse | null;
med: MeditationStats | null; med: MeditationStats | null;
fitbit: {
steps: { day: string; value: number }[];
resting_hr: { day: string; avg: number }[];
hrv: { day: string; avg: number }[];
} | null;
} }
| undefined; | undefined;
@@ -89,6 +94,21 @@ export default function Today() {
); );
} else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldnt load" href="/mind" />); } else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldnt load" href="/mind" />);
{
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(
<NavCard key="fitbit" title="Fitbit" href="/fitbit"
big={steps ? num(Math.round(steps.value)) : '—'} unit={steps ? 'steps' : undefined}
note={
steps
? `${fmtD(steps.day)}${hrv ? ` · HRV ${Math.round(hrv.avg)} ms` : ''}`
: 'Open to sync from Health Connect'
} />,
);
}
cards.push( cards.push(
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna" <NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
note="AncestryDNA trait report" />, note="AncestryDNA trait report" />,

View File

@@ -13,15 +13,19 @@ export default function Sleep() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const chartW = Math.min(608, width - 32) - 28; const chartW = Math.min(608, width - 32) - 28;
const { data, err, loading, refresh } = useLoad(() => const { data, err, loading, refresh } = useLoad(() =>
Promise.all([load<SleepApiResponse>('sleepApi'), load<SleepCycleExport>('sleepCycle')]), Promise.all([
load<SleepApiResponse>('sleepApi'),
load<SleepCycleExport>('sleepCycle'),
load<{ sleep: import('../lib/shape').HcSleepRow[] }>('fitbitSummary').catch(() => null),
]),
); );
let body: React.ReactNode = null; let body: React.ReactNode = null;
if (loading && !data) body = <Loading />; if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />; else if (err) body = <ErrBox msg={err} />;
else if (data) { else if (data) {
const [api, sc] = data; const [api, sc, fb] = data;
const nights = shapeSleep(api, sc); const nights = shapeSleep(api, sc, fb?.sleep ?? []);
const recent = nights.slice(-30); const recent = nights.slice(-30);
const last7 = nights.slice(-7); const last7 = nights.slice(-7);
const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) => const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) =>
@@ -56,8 +60,9 @@ export default function Sleep() {
/> />
</Panel> </Panel>
<SrcNote> <SrcNote>
Sources: Eight Sleep pod (through Feb 2026, incl. HRV/HR) and Sleep Cycle app export Sources: Fitbit Air (via Health Connect), Eight Sleep pod (through Feb 2026, incl.
(through {sc.exported_at}). {num(nights.length)} nights total on record. HRV/HR), Sleep Cycle export (through {sc.exported_at}). {num(nights.length)} nights
total on record.
</SrcNote> </SrcNote>
</> </>
); );

View File

@@ -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<number | null>(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 (
<View>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
{[0, 0.5, 1].map((f) => (
<G key={f}>
<Line x1={padL} y1={y(maxV * f)} x2={W} y2={y(maxV * f)} stroke={C.gridline} strokeWidth={1} />
<SvgText x={padL - 5} y={y(maxV * f) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{fmt(maxV * f)}
</SvgText>
</G>
))}
{data.map((d, i) => {
const x = padL + (i + 0.5) * (iw / n) - bw / 2;
return (
<G key={d.day} onPress={() => setSel(i === sel ? null : i)}>
<Rect x={x - 1} y={8} width={bw + 2} height={H - padB - 8} fill="transparent" />
<Rect x={x} y={y(d.value)} width={bw} height={Math.max(1, H - padB - y(d.value))}
rx={1.5} fill={color} opacity={sel == null || sel === i ? 1 : 0.35} />
</G>
);
})}
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>{fmtD(data[0].day)}</SvgText>
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{fmtD(data[n - 1].day)}
</SvgText>
</Svg>
{selD && (
<DetailCard title={fmtDY(selD.day)} rows={[[unit, `${num(Math.round(selD.value))}`]]} />
)}
</View>
);
}
/* 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<number | null>(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 (
<View>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
{[min, max].map((v) => (
<G key={v}>
<Line x1={padL} y1={y(v)} x2={W} y2={y(v)} stroke={C.gridline} strokeWidth={1} />
<SvgText x={padL - 5} y={y(v) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{v.toFixed(decimals)}
</SvgText>
</G>
))}
<Path d={path} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" opacity={0.85} />
{data.map((d, i) => (
<G key={d.day} onPress={() => setSel(i === sel ? null : i)}>
<Rect x={x(i) - iw / n / 2} y={0} width={iw / n} height={H - padB} fill="transparent" />
<Circle cx={x(i)} cy={y(d.avg)} r={sel === i ? 4.5 : 2.5} fill={color}
stroke={C.surface} strokeWidth={1} />
</G>
))}
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>{fmtD(data[0].day)}</SvgText>
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{fmtD(data[n - 1].day)}
</SvgText>
</Svg>
{selD && (
<DetailCard title={fmtDY(selD.day)} rows={[[unit, selD.avg.toFixed(decimals)]]} />
)}
</View>
);
}
/* biomarker sparkline */ /* biomarker sparkline */
export function Sparkline({ export function Sparkline({
pts, flagged, width, pts, flagged, width,

View File

@@ -14,8 +14,27 @@ export const EP = {
medStats: `${BASE}/api/meditation/v1/stats`, medStats: `${BASE}/api/meditation/v1/stats`,
medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`, medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`,
traits: `${BASE}/ancestry-traits.json`, traits: `${BASE}/ancestry-traits.json`,
fitbitSummary: `${BASE}/api/health/v1/fitbit/summary?days=30`,
fitbitLatest: `${BASE}/api/health/v1/fitbit/latest`,
} as const; } as const;
export const FITBIT_SYNC_URL = `${BASE}/api/health/v1/fitbit/sync`;
/* authenticated POST (fitbit sync) */
export async function postJson<T>(url: string, body: unknown): Promise<T> {
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; export type EpKey = keyof typeof EP;
const AUTH_KEY = 'hd.auth'; const AUTH_KEY = 'hd.auth';

189
src/lib/fitbit.ts Normal file
View File

@@ -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<any[]> {
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<SyncResult> {
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<string, string | null>;
daily: Record<string, string | null>;
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<string, DailyRow>();
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`,
};
}

View File

@@ -3,7 +3,17 @@ import type {
Week, Workout, Week, Workout,
} from './types'; } 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<string, Night>(); const byDate = new Map<string, Night>();
(sc.sessions || []).forEach((r) => { (sc.sessions || []).forEach((r) => {
const o: Record<string, any> = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]])); const o: Record<string, any> = 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', 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)); return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1));
} }