Files
health-app/src/app/index.tsx
Marcus Rehbock 2a5c956c32 Expo app: native front end for health.rehbock.xyz
Login (HTTP Basic, token in SecureStore) + Today, Blood, Sleep, Train,
Body, Mind, DNA screens ported from the PWA at /app/. All data is
fetched live from health.rehbock.xyz; nothing is stored on device
beyond the auth token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:38:52 -07:00

144 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Stack } from 'expo-router';
import React from 'react';
import { Alert, Pressable, Text, View } from 'react-native';
import { NavCard, PriorityCard, Screen, Sec } from '../components/ui';
import { useAuth } from '../lib/api';
import { agoTxt, fmtD, fmtDY, hm, num, cap } from '../lib/format';
import { shapeBiomarkers, shapeSleep, weekStart, workoutVolume } from '../lib/shape';
import { C } from '../lib/theme';
import type {
BiomarkersResponse, DexaResponse, HealthMeta, MeditationStats, SleepApiResponse,
SleepCycleExport, WorkoutsResponse,
} from '../lib/types';
import { loadAll, useLoad } from '../lib/use-load';
export default function Today() {
const { logout } = useAuth();
const { data, loading, refresh } = useLoad(() =>
loadAll({
meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi',
sc: 'sleepCycle', dexa: 'dexa', med: 'medStats',
} as const),
);
const r = data as
| {
meta: HealthMeta | null;
bio: BiomarkersResponse | null;
wo: WorkoutsResponse | null;
sApi: SleepApiResponse | null;
sc: SleepCycleExport | null;
dexa: DexaResponse | null;
med: MeditationStats | null;
}
| undefined;
const cards: React.ReactNode[] = [];
if (r) {
if (r.sApi && r.sc) {
const nights = shapeSleep(r.sApi, r.sc);
const last = nights[nights.length - 1];
cards.push(
<NavCard key="sleep" title="Sleep" big={hm(last.asleep)} href="/sleep"
note={`Last night on record — ${fmtD(last.date)} (${last.src})`} />,
);
} else cards.push(<NavCard key="sleep" title="Sleep" big="—" note="Couldnt load" href="/sleep" />);
if (r.wo) {
const ws = [...r.wo.workouts].sort((a, b) => (a.start_time < b.start_time ? -1 : 1));
const last = ws[ws.length - 1];
const { vol } = workoutVolume(last);
const wkCount = ws.filter(
(w) => weekStart(w.start_time) === weekStart(new Date().toISOString()),
).length;
cards.push(
<NavCard key="train" title="Training" big={last.title} href="/train"
note={`${agoTxt(last.start_time)} · ${num(Math.round(vol))} kg · ${wkCount} this week`} />,
);
} else cards.push(<NavCard key="train" title="Training" big="—" note="Couldnt load" href="/train" />);
if (r.bio) {
const marks = shapeBiomarkers(r.bio);
const latestDraw = r.bio.biomarkers.reduce((m, b) => (b.collected > m ? b.collected : m), '');
const flagged = marks.filter((m) => m.latest.flagged && m.latest.collected === latestDraw);
cards.push(
<NavCard key="blood" title="Blood" big={String(marks.length)} unit="markers" href="/blood"
flagline={
flagged.length
? `${flagged.length} flagged: ${flagged.slice(0, 3).map((f) => cap(f.name)).join(', ')}${flagged.length > 3 ? '…' : ''}`
: undefined
}
okline={flagged.length ? undefined : 'All markers in range'}
note={`Latest draw ${fmtDY(latestDraw)}`} />,
);
} else cards.push(<NavCard key="blood" title="Blood" big="—" note="Couldnt load" href="/blood" />);
if (r.dexa) {
const scan = r.dexa.scans[r.dexa.scans.length - 1];
cards.push(
<NavCard key="body" title="Body" big={`${scan.summary.total_body_fat_pct}`} unit="% fat"
href="/body"
note={`${scan.summary.total_lean_mass_kg} kg lean · DXA ${fmtD(scan.scan_date)}`} />,
);
} else cards.push(<NavCard key="body" title="Body" big="—" note="Couldnt load" href="/body" />);
if (r.med) {
cards.push(
<NavCard key="mind" title="Mind" big={String(r.med.totalMinutes)} unit="min" href="/mind"
note={`${r.med.totalSessions} sessions · streak ${r.med.currentStreak}`} />,
);
} else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldnt load" href="/mind" />);
cards.push(
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
note="AncestryDNA trait report" />,
);
}
return (
<>
<Stack.Screen
options={{
headerRight: () => (
<Pressable
hitSlop={10}
onPress={() =>
Alert.alert('Lock the dashboard?', 'You will need the password to get back in.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Lock', style: 'destructive', onPress: logout },
])
}>
<Text style={{ fontSize: 16 }}>🔒</Text>
</Pressable>
),
}}
/>
<Screen onRefresh={refresh}>
<Sec
title={new Date().toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric',
})}
/>
{loading && !r ? (
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', padding: 24 }}>
Loading
</Text>
) : (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>{cards}</View>
)}
{r?.meta?.current_priorities?.length ? (
<>
<Sec title="Priorities" count={r.meta.current_priorities.length} />
{r.meta.current_priorities.map((p, i) => (
<PriorityCard key={p.title} p={p} rank={i + 1} />
))}
</>
) : null}
<Text style={{ fontSize: 11, color: C.dim, marginTop: 32 }}>
Personal health data · served from rehbock.xyz over HTTPS · nothing cached on device
</Text>
</Screen>
</>
);
}