Files
health-app/src/app/body.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.8 KiB
TypeScript

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ErrBox, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui';
import { load } from '../lib/api';
import { fmtD, fmtDY, num } from '../lib/format';
import { C } from '../lib/theme';
import type { DexaIndex, DexaResponse } from '../lib/types';
import { useLoad } from '../lib/use-load';
function delta(
curV: number | null | undefined,
prevV: number | null | undefined,
goodDir: -1 | 0 | 1,
unit: string,
prevDate: string,
dp = 1,
): { text: string; tone: 'good' | 'bad' | 'neutral' } | undefined {
if (prevV == null || curV == null) return undefined;
const d = curV - prevV;
if (Math.abs(d) < 1e-9) return { text: 'unchanged', tone: 'neutral' };
const good = goodDir === 0 ? null : (d > 0) === (goodDir > 0);
return {
text: `${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(dp)}${unit} vs ${fmtD(prevDate)}`,
tone: good == null ? 'neutral' : good ? 'good' : 'bad',
};
}
function IdxTable({ title, arr }: { title: string; arr?: DexaIndex[] }) {
if (!Array.isArray(arr) || !arr.length) return null;
return (
<>
<Sec title={title} />
<Panel>
<View style={s.tr}>
<Text style={[s.th, { flex: 2 }]}>MEASURE</Text>
<Text style={s.th}>RESULT</Text>
<Text style={s.th}>%ILE (YN)</Text>
</View>
{arr.map((r) => (
<View key={r.measure} style={s.tr}>
<Text style={[s.td, { flex: 2, color: C.muted }]}>{r.measure}</Text>
<Text style={s.td}>
{r.result}
{r.unit ? ` ${r.unit}` : ''}
</Text>
<Text style={s.td}>{r.percentile_yn != null ? `${r.percentile_yn}%` : '—'}</Text>
</View>
))}
</Panel>
</>
);
}
export default function Body() {
const { data, err, loading, refresh } = useLoad(() => load<DexaResponse>('dexa'));
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data) {
const scans = data.scans;
const cur = scans[scans.length - 1];
const prev = scans.length > 1 ? scans[scans.length - 2] : null;
const sm = cur.summary;
const p = prev?.summary;
const pd = prev?.scan_date ?? '';
const reg = cur.body_composition_by_region;
body = (
<>
<TileGrid>
<Tile label="Body fat" value={String(sm.total_body_fat_pct)} unit="%"
{...toDelta(delta(sm.total_body_fat_pct, p?.total_body_fat_pct, -1, ' pp', pd))} />
<Tile label="Lean mass" value={String(sm.total_lean_mass_kg)} unit="kg"
{...toDelta(delta(sm.total_lean_mass_kg, p?.total_lean_mass_kg, 1, ' kg', pd))} />
<Tile label="Fat mass" value={String(sm.total_fat_mass_kg)} unit="kg"
{...toDelta(delta(sm.total_fat_mass_kg, p?.total_fat_mass_kg, 0, ' kg', pd))} />
<Tile label="Total mass" value={String(sm.total_mass_kg)} unit="kg"
{...toDelta(delta(sm.total_mass_kg, p?.total_mass_kg, 0, ' kg', pd))} />
<Tile label="Visceral fat (VAT)" value={String(sm.est_vat_area_cm2)} unit="cm²"
sub={sm.est_vat_risk}
{...toDelta(delta(sm.est_vat_area_cm2, p?.est_vat_area_cm2, -1, ' cm²', pd))} />
<Tile label="ALMI" value={String(sm.appendicular_lean_mass_to_height2_kg_m2)} unit="kg/m²"
sub={`threshold ${sm.appendicular_lean_mass_normal_threshold_kg_m2}`}
{...toDelta(delta(
sm.appendicular_lean_mass_to_height2_kg_m2,
p?.appendicular_lean_mass_to_height2_kg_m2, 1, '', pd, 2,
))} />
<Tile label="Bone density" value={String(sm.total_body_bmd_g_cm2)} unit="g/cm²"
{...toDelta(delta(sm.total_body_bmd_g_cm2, p?.total_body_bmd_g_cm2, 1, '', pd, 3))} />
<Tile label="Est. RMR" value={num(sm.resting_metabolic_rate_kcal_day)} unit="kcal"
{...toDelta(delta(
sm.resting_metabolic_rate_kcal_day, p?.resting_metabolic_rate_kcal_day, 0, ' kcal', pd, 0,
))} />
</TileGrid>
<SrcNote>
DXA scan {fmtDY(cur.scan_date)} {cur.provider}, {cur.model || 'Hologic Horizon'}.
{prev ? ` Deltas vs ${fmtDY(prev.scan_date)}.` : ''}
</SrcNote>
{Array.isArray(reg) && reg.length ? (
<>
<Sec title="By region" />
<Panel>
<View style={s.tr}>
<Text style={[s.th, { flex: 2 }]}>REGION</Text>
<Text style={s.th}>FAT %</Text>
<Text style={s.th}>LEAN KG</Text>
<Text style={s.th}>FAT KG</Text>
</View>
{reg.map((r) => (
<View key={r.region} style={s.tr}>
<Text style={[s.td, { flex: 2, color: C.muted }]}>{r.region}</Text>
<Text style={s.td}>{r.fat_pct ?? '—'}</Text>
<Text style={s.td}>{r.lean_mass_g ? (r.lean_mass_g / 1000).toFixed(1) : '—'}</Text>
<Text style={s.td}>{r.fat_mass_g ? (r.fat_mass_g / 1000).toFixed(1) : '—'}</Text>
</View>
))}
</Panel>
</>
) : null}
<IdxTable title="Adipose indices" arr={cur.adipose_indices} />
<IdxTable title="Lean indices" arr={cur.lean_indices} />
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}
function toDelta(d?: { text: string; tone: 'good' | 'bad' | 'neutral' }) {
return d ? { delta: d.text, deltaTone: d.tone } : {};
}
const s = StyleSheet.create({
tr: {
flexDirection: 'row', gap: 8, paddingVertical: 6,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#1e1e23',
},
th: { flex: 1, fontSize: 10, letterSpacing: 0.5, color: C.muted, fontWeight: '600' },
td: { flex: 1, fontSize: 12, color: C.text, fontVariant: ['tabular-nums'] },
});