Fitbit Air: Health Connect sync + vitals dashboard
All checks were successful
Build & Release APK / build (push) Successful in 2m5s
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:
22
app.json
22
app.json
@@ -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
47
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
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(() =>
|
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="Couldn’t load" href="/mind" />);
|
} 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(
|
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" />,
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
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,
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user