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>
122 lines
3.5 KiB
TypeScript
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));
|
|
}
|