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:
@@ -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="Couldn’t 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="Couldn’t 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="Couldn’t 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="Couldn’t 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="Couldn’t 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 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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user