4 Commits
v1.0.3 ... main

Author SHA1 Message Date
81632a4275 Fix: Fitbit screen showed no data after a successful sync
All checks were successful
Build & Release APK / build (push) Successful in 2m4s
load() caches by endpoint for the session, so the post-sync refresh
re-served the pre-sync (empty) summary. Sync now invalidates the
fitbit cache keys — the watermark before reading, summary+latest after
uploading — so the refetch actually hits the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 00:03:17 -07:00
c48212bfff 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>
2026-08-07 22:25:07 -07:00
adb8905ebf App icon: violet heart + ECG pulse on the dashboard's dark palette
All checks were successful
Build & Release APK / build (push) Successful in 3m28s
Replaces the Expo template icons; adaptive foreground/monochrome/splash
generated from assets/icon-src/*.svg (rsvg-convert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 19:38:28 -07:00
1a65736fb8 CI: reuse persistent runner caches (npm/gradle/android-sdk volumes)
Some checks failed
Build & Release APK / build (push) Has been cancelled
Node lives in the npm volume, cmdline-tools download is guarded, and
sdkmanager no-ops when packages are present. First run seeds the
volumes; later runs skip all toolchain downloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 19:33:54 -07:00
24 changed files with 787 additions and 64 deletions

View File

@@ -20,25 +20,33 @@ jobs:
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}" git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD git checkout -q FETCH_HEAD
- name: Install Node 22 # /root/.npm and /root/.gradle and /root/android-sdk are persistent named
# volumes (act_runner config.yaml) — downloads below only happen on a cold cache.
- name: Install Node 22 (cached in npm volume)
run: | run: |
curl -sLo /tmp/node.tar.xz https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz NODE_DIR="$HOME/.npm/_node-v22.14.0"
mkdir -p /opt/node && tar xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 if [ ! -x "$NODE_DIR/bin/node" ]; then
echo "/opt/node/bin" >> "$GITHUB_PATH" curl -sLo /tmp/node.tar.xz https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz
/opt/node/bin/node --version mkdir -p "$NODE_DIR" && tar xJf /tmp/node.tar.xz -C "$NODE_DIR" --strip-components=1
fi
echo "$NODE_DIR/bin" >> "$GITHUB_PATH"
"$NODE_DIR/bin/node" --version
- name: npm ci - name: npm ci
run: npm ci --no-audit --no-fund run: npm ci --no-audit --no-fund
- name: Install Android SDK + NDK - name: Install Android SDK + NDK (cached volume)
run: | run: |
mkdir -p "$HOME/android-sdk/cmdline-tools" SDK="$HOME/android-sdk"
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip SDKMAN="$SDK/cmdline-tools/latest/bin/sdkmanager"
unzip -q /tmp/ct.zip -d /tmp if [ ! -x "$SDKMAN" ]; then
mv /tmp/cmdline-tools "$HOME/android-sdk/cmdline-tools/latest" mkdir -p "$SDK/cmdline-tools"
SDKMAN="$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
yes | "$SDKMAN" --sdk_root="$HOME/android-sdk" --licenses >/dev/null 2>&1 || true unzip -q /tmp/ct.zip -d /tmp
"$SDKMAN" --sdk_root="$HOME/android-sdk" \ mv /tmp/cmdline-tools "$SDK/cmdline-tools/latest"
fi
yes | "$SDKMAN" --sdk_root="$SDK" --licenses >/dev/null 2>&1 || true
"$SDKMAN" --sdk_root="$SDK" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0" \ "platform-tools" "platforms;android-36" "build-tools;36.0.0" \
"ndk;27.1.12297006" "cmake;3.22.1" >/dev/null "ndk;27.1.12297006" "cmake;3.22.1" >/dev/null

View File

@@ -9,12 +9,25 @@
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#0d0d0f", "backgroundColor": "#0d0d0f",
"ios": { "ios": {
"icon": "./assets/expo.icon", "icon": "./assets/images/icon.png",
"bundleIdentifier": "xyz.rehbock.health", "bundleIdentifier": "xyz.rehbock.health",
"supportsTablet": true "supportsTablet": true
}, },
"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",

View File

@@ -1,3 +0,0 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,40 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

25
assets/icon-src/glyph.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="heart" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8a86ff"/>
<stop offset="100%" stop-color="#5b57d6"/>
</linearGradient>
</defs>
<!-- glyph only, transparent bg, scaled into the adaptive-icon safe zone -->
<g transform="translate(512 512) scale(0.62) translate(-512 -512)">
<path d="M512 812
C 402 726, 232 616, 210 462
C 196 360, 268 268, 372 268
C 436 268, 486 304, 512 356
C 538 304, 588 268, 652 268
C 756 268, 828 360, 814 462
C 792 616, 622 726, 512 812 Z"
fill="url(#heart)"/>
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
fill="none" stroke="#0d0d0f" stroke-width="58"
stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
fill="none" stroke="#ffffff" stroke-width="26"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

32
assets/icon-src/icon.svg Normal file
View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<radialGradient id="bg" cx="50%" cy="38%" r="75%">
<stop offset="0%" stop-color="#1e1e26"/>
<stop offset="100%" stop-color="#0d0d0f"/>
</radialGradient>
<linearGradient id="heart" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8a86ff"/>
<stop offset="100%" stop-color="#5b57d6"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" fill="url(#bg)"/>
<!-- heart -->
<path d="M512 812
C 402 726, 232 616, 210 462
C 196 360, 268 268, 372 268
C 436 268, 486 304, 512 356
C 538 304, 588 268, 652 268
C 756 268, 828 360, 814 462
C 792 616, 622 726, 512 812 Z"
fill="url(#heart)"/>
<!-- ECG pulse across the heart -->
<polyline points="120,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 904,520"
fill="none" stroke="#0d0d0f" stroke-width="58"
stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="120,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 904,520"
fill="none" stroke="#ffffff" stroke-width="26"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<mask id="cut">
<rect width="1024" height="1024" fill="white"/>
<polyline points="60,520 330,520 385,520 425,414 478,626 528,368 576,556 608,520 964,520"
fill="none" stroke="black" stroke-width="58"
stroke-linecap="round" stroke-linejoin="round"/>
</mask>
</defs>
<!-- single-silhouette version: heart with the ECG line cut out (launcher tints it) -->
<g transform="translate(512 512) scale(0.62) translate(-512 -512)">
<path d="M512 812
C 402 726, 232 616, 210 462
C 196 360, 268 268, 372 268
C 436 268, 486 304, 512 356
C 538 304, 588 268, 652 268
C 756 268, 828 360, 814 462
C 792 616, 622 726, 512 812 Z"
fill="#ffffff" mask="url(#cut)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 451 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

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';
@@ -79,6 +98,11 @@ export function clearCache() {
cache = {}; cache = {};
} }
/* drop specific endpoints from the cache so the next load() refetches */
export function invalidate(...keys: EpKey[]) {
for (const k of keys) delete cache[k];
}
/* ---- auth context ---- */ /* ---- auth context ---- */
interface AuthCtx { interface AuthCtx {

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

@@ -0,0 +1,193 @@
/* 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, invalidate, 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…');
invalidate('fitbitLatest'); // watermark must be fresh, never the session-cached copy
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;
}
// new rows exist server-side — cached copies of these are now stale
invalidate('fitbitSummary', 'fitbitLatest');
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));
} }