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>
144 lines
5.5 KiB
TypeScript
144 lines
5.5 KiB
TypeScript
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="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" />,
|
||
);
|
||
}
|
||
|
||
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>
|
||
</>
|
||
);
|
||
}
|