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.8 KiB
TypeScript
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'] },
|
|
});
|