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

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

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

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

View File

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