Files
health-app/src/lib/shape.ts
Marcus Rehbock c48212bfff
All checks were successful
Build & Release APK / build (push) Successful in 2m5s
Fitbit Air: Health Connect sync + vitals dashboard
- 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

122 lines
3.5 KiB
TypeScript

import type {
BiomarkersResponse, MarkerGroup, Night, SleepApiResponse, SleepCycleExport,
Week, Workout,
} from './types';
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]]));
const date = String(o.end || '').slice(0, 10);
if (!date) return;
byDate.set(date, {
date,
total: o.inbed_s || o.asleep_s,
asleep: o.asleep_s,
deep: o.deep_s,
rem: o.rem_s,
light: o.light_s || (o.deep_s || o.rem_s ? 0 : o.asleep_s),
awake: o.awake_s,
quality: o.quality,
hrv: null,
hr: null,
src: 'Sleep Cycle',
});
});
(api.sessions || []).forEach((s) => {
const date = s.ended_at.slice(0, 10);
byDate.set(date, {
date,
total: s.total_seconds,
asleep: s.sleep_seconds,
deep: s.deep_seconds,
rem: s.rem_seconds,
light: s.light_seconds,
awake: s.awake_seconds,
quality: null,
hrv: s.avg_hrv,
hr: s.avg_hr,
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));
}
export function workoutVolume(w: Workout): { vol: number; sets: number } {
let vol = 0;
let sets = 0;
(w.exercises || []).forEach((ex) =>
(ex.sets || []).forEach((s) => {
sets++;
if (s.weight_kg && s.reps) vol += s.weight_kg * s.reps;
}),
);
return { vol, sets };
}
export function weekStart(d: string): string {
const dt = new Date(d);
const day = (dt.getDay() + 6) % 7;
dt.setDate(dt.getDate() - day);
return dt.toISOString().slice(0, 10);
}
export function shapeWeeks(workouts: Workout[], n: number): Week[] {
const map = new Map<string, Week>();
workouts.forEach((w) => {
const k = weekStart(w.start_time);
if (!map.has(k)) map.set(k, { start: k, vol: 0, count: 0, sets: 0 });
const e = map.get(k)!;
const { vol, sets } = workoutVolume(w);
e.vol += vol;
e.count++;
e.sets += sets;
});
return [...map.values()].sort((a, b) => (a.start < b.start ? -1 : 1)).slice(-n);
}
export function shapeBiomarkers(bm: BiomarkersResponse): MarkerGroup[] {
const groups = new Map<string, typeof bm.biomarkers>();
bm.biomarkers.forEach((b) => {
if (!groups.has(b.name)) groups.set(b.name, []);
groups.get(b.name)!.push(b);
});
const out: MarkerGroup[] = [];
groups.forEach((hist, name) => {
hist.sort((a, b) => (a.collected < b.collected ? -1 : 1));
const latest = hist[hist.length - 1];
const series = hist
.filter((h) => h.unit === latest.unit && h.value_num != null)
.map((h) => ({ d: h.collected, v: h.value_num! }));
out.push({ name, latest, series, panel: latest.panel });
});
return out.sort((a, b) => a.name.localeCompare(b.name));
}