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>
This commit is contained in:
2026-08-07 16:38:52 -07:00
parent 36bd13ff59
commit 2a5c956c32
38 changed files with 2060 additions and 1180 deletions

View File

@@ -1,98 +1,143 @@
import * as Device from 'expo-device';
import { Platform, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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';
import { AnimatedIcon } from '@/components/animated-icon';
import { HintRow } from '@/components/hint-row';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
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),
);
function getDevMenuHint() {
if (Platform.OS === 'web') {
return <ThemedText type="small">use browser devtools</ThemedText>;
}
if (Device.isDevice) {
return (
<ThemedText type="small">
shake device or press <ThemedText type="code">m</ThemedText> in terminal
</ThemedText>
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" />,
);
}
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
return (
<ThemedText type="small">
press <ThemedText type="code">{shortcut}</ThemedText>
</ThemedText>
<>
<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>
</>
);
}
export default function HomeScreen() {
return (
<ThemedView style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.heroSection}>
<AnimatedIcon />
<ThemedText type="title" style={styles.title}>
Welcome to&nbsp;Expo
</ThemedText>
</ThemedView>
<ThemedText type="code" style={styles.code}>
get started
</ThemedText>
<ThemedView type="backgroundElement" style={styles.stepContainer}>
<HintRow
title="Try editing"
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
/>
<HintRow title="Dev tools" hint={getDevMenuHint()} />
<HintRow
title="Fresh start"
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
/>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</SafeAreaView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
flexDirection: 'row',
},
safeArea: {
flex: 1,
paddingHorizontal: Spacing.four,
alignItems: 'center',
gap: Spacing.three,
paddingBottom: BottomTabInset + Spacing.three,
maxWidth: MaxContentWidth,
},
heroSection: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
paddingHorizontal: Spacing.four,
gap: Spacing.four,
},
title: {
textAlign: 'center',
},
code: {
textTransform: 'uppercase',
},
stepContainer: {
gap: Spacing.three,
alignSelf: 'stretch',
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.four,
borderRadius: Spacing.four,
},
});