Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c48212bfff |
22
app.json
22
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",
|
||||
|
||||
47
package-lock.json
generated
47
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -22,6 +22,7 @@ function Gate() {
|
||||
<Stack.Screen name="index" options={{ title: 'Health' }} />
|
||||
<Stack.Screen name="blood" options={{ title: 'Bloodwork' }} />
|
||||
<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="body" options={{ title: 'Body Composition' }} />
|
||||
<Stack.Screen name="mind" options={{ title: 'Meditation' }} />
|
||||
|
||||
237
src/app/fitbit.tsx
Normal file
237
src/app/fitbit.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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(<NavCard key="mind" title="Mind" big="—" note="Couldn’t 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(
|
||||
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
|
||||
note="AncestryDNA trait report" />,
|
||||
|
||||
@@ -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<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;
|
||||
if (loading && !data) body = <Loading />;
|
||||
else if (err) body = <ErrBox msg={err} />;
|
||||
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() {
|
||||
/>
|
||||
</Panel>
|
||||
<SrcNote>
|
||||
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.
|
||||
</SrcNote>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 */
|
||||
export function Sparkline({
|
||||
pts, flagged, width,
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
const AUTH_KEY = 'hd.auth';
|
||||
|
||||
189
src/lib/fitbit.ts
Normal file
189
src/lib/fitbit.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
@@ -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<string, Night>();
|
||||
(sc.sessions || []).forEach((r) => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user