8 Commits

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
24561bbc88 CI: pin builds to the desktop runner
All checks were successful
Build & Release APK / build (push) Successful in 11m17s
The RN/NDK build exhausted the 8GB VPS (whole docker stack went down
2026-08-08); marcusDesktop (32 threads) now handles these, and jobs
queue while it is offline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:38:10 -07:00
0ed7ec0624 Config plugin: parallel Gradle with all cores + build cache
Clean arm64 release rebuild drops ~2m40s -> ~1m on 32 threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:57:51 -07:00
08b9579839 README: document CI pipeline
Some checks failed
Build & Release APK / build (push) Failing after 35m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:47:32 -07:00
21f431441e Gitea Actions: auto-build signed arm64 APK release on push to main
Some checks failed
Build & Release APK / build (push) Has been cancelled
Mirrors the meditation-timer pipeline; VERSION_CODE/VERSION_NAME are
stamped from the run number via app.config.js so Obtainium sees each
release as an upgrade. Keystore comes from repo secrets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:47:07 -07:00
27 changed files with 891 additions and 53 deletions

View File

@@ -0,0 +1,94 @@
name: Build & Release APK
on:
push:
branches: [main]
jobs:
build:
# RN/NDK builds need ~6GB and once took down the 8GB VPS — desktop runner only.
# Jobs queue while the desktop is offline.
runs-on: desktop
container:
image: eclipse-temurin:21-jdk
steps:
# actions/checkout needs node, which this container lacks — clone directly
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git unzip curl xz-utils >/dev/null
git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD
# /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: |
NODE_DIR="$HOME/.npm/_node-v22.14.0"
if [ ! -x "$NODE_DIR/bin/node" ]; then
curl -sLo /tmp/node.tar.xz https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz
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
run: npm ci --no-audit --no-fund
- name: Install Android SDK + NDK (cached volume)
run: |
SDK="$HOME/android-sdk"
SDKMAN="$SDK/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMAN" ]; then
mkdir -p "$SDK/cmdline-tools"
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d /tmp
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" \
"ndk;27.1.12297006" "cmake;3.22.1" >/dev/null
- name: Decode signing keystore
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
run: echo "$KEYSTORE_B64" | base64 -d > /tmp/release.jks
- name: Prebuild android project
env:
VERSION_CODE: ${{ github.run_number }}
VERSION_NAME: 1.0.${{ github.run_number }}
run: npx expo prebuild --platform android --no-install
- name: Build signed release APK (arm64)
env:
HEALTH_KEYSTORE: /tmp/release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: health
VERSION_CODE: ${{ github.run_number }}
VERSION_NAME: 1.0.${{ github.run_number }}
run: |
export ANDROID_HOME="$HOME/android-sdk"
echo "sdk.dir=$ANDROID_HOME" > android/local.properties
cd android && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a \
--no-daemon --console=plain
- name: Publish Gitea release with APK
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
API="https://git.rehbock.xyz/api/v1/repos/${{ github.repository }}"
TAG="v1.0.${{ github.run_number }}"
APK=android/app/build/outputs/apk/release/app-release.apk
test -f "$APK"
curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of commit ${{ github.sha }}\",\"target_commitish\":\"${{ github.sha }}\"}" \
> /tmp/release.json
RID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)
echo "release id: $RID"
curl -sf -X POST "$API/releases/$RID/assets?name=health-app-$TAG-arm64.apk" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
echo "published $TAG"

View File

@@ -47,8 +47,16 @@ cd android && ./gradlew assembleRelease
# → android/app/build/outputs/apk/release/app-release.apk
```
Install on GrapheneOS by copying the APK over (or via Obtainium once a
Gitea release exists).
Install on GrapheneOS by copying the APK over, or via Obtainium.
## CI (Gitea Actions)
Every push to `main` runs `.gitea/workflows/release.yml`: prebuild →
signed arm64 APK → Gitea release tagged `v1.0.<run_number>`, APK attached.
`app.config.js` stamps `VERSION_CODE`/`VERSION_NAME` from the run number so
each release installs as an upgrade (Obtainium-friendly).
Repo secrets: `KEYSTORE_B64` (base64 of the jks), `KEYSTORE_PASSWORD`.
## iOS

10
app.config.js Normal file
View File

@@ -0,0 +1,10 @@
/* Wraps app.json so CI can stamp versions: VERSION_NAME → expo.version,
VERSION_CODE → android.versionCode (Obtainium needs it to increase). */
module.exports = ({ config }) => {
const versionCode = parseInt(process.env.VERSION_CODE || '', 10);
if (process.env.VERSION_NAME) config.version = process.env.VERSION_NAME;
if (Number.isFinite(versionCode)) {
config.android = { ...config.android, versionCode };
}
return config;
};

View File

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

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-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",

View File

@@ -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": {

View File

@@ -40,6 +40,17 @@ module.exports = function withReleaseSigning(config) {
if (jdk && !cfg.modResults.some((p) => p.key === 'org.gradle.java.home')) {
cfg.modResults.push({ type: 'property', key: 'org.gradle.java.home', value: jdk });
}
/* use every core: parallel module builds + build cache */
const set = (key, value) => {
const hit = cfg.modResults.find((p) => p.key === key);
if (hit) hit.value = value;
else cfg.modResults.push({ type: 'property', key, value });
};
set('org.gradle.parallel', 'true');
set('org.gradle.caching', 'true');
set('org.gradle.configuration-cache', 'false');
set('org.gradle.workers.max', String(os.cpus().length));
set('org.gradle.jvmargs', '-Xmx6g -XX:MaxMetaspaceSize=1g');
return cfg;
});
return withAppBuildGradle(config, (cfg) => {

View File

@@ -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
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(() =>
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="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(
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
note="AncestryDNA trait report" />,

View File

@@ -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>
</>
);

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 */
export function Sparkline({
pts, flagged, width,

View File

@@ -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';
@@ -79,6 +98,11 @@ export function clearCache() {
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 ---- */
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,
} 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));
}