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,18 +1,22 @@
{
"expo": {
"name": "health-app",
"name": "Health",
"slug": "health-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "healthapp",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "dark",
"backgroundColor": "#0d0d0f",
"ios": {
"icon": "./assets/expo.icon"
"icon": "./assets/expo.icon",
"bundleIdentifier": "xyz.rehbock.health",
"supportsTablet": true
},
"android": {
"package": "xyz.rehbock.health",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundColor": "#0d0d0f",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
@@ -25,10 +29,11 @@
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-splash-screen",
{
"backgroundColor": "#208AEF",
"backgroundColor": "#0d0d0f",
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}

167
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"expo-image": "~57.0.2",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
"expo-secure-store": "~57.0.1",
"expo-splash-screen": "~57.0.5",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
@@ -29,6 +30,7 @@
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},
@@ -2975,6 +2977,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -3404,6 +3412,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3518,6 +3576,61 @@
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3545,6 +3658,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -3913,6 +4038,15 @@
}
}
},
"node_modules/expo-secure-store": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz",
"integrity": "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-server": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz",
@@ -5311,6 +5445,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0"
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -5835,6 +5975,18 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -6474,6 +6626,21 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.15.4",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
"integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",

View File

@@ -12,6 +12,7 @@
"expo-image": "~57.0.2",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
"expo-secure-store": "~57.0.1",
"expo-splash-screen": "~57.0.5",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
@@ -24,6 +25,7 @@
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},

View File

@@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -1,18 +1,40 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useColorScheme } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { View } from 'react-native';
import { AuthProvider, useAuth } from '../lib/api';
import { C } from '../lib/theme';
import { LoginScreen } from '../components/login';
import { AnimatedSplashOverlay } from '@/components/animated-icon';
import AppTabs from '@/components/app-tabs';
SplashScreen.preventAutoHideAsync();
export default function TabLayout() {
const colorScheme = useColorScheme();
function Gate() {
const { ready, authed } = useAuth();
if (!ready) return <View style={{ flex: 1, backgroundColor: C.bg }} />;
if (!authed) return <LoginScreen />;
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AnimatedSplashOverlay />
<AppTabs />
</ThemeProvider>
<Stack
screenOptions={{
headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text,
headerTitleStyle: { fontWeight: '600', fontSize: 17 },
headerShadowVisible: false,
contentStyle: { backgroundColor: C.bg },
}}>
<Stack.Screen name="index" options={{ title: 'Health' }} />
<Stack.Screen name="blood" options={{ title: 'Bloodwork' }} />
<Stack.Screen name="sleep" options={{ title: 'Sleep' }} />
<Stack.Screen name="train" options={{ title: 'Training' }} />
<Stack.Screen name="body" options={{ title: 'Body Composition' }} />
<Stack.Screen name="mind" options={{ title: 'Meditation' }} />
<Stack.Screen name="dna" options={{ title: 'Genetic Traits' }} />
</Stack>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<StatusBar style="light" />
<Gate />
</AuthProvider>
);
}

160
src/app/blood.tsx Normal file
View File

@@ -0,0 +1,160 @@
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Sparkline } from '../components/charts';
import { ErrBox, Loading, PriorityCard, Screen, SearchInput, Sec } from '../components/ui';
import { load } from '../lib/api';
import { cap, fmtD, fmtDY } from '../lib/format';
import { shapeBiomarkers } from '../lib/shape';
import { C } from '../lib/theme';
import type { BiomarkersResponse, DrawsResponse, HealthMeta, MarkerGroup } from '../lib/types';
import { useLoad } from '../lib/use-load';
import { useWindowDimensions } from 'react-native';
function MarkerCard({ m, sparkW }: { m: MarkerGroup; sparkW: number }) {
const L = m.latest;
return (
<View style={[s.card, L.flagged && { borderColor: 'rgba(255,69,58,0.35)' }]}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 12, color: C.muted, textTransform: 'capitalize' }}>
{cap(m.name)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 5, marginTop: 2 }}>
<Text style={{ fontSize: 22, fontWeight: '300', letterSpacing: -0.5, color: C.text }}>
{String(L.value)}
</Text>
<Text style={{ fontSize: 11, color: C.muted }}>{L.unit || ''}</Text>
</View>
<Text style={{ fontSize: 10.5, color: C.dim, marginTop: 2 }}>
{L.reference_range ? `ref ${L.reference_range} · ` : ''}
{fmtDY(L.collected)} · {cap(L.panel)}
</Text>
</View>
<View style={[s.status, L.flagged ? s.statusFlag : s.statusOk]}>
<Text style={{
fontSize: 9.5, fontWeight: '700', letterSpacing: 0.4,
color: L.flagged ? C.red : C.green,
}}>
{L.flagged ? 'FLAG' : 'OK'}
</Text>
</View>
</View>
<Sparkline pts={m.series} flagged={L.flagged} width={sparkW} />
</View>
);
}
export default function Blood() {
const { width } = useWindowDimensions();
const sparkW = Math.min(608, width - 32) - 28;
const [q, setQ] = useState('');
const { data, err, loading, refresh } = useLoad(() =>
Promise.all([
load<BiomarkersResponse>('biomarkers'),
load<HealthMeta>('meta'),
load<DrawsResponse>('draws'),
]),
);
const marks = useMemo(() => (data ? shapeBiomarkers(data[0]) : []), [data]);
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data) {
const [bio, meta, draws] = data;
const latestDraw = bio.biomarkers.reduce((m, b) => (b.collected > m ? b.collected : m), '');
const flaggedNow = marks.filter((m) => m.latest.flagged);
const ql = q.trim().toLowerCase();
const subset = ql
? marks.filter((m) => cap(m.name).toLowerCase().includes(ql) || m.panel.includes(ql))
: marks;
const flagged = subset.filter((m) => m.latest.flagged);
const rest = subset.filter((m) => !m.latest.flagged);
body = (
<>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 4 }}>
<Pill num={String(marks.length)} lbl="Markers" />
<Pill num={String(draws.draws.length)} lbl="Draws" />
<Pill num={String(flaggedNow.length)} lbl="Flagged" color={flaggedNow.length ? C.red : C.green} />
<Pill num={fmtD(latestDraw)} lbl="Last draw" small />
</View>
{meta.current_priorities?.length ? (
<>
<Sec title="Priorities" />
{meta.current_priorities.map((p, i) => (
<PriorityCard key={p.title} p={p} rank={i + 1} />
))}
</>
) : null}
<Sec title="All markers" count={marks.length} />
<SearchInput value={q} onChange={setQ} placeholder="Search markers…" />
{flagged.length ? (
<>
<Sec title="Flagged" count={flagged.length} />
{flagged.map((m) => <MarkerCard key={m.name} m={m} sparkW={sparkW} />)}
</>
) : null}
{rest.length ? (
<>
{flagged.length ? <Sec title="In range" count={rest.length} /> : null}
{rest.map((m) => <MarkerCard key={m.name} m={m} sparkW={sparkW} />)}
</>
) : null}
{!subset.length ? (
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', padding: 24 }}>
No markers match.
</Text>
) : null}
{meta.tests_never_done?.length ? (
<>
<Sec title="Never tested" count={meta.tests_never_done.length} />
{meta.tests_never_done.map((t) => (
<View key={t} style={s.missing}>
<Text style={{ color: C.muted, fontSize: 12 }}> {t}</Text>
</View>
))}
</>
) : null}
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}
function Pill({ num, lbl, color, small }: { num: string; lbl: string; color?: string; small?: boolean }) {
return (
<View style={s.pill}>
<Text style={{
fontSize: small ? 14 : 19, fontWeight: '600', color: color || C.text,
paddingTop: small ? 5 : 0,
}}>
{num}
</Text>
<Text style={{ fontSize: 10, color: C.muted, letterSpacing: 0.5 }}>{lbl.toUpperCase()}</Text>
</View>
);
}
const s = StyleSheet.create({
card: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
},
status: { paddingVertical: 2, paddingHorizontal: 8, borderRadius: 10 },
statusOk: { backgroundColor: C.greenDim },
statusFlag: { backgroundColor: C.redDim },
pill: {
borderWidth: 1, borderColor: C.border, borderRadius: 10, paddingVertical: 8,
paddingHorizontal: 14, backgroundColor: C.surface,
},
missing: {
borderWidth: 1, borderStyle: 'dashed', borderColor: C.border, borderRadius: 10,
paddingVertical: 9, paddingHorizontal: 13, backgroundColor: C.surface, marginBottom: 6,
},
});

143
src/app/body.tsx Normal file
View File

@@ -0,0 +1,143 @@
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'] },
});

99
src/app/dna.tsx Normal file
View File

@@ -0,0 +1,99 @@
import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ErrBox, Loading, Screen, SearchInput, SrcNote } from '../components/ui';
import { load } from '../lib/api';
import { C } from '../lib/theme';
import type { TraitsResponse } from '../lib/types';
import { useLoad } from '../lib/use-load';
export default function Dna() {
const [cat, setCat] = useState('all');
const [q, setQ] = useState('');
const { data, err, loading, refresh } = useLoad(() => load<TraitsResponse>('traits'));
const cats = useMemo(
() => (data ? ['all', ...new Set(data.traits.map((t) => t.category))] : ['all']),
[data],
);
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data) {
const ql = q.trim().toLowerCase();
const subset = data.traits.filter(
(t) =>
(cat === 'all' || t.category === cat) &&
(!ql ||
t.trait.toLowerCase().includes(ql) ||
(t.result || '').toLowerCase().includes(ql)),
);
body = (
<>
<SearchInput value={q} onChange={setQ} placeholder={`Search ${data.trait_count} traits…`} />
<ScrollView horizontal showsHorizontalScrollIndicator={false}
style={{ marginBottom: 8 }} contentContainerStyle={{ gap: 6, paddingBottom: 6 }}>
{cats.map((c) => (
<Pressable key={c} onPress={() => setCat(c)}
style={[s.chip, cat === c && s.chipActive]}>
<Text style={{
fontSize: 12, color: cat === c ? C.accent : C.muted,
textTransform: 'capitalize',
}}>
{c}
</Text>
</Pressable>
))}
</ScrollView>
{subset.map((t) => (
<View key={t.trait} style={s.trait}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10, alignItems: 'baseline' }}>
<Text style={{ fontSize: 13, fontWeight: '500', color: C.text, flex: 1 }}>{t.trait}</Text>
<Text style={{ fontSize: 10, color: C.dim, letterSpacing: 0.5, textTransform: 'uppercase' }}>
{t.category}
</Text>
</View>
<Text style={{ fontSize: 12, color: C.accent, marginTop: 2 }}>{t.result}</Text>
{t.position_estimate != null && t.scale ? (
<View style={{ marginTop: 9 }}>
<View style={s.track}>
<View style={[s.dot, { left: `${Math.round(t.position_estimate * 100)}%` }]} />
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 }}>
<Text style={{ fontSize: 10, color: C.dim }}>{t.scale.left}</Text>
<Text style={{ fontSize: 10, color: C.dim }}>{t.scale.right}</Text>
</View>
</View>
) : null}
</View>
))}
{!subset.length ? (
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', padding: 24 }}>
No traits match.
</Text>
) : null}
<SrcNote>{data.source} slider positions are visual estimates from the report.</SrcNote>
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}
const s = StyleSheet.create({
chip: {
paddingVertical: 5, paddingHorizontal: 12, borderRadius: 16, borderWidth: 1,
borderColor: C.border, backgroundColor: C.surface,
},
chipActive: { borderColor: C.accent, backgroundColor: C.accentDim },
trait: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
},
track: { height: 4, borderRadius: 2, backgroundColor: C.surface2 },
dot: {
position: 'absolute', top: -4, marginLeft: -5.5, width: 11, height: 11,
borderRadius: 5.5, backgroundColor: C.accent, borderWidth: 2, borderColor: C.bg,
},
});

View File

@@ -1,180 +0,0 @@
import { Image } from 'expo-image';
import { SymbolView } from 'expo-symbols';
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ExternalLink } from '@/components/external-link';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Collapsible } from '@/components/ui/collapsible';
import { WebBadge } from '@/components/web-badge';
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export default function TabTwoScreen() {
const safeAreaInsets = useSafeAreaInsets();
const insets = {
...safeAreaInsets,
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
};
const theme = useTheme();
const contentPlatformStyle = Platform.select({
android: {
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
},
web: {
paddingTop: Spacing.six,
paddingBottom: Spacing.four,
},
});
return (
<ScrollView
style={[styles.scrollView, { backgroundColor: theme.background }]}
contentInset={insets}
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
<ThemedView style={styles.container}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="subtitle">Explore</ThemedText>
<ThemedText style={styles.centerText} themeColor="textSecondary">
This starter app includes example{'\n'}code to help you get started.
</ThemedText>
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={({ pressed }) => pressed && styles.pressed}>
<ThemedView type="backgroundElement" style={styles.linkButton}>
<ThemedText type="link">Expo documentation</ThemedText>
<SymbolView
tintColor={theme.text}
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
size={12}
/>
</ThemedView>
</Pressable>
</ExternalLink>
</ThemedView>
<ThemedView style={styles.sectionsWrapper}>
<Collapsible title="File-based routing">
<ThemedText type="small">
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
<ThemedText type="code">src/app/explore.tsx</ThemedText>
</ThemedText>
<ThemedText type="small">
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
<ThemedText type="small">
You can open this project on Android, iOS, and the web. To open the web version,
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
project.
</ThemedText>
<Image
source={require('@/assets/images/tutorial-web.png')}
style={styles.imageTutorial}
/>
</ThemedView>
</Collapsible>
<Collapsible title="Images">
<ThemedText type="small">
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
screen densities.
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText type="small">
This template has light and dark mode support. The{' '}
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="linkPrimary">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText type="small">
This template includes an example of an animated component. The{' '}
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
animate opening this hint.
</ThemedText>
</Collapsible>
</ThemedView>
{Platform.OS === 'web' && <WebBadge />}
</ThemedView>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
contentContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
container: {
maxWidth: MaxContentWidth,
flexGrow: 1,
},
titleContainer: {
gap: Spacing.three,
alignItems: 'center',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.six,
},
centerText: {
textAlign: 'center',
},
pressed: {
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
paddingHorizontal: Spacing.four,
paddingVertical: Spacing.two,
borderRadius: Spacing.five,
justifyContent: 'center',
gap: Spacing.one,
alignItems: 'center',
},
sectionsWrapper: {
gap: Spacing.five,
paddingHorizontal: Spacing.four,
paddingTop: Spacing.three,
},
collapsibleContent: {
alignItems: 'center',
},
imageTutorial: {
width: '100%',
aspectRatio: 296 / 171,
borderRadius: Spacing.three,
marginTop: Spacing.two,
},
imageReact: {
width: 100,
height: 100,
alignSelf: 'center',
},
});

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,
},
});

79
src/app/mind.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ErrBox, Loading, Screen, Sec, Tile, TileGrid } from '../components/ui';
import { load } from '../lib/api';
import { agoTxt, fmtDY, hm } from '../lib/format';
import { C } from '../lib/theme';
import type { MeditationSessionsResponse, MeditationStats } from '../lib/types';
import { useLoad } from '../lib/use-load';
export default function Mind() {
const { data, err, loading, refresh } = useLoad(() =>
Promise.all([
load<MeditationStats>('medStats'),
load<MeditationSessionsResponse>('medSessions'),
]),
);
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data) {
const [stats, sess] = data;
const list = [...(sess.sessions || [])].sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
const rated = list.filter((x) => x.rating);
const avgR = rated.length ? rated.reduce((sum, x) => sum + x.rating!, 0) / rated.length : null;
body = (
<>
<TileGrid>
<Tile label="Total time" value={String(stats.totalMinutes)} unit="min"
sub={`${stats.totalSessions} sessions`} />
<Tile label="Current streak" value={String(stats.currentStreak)} unit="days" />
<Tile label="Avg rating" value={avgR ? avgR.toFixed(1) : '—'}
unit={avgR ? '/ 5' : undefined} sub={`${rated.length} rated`} />
<Tile label="Last sit"
value={list.length
? hm((new Date(list[0].endedAt).getTime() - new Date(list[0].startedAt).getTime()) / 1000)
: '—'}
sub={list.length ? agoTxt(list[0].startedAt) : undefined} />
</TileGrid>
<Sec title="Sessions" count={list.length} />
{list.map((x) => (
<View key={x.startedAt} style={s.row}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
<Text style={{ fontSize: 13.5, fontWeight: '500', color: C.text }}>
{x.actualMin} min
{!x.completed && (
<Text style={{ color: C.yellow, fontSize: 11 }}>
{' '}· {x.completionPct}% of {x.plannedMin}
</Text>
)}
</Text>
<Text style={{ fontSize: 11, color: C.muted }}>{fmtDY(x.startedAt)}</Text>
</View>
{x.rating ? (
<Text style={{ color: C.yellow, letterSpacing: 1, fontSize: 12, marginTop: 2 }}>
{'★'.repeat(x.rating)}{'☆'.repeat(5 - x.rating)}
</Text>
) : null}
{x.note ? (
<Text style={{ fontSize: 12, color: C.muted, marginTop: 6, fontStyle: 'italic' }}>
{x.note}
</Text>
) : null}
</View>
))}
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}
const s = StyleSheet.create({
row: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
},
});

67
src/app/sleep.tsx Normal file
View File

@@ -0,0 +1,67 @@
import React from 'react';
import { useWindowDimensions } from 'react-native';
import { SleepChart } from '../components/charts';
import { ErrBox, LegendRow, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui';
import { load } from '../lib/api';
import { fmtDY, hm, num } from '../lib/format';
import { shapeSleep } from '../lib/shape';
import { C } from '../lib/theme';
import type { SleepApiResponse, SleepCycleExport } from '../lib/types';
import { useLoad } from '../lib/use-load';
export default function Sleep() {
const { width } = useWindowDimensions();
const chartW = Math.min(608, width - 32) - 28;
const { data, err, loading, refresh } = useLoad(() =>
Promise.all([load<SleepApiResponse>('sleepApi'), load<SleepCycleExport>('sleepCycle')]),
);
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data) {
const [api, sc] = data;
const nights = shapeSleep(api, sc);
const recent = nights.slice(-30);
const last7 = nights.slice(-7);
const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) =>
arr.reduce((sum, x) => sum + f(x), 0) / (arr.length || 1);
const a7 = avg(last7, (t) => t.asleep);
const deepPct =
avg(last7.filter((t) => t.deep), (t) => t.deep / (t.asleep || 1)) * 100;
const esNights = nights.filter((t) => t.hrv);
const lastHrv = esNights.length ? esNights[esNights.length - 1] : null;
const last = nights[nights.length - 1];
body = (
<>
<TileGrid>
<Tile label="Last night on record" value={hm(last.asleep)}
sub={`${fmtDY(last.date)} · ${last.src}`} />
<Tile label="Avg · last 7 recorded" value={hm(a7)} />
<Tile label="Deep sleep share" value={deepPct ? deepPct.toFixed(0) : '—'}
unit={deepPct ? '%' : undefined} sub="of asleep time, when staged" />
{lastHrv ? (
<Tile label="HRV · last Eight Sleep night" value={String(Math.round(lastHrv.hrv!))}
unit="ms" sub={`${fmtDY(lastHrv.date)} · HR ${Math.round(lastHrv.hr!)} bpm`} />
) : null}
</TileGrid>
<Sec title="Last 30 recorded nights" />
<Panel title="Time asleep by stage" sub="tap a bar">
<SleepChart nights={recent} width={chartW} />
<LegendRow
items={[
['Deep', C.s1], ['REM', C.s2], ['Light / unstaged', C.s3], ['Awake', C.awake],
]}
/>
</Panel>
<SrcNote>
Sources: Eight Sleep pod (through Feb 2026, incl. HRV/HR) and Sleep Cycle app export
(through {sc.exported_at}). {num(nights.length)} nights total on record.
</SrcNote>
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}

102
src/app/train.tsx Normal file
View File

@@ -0,0 +1,102 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { VolumeChart } from '../components/charts';
import { ErrBox, Loading, Panel, Screen, Sec, Tile, TileGrid } from '../components/ui';
import { load } from '../lib/api';
import { agoTxt, fmtD, fmtDY, num } from '../lib/format';
import { shapeWeeks, weekStart, workoutVolume } from '../lib/shape';
import { C } from '../lib/theme';
import type { Workout, WorkoutsResponse } from '../lib/types';
import { useLoad } from '../lib/use-load';
function WorkoutRow({ w }: { w: Workout }) {
const [open, setOpen] = useState(false);
const { vol, sets } = workoutVolume(w);
const mins = Math.round(
(new Date(w.end_time).getTime() - new Date(w.start_time).getTime()) / 60000,
);
return (
<Pressable style={s.row} onPress={() => setOpen(!open)}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
<Text style={{ fontSize: 13.5, fontWeight: '500', color: C.text, flex: 1 }}>{w.title}</Text>
<Text style={{ fontSize: 11, color: C.muted }}>{fmtDY(w.start_time)}</Text>
</View>
<Text style={{ fontSize: 12, color: C.muted, marginTop: 3 }}>
{mins} min · {w.exercises.length} exercises · {sets} sets · {num(Math.round(vol))} kg
</Text>
{open && (
<View style={s.detail}>
{(w.exercises || []).map((ex, i) => {
const ss = (ex.sets || [])
.map((st) => {
if (st.reps != null) return st.weight_kg ? `${st.reps}×${st.weight_kg}kg` : `${st.reps}`;
if (st.duration_seconds) return `${Math.round(st.duration_seconds / 60)}min`;
return '—';
})
.join(', ');
return (
<Text key={i} style={{ fontSize: 12, marginVertical: 3 }}>
<Text style={{ color: C.text }}>{ex.title}</Text>
<Text style={{ color: C.muted }}> {ss}</Text>
</Text>
);
})}
</View>
)}
</Pressable>
);
}
export default function Train() {
const { width } = useWindowDimensions();
const chartW = Math.min(608, width - 32) - 28;
const { data, err, loading, refresh } = useLoad(() => load<WorkoutsResponse>('workouts'));
const shaped = useMemo(() => {
if (!data) return null;
const ws = [...data.workouts].sort((a, b) => (a.start_time < b.start_time ? -1 : 1));
return { ws, weeks: shapeWeeks(ws, 16) };
}, [data]);
let body: React.ReactNode = null;
if (loading && !data) body = <Loading />;
else if (err) body = <ErrBox msg={err} />;
else if (data && shaped) {
const { ws, weeks } = shaped;
const thisWk = weekStart(new Date().toISOString());
const wkCount = ws.filter((w) => weekStart(w.start_time) === thisWk).length;
const last8 = weeks.slice(-8);
const avgWk = last8.reduce((sum, w) => sum + w.count, 0) / (last8.length || 1);
const last = ws[ws.length - 1];
body = (
<>
<TileGrid>
<Tile label="Workouts logged" value={String(ws.length)} sub="via Hevy" />
<Tile label="This week" value={String(wkCount)}
sub={`avg ${avgWk.toFixed(1)}/week over 8 weeks`} />
<Tile label="Last session" value={last.title} sub={agoTxt(last.start_time)} />
<Tile label="Last synced" value={fmtD(data.synced_at)} sub="syncs every 6 h" />
</TileGrid>
<Sec title="Weekly volume · 16 weeks" />
<Panel title="Lifted volume (kg)" sub="tap a bar">
<VolumeChart weeks={weeks} width={chartW} />
</Panel>
<Sec title="Recent workouts" />
{ws.slice(-20).reverse().map((w) => (
<WorkoutRow key={w.start_time} w={w} />
))}
</>
);
}
return <Screen onRefresh={refresh}>{body}</Screen>;
}
const s = StyleSheet.create({
row: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
},
detail: { marginTop: 10, borderTopWidth: 1, borderTopColor: C.border, paddingTop: 10 },
});

View File

@@ -1,6 +0,0 @@
.expoLogoBackground {
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
border-radius: 40px;
width: 128px;
height: 128px;
}

View File

@@ -1,148 +0,0 @@
import { Image } from 'expo-image';
import * as SplashScreen from 'expo-splash-screen';
import { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
const DURATION = 600;
export function AnimatedSplashOverlay() {
const [animate, setAnimate] = useState(false);
const [visible, setVisible] = useState(true);
if (!visible) return null;
const splashKeyframe = new Keyframe({
0: {
transform: [{ scale: 1 }],
opacity: 1,
},
20: {
opacity: 1,
},
70: {
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 0,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const image = <Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />;
return animate ? (
<Animated.View
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
'worklet';
if (finished) {
scheduleOnRN(setVisible, false);
}
})}
style={styles.splashOverlay}>
{image}
</Animated.View>
) : (
<View
onLayout={() => {
SplashScreen.hideAsync().finally(() => {
setAnimate(true);
});
}}
style={styles.splashOverlay}>
{image}
</View>
);
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: INITIAL_SCALE_FACTOR }],
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const logoKeyframe = new Keyframe({
0: {
transform: [{ scale: 1.3 }],
opacity: 0,
},
40: {
transform: [{ scale: 1.3 }],
opacity: 0,
easing: Easing.elastic(0.7),
},
100: {
opacity: 1,
transform: [{ scale: 1 }],
easing: Easing.elastic(0.7),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '0deg' }],
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
zIndex: 100,
},
image: {
width: 76,
height: 71,
},
background: {
borderRadius: 40,
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
width: 128,
height: 128,
position: 'absolute',
},
splashOverlay: {
...StyleSheet.absoluteFill,
backgroundColor: '#208AEF',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
});

View File

@@ -1,108 +0,0 @@
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
import classes from './animated-icon.module.css';
const DURATION = 300;
export function AnimatedSplashOverlay() {
return null;
}
const keyframe = new Keyframe({
0: {
transform: [{ scale: 0 }],
},
60: {
transform: [{ scale: 1.2 }],
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
easing: Easing.elastic(1.2),
},
});
const logoKeyframe = new Keyframe({
0: {
opacity: 0,
},
60: {
transform: [{ scale: 1.2 }],
opacity: 0,
easing: Easing.elastic(1.2),
},
100: {
transform: [{ scale: 1 }],
opacity: 1,
easing: Easing.elastic(1.2),
},
});
const glowKeyframe = new Keyframe({
0: {
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
opacity: 0,
},
[DURATION / 1000]: {
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
opacity: 1,
easing: Easing.elastic(0.7),
},
100: {
transform: [{ rotateZ: '7200deg' }],
},
});
export function AnimatedIcon() {
return (
<View style={styles.iconContainer}>
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
</Animated.View>
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
<div className={classes.expoLogoBackground} />
</Animated.View>
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
zIndex: 1000,
position: 'absolute',
top: 128 / 2 + 138,
},
imageContainer: {
justifyContent: 'center',
alignItems: 'center',
},
glow: {
width: 201,
height: 201,
position: 'absolute',
},
iconContainer: {
justifyContent: 'center',
alignItems: 'center',
width: 128,
height: 128,
},
image: {
position: 'absolute',
width: 76,
height: 71,
},
background: {
width: 128,
height: 128,
position: 'absolute',
},
});

View File

@@ -1,32 +0,0 @@
import { NativeTabs } from 'expo-router/unstable-native-tabs';
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/theme';
export default function AppTabs() {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<NativeTabs
backgroundColor={colors.background}
indicatorColor={colors.backgroundElement}
labelStyle={{ selected: { color: colors.text } }}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/home.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon
src={require('@/assets/images/tabIcons/explore.png')}
renderingMode="template"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@@ -1,115 +0,0 @@
import {
Tabs,
TabList,
TabTrigger,
TabSlot,
TabTriggerSlotProps,
TabListProps,
} from 'expo-router/ui';
import { SymbolView } from 'expo-symbols';
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
import { ExternalLink } from './external-link';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
export default function AppTabs() {
return (
<Tabs>
<TabSlot style={{ height: '100%' }} />
<TabList asChild>
<CustomTabList>
<TabTrigger name="home" href="/" asChild>
<TabButton>Home</TabButton>
</TabTrigger>
<TabTrigger name="explore" href="/explore" asChild>
<TabButton>Explore</TabButton>
</TabTrigger>
</CustomTabList>
</TabList>
</Tabs>
);
}
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
return (
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
<ThemedView
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
style={styles.tabButtonView}>
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
{children}
</ThemedText>
</ThemedView>
</Pressable>
);
}
export function CustomTabList(props: TabListProps) {
const scheme = useColorScheme();
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
return (
<View {...props} style={styles.tabListContainer}>
<ThemedView type="backgroundElement" style={styles.innerContainer}>
<ThemedText type="smallBold" style={styles.brandText}>
Expo Starter
</ThemedText>
{props.children}
<ExternalLink href="https://docs.expo.dev" asChild>
<Pressable style={styles.externalPressable}>
<ThemedText type="link">Docs</ThemedText>
<SymbolView
tintColor={colors.text}
name={{ ios: 'arrow.up.right.square', web: 'link' }}
size={12}
/>
</Pressable>
</ExternalLink>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
tabListContainer: {
position: 'absolute',
width: '100%',
padding: Spacing.three,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
innerContainer: {
paddingVertical: Spacing.two,
paddingHorizontal: Spacing.five,
borderRadius: Spacing.five,
flexDirection: 'row',
alignItems: 'center',
flexGrow: 1,
gap: Spacing.two,
maxWidth: MaxContentWidth,
},
brandText: {
marginRight: 'auto',
},
pressed: {
opacity: 0.7,
},
tabButtonView: {
paddingVertical: Spacing.one,
paddingHorizontal: Spacing.three,
borderRadius: Spacing.three,
},
externalPressable: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.one,
marginLeft: Spacing.three,
},
});

189
src/components/charts.tsx Normal file
View File

@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { Text, View } from 'react-native';
import Svg, { Circle, G, Line, Path, Rect, Text as SvgText } from 'react-native-svg';
import { fmtD, fmtDY, hm, kfmt, num } from '../lib/format';
import { C } from '../lib/theme';
import type { Night, Week } from '../lib/types';
const DetailRow = ({ k, v, dot }: { k: string; v: string; dot?: string }) => (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 2 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
{dot ? <View style={{ width: 7, height: 7, borderRadius: 2, backgroundColor: dot }} /> : null}
<Text style={{ color: C.muted, fontSize: 11.5 }}>{k}</Text>
</View>
<Text style={{ color: C.text, fontSize: 11.5, fontWeight: '500' }}>{v}</Text>
</View>
);
function DetailCard({ title, rows }: { title: string; rows: [string, string, string?][] }) {
return (
<View
style={{
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.border,
borderRadius: 10, padding: 10, marginTop: 10,
}}>
<Text style={{ color: C.text, fontSize: 11.5, fontWeight: '600', marginBottom: 3 }}>
{title}
</Text>
{rows.map(([k, v, dot]) => (
<DetailRow key={k} k={k} v={v} dot={dot} />
))}
</View>
);
}
/* stacked nightly sleep bars — tap a bar for the night's detail */
export function SleepChart({ nights, width }: { nights: Night[]; width: number }) {
const [sel, setSel] = useState<number | null>(null);
const H = 150, padL = 26, padB = 16, W = width;
const iw = W - padL, n = nights.length, bw = Math.max(3, iw / n - 2);
const maxH = Math.max(...nights.map((t) => t.total / 3600), 9);
const yMax = Math.ceil(maxH);
const y = (v: number) => H - padB - (v / yMax) * (H - padB - 6);
const grid = [];
for (let g = 0; g <= yMax; g += 3) grid.push(g);
const selN = sel != null ? nights[sel] : null;
return (
<View>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
{grid.map((g) => (
<G key={g}>
<Line x1={padL} y1={y(g)} x2={W} y2={y(g)} stroke={C.gridline} strokeWidth={1} />
<SvgText x={padL - 5} y={y(g) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{g}h
</SvgText>
</G>
))}
{nights.map((t, i) => {
const x = padL + (i + 0.5) * (iw / n) - bw / 2;
let acc = 0;
const segs: [number, string][] = [
[t.deep, C.s1], [t.rem, C.s2], [t.light, C.s3], [t.awake, C.awake],
];
return (
<G key={t.date} onPress={() => setSel(i === sel ? null : i)}>
<Rect x={x - 1} y={6} width={bw + 2} height={H - padB - 6} fill="transparent" />
{segs.map(([secs, color], j) => {
const v = secs / 3600;
if (v <= 0.008) return null;
const y1 = y(acc + v), y2 = y(acc);
acc += v;
return (
<Rect key={j} x={x} y={y1} width={bw} height={Math.max(1, y2 - y1 - 1)}
rx={1.5} fill={color} opacity={sel == null || sel === i ? 1 : 0.35} />
);
})}
</G>
);
})}
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>
{fmtD(nights[0].date)}
</SvgText>
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{fmtD(nights[n - 1].date)}
</SvgText>
</Svg>
{selN && (
<DetailCard
title={fmtDY(selN.date)}
rows={[
['Asleep', hm(selN.asleep)],
...(selN.deep ? ([['Deep', hm(selN.deep), C.s1]] as [string, string, string][]) : []),
...(selN.rem ? ([['REM', hm(selN.rem), C.s2]] as [string, string, string][]) : []),
...(selN.light ? ([['Light', hm(selN.light), C.s3]] as [string, string, string][]) : []),
...(selN.awake ? ([['Awake', hm(selN.awake), C.awake]] as [string, string, string][]) : []),
...(selN.hrv ? ([['HRV', `${Math.round(selN.hrv)} ms`]] as [string, string][]) : []),
...(selN.hr ? ([['Resting HR', `${Math.round(selN.hr)} bpm`]] as [string, string][]) : []),
...(selN.quality != null ? ([['Quality', `${selN.quality}%`]] as [string, string][]) : []),
['Source', selN.src],
]}
/>
)}
</View>
);
}
/* weekly training volume bars — tap a bar for the week's detail */
export function VolumeChart({ weeks, width }: { weeks: Week[]; width: number }) {
const [sel, setSel] = useState<number | null>(null);
const H = 140, padL = 30, padB = 16, W = width;
const iw = W - padL, n = weeks.length, bw = Math.max(4, iw / n - 3);
const maxV = Math.max(...weeks.map((w) => w.vol), 1);
const y = (v: number) => H - padB - (v / maxV) * (H - padB - 8);
const selW = sel != null ? weeks[sel] : null;
return (
<View>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
{[0, 0.5, 1].map((f) => (
<G key={f}>
<Line x1={padL} y1={y(maxV * f)} x2={W} y2={y(maxV * f)} stroke={C.gridline} strokeWidth={1} />
<SvgText x={padL - 5} y={y(maxV * f) + 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{kfmt(maxV * f)}
</SvgText>
</G>
))}
{weeks.map((w, i) => {
const x = padL + (i + 0.5) * (iw / n) - bw / 2;
return (
<G key={w.start} onPress={() => setSel(i === sel ? null : i)}>
<Rect x={x - 1.5} y={8} width={bw + 3} height={H - padB - 8} fill="transparent" />
<Rect x={x} y={y(w.vol)} width={bw} height={Math.max(1, H - padB - y(w.vol))}
rx={2} fill={C.s1} opacity={sel == null || sel === i ? 1 : 0.35} />
</G>
);
})}
<SvgText x={padL} y={H - 3} fill={C.muted} fontSize={9.5}>
{fmtD(weeks[0].start)}
</SvgText>
<SvgText x={W} y={H - 3} textAnchor="end" fill={C.muted} fontSize={9.5}>
{fmtD(weeks[n - 1].start)}
</SvgText>
</Svg>
{selW && (
<DetailCard
title={`Week of ${fmtDY(selW.start)}`}
rows={[
['Volume', `${num(Math.round(selW.vol))} kg`],
['Workouts', String(selW.count)],
['Sets', String(selW.sets)],
]}
/>
)}
</View>
);
}
/* biomarker sparkline */
export function Sparkline({
pts, flagged, width,
}: {
pts: { d: string; v: number }[];
flagged: boolean;
width: number;
}) {
if (pts.length < 2) return null;
const W = width, H = 30, p = 4;
const vs = pts.map((q) => q.v);
const min = Math.min(...vs), max = Math.max(...vs), span = max - min || 1;
const x = (i: number) => p + (i * (W - 2 * p)) / (pts.length - 1);
const y = (v: number) => H - p - ((v - min) / span) * (H - 2 * p);
const d = pts.map((q, i) => (i ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(q.v).toFixed(1)).join(' ');
const last = pts[pts.length - 1];
return (
<View style={{ marginTop: 6 }}>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Path d={d} fill="none" stroke={C.s1} strokeWidth={2} strokeLinecap="round" opacity={0.7} />
<Circle cx={x(pts.length - 1)} cy={y(last.v)} r={3.5}
fill={flagged ? C.red : C.s1} stroke={C.surface} strokeWidth={1.5} />
</Svg>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 }}>
<Text style={{ color: C.dim, fontSize: 10 }}>{fmtD(pts[0].d)}</Text>
<Text style={{ color: C.dim, fontSize: 10 }}>{fmtD(last.d)}</Text>
</View>
</View>
);
}

View File

@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -1,35 +0,0 @@
import type { ReactNode } from 'react';
import { View, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
type HintRowProps = {
title?: string;
hint?: ReactNode;
};
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
return (
<View style={styles.stepRow}>
<ThemedText type="small">{title}</ThemedText>
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
</ThemedView>
</View>
);
}
const styles = StyleSheet.create({
stepRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
codeSnippet: {
borderRadius: Spacing.two,
paddingVertical: Spacing.half,
paddingHorizontal: Spacing.two,
},
});

84
src/components/login.tsx Normal file
View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import {
KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View,
} from 'react-native';
import { useAuth } from '../lib/api';
import { C } from '../lib/theme';
export function LoginScreen() {
const { login } = useAuth();
const [user, setUser] = useState('admin');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const go = async () => {
setBusy(true);
setErr('');
try {
const ok = await login(user.trim(), pass);
if (!ok) setErr('Wrong password.');
else setPass('');
} catch {
setErr('Network error — are you online?');
} finally {
setBusy(false);
}
};
return (
<KeyboardAvoidingView
style={s.wrap}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={s.card}>
<Text style={{ fontSize: 34, marginBottom: 10 }}></Text>
<Text style={s.h1}>Health Dashboard</Text>
<Text style={s.sub}>health.rehbock.xyz private</Text>
<Text style={s.lbl}>USER</Text>
<TextInput
style={s.input}
value={user}
onChangeText={setUser}
autoCapitalize="none"
autoCorrect={false}
textContentType="username"
/>
<Text style={s.lbl}>PASSWORD</Text>
<TextInput
style={s.input}
value={pass}
onChangeText={setPass}
secureTextEntry
textContentType="password"
onSubmitEditing={go}
returnKeyType="go"
/>
<Pressable style={[s.go, busy && { opacity: 0.5 }]} disabled={busy} onPress={go}>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 15 }}>Unlock</Text>
</Pressable>
<Text style={s.err}>{err}</Text>
</View>
</KeyboardAvoidingView>
);
}
const s = StyleSheet.create({
wrap: {
flex: 1, backgroundColor: C.bg, alignItems: 'center', justifyContent: 'center', padding: 24,
},
card: {
width: '100%', maxWidth: 340, backgroundColor: C.surface, borderWidth: 1,
borderColor: C.border, borderRadius: 16, paddingVertical: 28, paddingHorizontal: 24,
},
h1: { fontSize: 18, fontWeight: '600', color: C.text, marginBottom: 4 },
sub: { fontSize: 12, color: C.muted, marginBottom: 20 },
lbl: { fontSize: 11, color: C.muted, letterSpacing: 0.5, marginTop: 12, marginBottom: 5 },
input: {
paddingVertical: 11, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1,
borderColor: C.border, backgroundColor: C.surface2, color: C.text, fontSize: 15,
},
go: {
marginTop: 20, padding: 12, borderRadius: 10, backgroundColor: C.accent, alignItems: 'center',
},
err: { marginTop: 12, fontSize: 12.5, color: C.red, minHeight: 18 },
});

View File

@@ -1,73 +0,0 @@
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
import { Fonts, ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
themeColor?: ThemeColor;
};
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
const theme = useTheme();
return (
<Text
style={[
{ color: theme[themeColor ?? 'text'] },
type === 'default' && styles.default,
type === 'title' && styles.title,
type === 'small' && styles.small,
type === 'smallBold' && styles.smallBold,
type === 'subtitle' && styles.subtitle,
type === 'link' && styles.link,
type === 'linkPrimary' && styles.linkPrimary,
type === 'code' && styles.code,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: 500,
},
smallBold: {
fontSize: 14,
lineHeight: 20,
fontWeight: 700,
},
default: {
fontSize: 16,
lineHeight: 24,
fontWeight: 500,
},
title: {
fontSize: 48,
fontWeight: 600,
lineHeight: 52,
},
subtitle: {
fontSize: 32,
lineHeight: 44,
fontWeight: 600,
},
link: {
lineHeight: 30,
fontSize: 14,
},
linkPrimary: {
lineHeight: 30,
fontSize: 14,
color: '#3c87f7',
},
code: {
fontFamily: Fonts.mono,
fontWeight: Platform.select({ android: 700 }) ?? 500,
fontSize: 12,
},
});

View File

@@ -1,16 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { ThemeColor } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
type?: ThemeColor;
};
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
const theme = useTheme();
return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
}

255
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,255 @@
import { router } from 'expo-router';
import React, { useState } from 'react';
import {
ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text,
TextInput, View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { clearCache } from '../lib/api';
import { C } from '../lib/theme';
import type { Priority } from '../lib/types';
/* Scrollable screen shell with pull-to-refresh (clears the API cache) */
export function Screen({
children, onRefresh,
}: {
children: React.ReactNode;
onRefresh?: () => void;
}) {
const insets = useSafeAreaInsets();
const [refreshing, setRefreshing] = useState(false);
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 48 }}
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={refreshing}
tintColor={C.muted}
colors={[C.accent]}
progressBackgroundColor={C.surface2}
onRefresh={() => {
setRefreshing(true);
clearCache();
onRefresh();
setTimeout(() => setRefreshing(false), 400);
}}
/>
) : undefined
}>
{children}
</ScrollView>
);
}
export function Loading() {
return (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color={C.muted} />
</View>
);
}
export function ErrBox({ msg }: { msg: string }) {
return (
<View style={s.errBox}>
<Text style={{ color: C.red, fontSize: 13 }}>
Could not load data {msg}. Pull down to retry.
</Text>
</View>
);
}
export function Sec({ title, count }: { title: string; count?: number | string }) {
return (
<Text style={s.sec}>
{title.toUpperCase()}
{count != null && <Text style={{ color: C.dim }}> {count}</Text>}
</Text>
);
}
export function TileGrid({ children }: { children: React.ReactNode }) {
return <View style={s.tiles}>{children}</View>;
}
export function Tile({
label, value, unit, sub, delta, deltaTone,
}: {
label: string;
value: string;
unit?: string;
sub?: string;
delta?: string;
deltaTone?: 'good' | 'bad' | 'neutral';
}) {
const toneColor = deltaTone === 'good' ? C.green : deltaTone === 'bad' ? C.red : C.muted;
return (
<View style={s.tile}>
<Text style={s.tileLbl}>{label.toUpperCase()}</Text>
<Text style={s.tileVal}>
{value}
{unit ? <Text style={s.tileUnit}> {unit}</Text> : null}
</Text>
{delta ? <Text style={{ fontSize: 11.5, marginTop: 5, color: toneColor }}>{delta}</Text> : null}
{sub ? <Text style={s.tileSub}>{sub}</Text> : null}
</View>
);
}
/* Today-screen navigation card */
export function NavCard({
title, big, unit, note, flagline, okline, href,
}: {
title: string;
big: string;
unit?: string;
note?: string;
flagline?: string;
okline?: string;
href: string;
}) {
return (
<Pressable
style={({ pressed }) => [s.navcard, pressed && { borderColor: C.accent }]}
onPress={() => router.push(href as any)}>
<View style={s.navHead}>
<Text style={s.navT}>{title.toUpperCase()}</Text>
<Text style={{ color: C.dim, fontSize: 13 }}></Text>
</View>
<Text style={s.navBig}>
{big}
{unit ? <Text style={s.tileUnit}> {unit}</Text> : null}
</Text>
{flagline ? <Text style={{ fontSize: 11.5, marginTop: 4, color: C.red }}>{flagline}</Text> : null}
{okline ? <Text style={{ fontSize: 11.5, marginTop: 4, color: C.green }}>{okline}</Text> : null}
{note ? (
<Text numberOfLines={2} style={s.navNote}>
{note}
</Text>
) : null}
</Pressable>
);
}
export function Panel({
title, sub, children,
}: {
title?: string;
sub?: string;
children: React.ReactNode;
}) {
return (
<View style={s.panel}>
{title ? (
<View style={s.panelHead}>
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>{title}</Text>
{sub ? <Text style={{ color: C.muted, fontSize: 11 }}>{sub}</Text> : null}
</View>
) : null}
{children}
</View>
);
}
export function PriorityCard({ p, rank }: { p: Priority; rank: number }) {
const color = p.severity === 'flag' ? C.red : C.yellow;
const border = p.severity === 'flag' ? 'rgba(255,69,58,0.35)' : 'rgba(245,197,66,0.35)';
return (
<View style={[s.priority, { borderColor: border }]}>
<Text style={{ color, fontSize: 10.5, fontWeight: '700', marginBottom: 4 }}>
#{rank} · {String(p.severity).toUpperCase()}
</Text>
<Text style={{ color: C.text, fontSize: 13.5, fontWeight: '600', marginBottom: 4 }}>
{p.title}
</Text>
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>{p.detail}</Text>
</View>
);
}
export function SearchInput({
value, onChange, placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
return (
<TextInput
style={s.search}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={C.dim}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
);
}
export function SrcNote({ children }: { children: React.ReactNode }) {
return <Text style={s.srcNote}>{children}</Text>;
}
export function LegendRow({ items }: { items: [string, string][] }) {
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 10 }}>
{items.map(([label, color]) => (
<View key={label} style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 3, backgroundColor: color }} />
<Text style={{ color: C.muted, fontSize: 11 }}>{label}</Text>
</View>
))}
</View>
);
}
const s = StyleSheet.create({
errBox: {
borderWidth: 1, borderColor: 'rgba(255,69,58,0.3)', backgroundColor: C.redDim,
borderRadius: 12, padding: 14, marginVertical: 12,
},
sec: {
fontSize: 11, fontWeight: '600', letterSpacing: 0.8, color: C.muted,
marginTop: 26, marginBottom: 10,
},
tiles: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
tile: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, flexBasis: '47%', flexGrow: 1,
},
tileLbl: { fontSize: 11, color: C.muted, letterSpacing: 0.5, marginBottom: 6 },
tileVal: { fontSize: 24, fontWeight: '300', letterSpacing: -0.6, color: C.text },
tileUnit: { fontSize: 12, color: C.muted, fontWeight: '400', letterSpacing: 0 },
tileSub: { fontSize: 11.5, color: C.muted, marginTop: 5 },
navcard: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, flexBasis: '47%', flexGrow: 1,
},
navHead: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8,
},
navT: { fontSize: 12, fontWeight: '600', color: C.muted, letterSpacing: 0.6 },
navBig: { fontSize: 22, fontWeight: '300', letterSpacing: -0.5, color: C.text },
navNote: { fontSize: 11.5, color: C.muted, marginTop: 4 },
panel: {
backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14,
padding: 14, marginBottom: 10,
},
panelHead: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10,
},
priority: {
borderWidth: 1, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 14,
backgroundColor: C.surface, marginBottom: 8,
},
search: {
paddingVertical: 10, paddingHorizontal: 14, borderRadius: 12, borderWidth: 1,
borderColor: C.border, backgroundColor: C.surface, color: C.text, fontSize: 14,
marginTop: 4, marginBottom: 12,
},
srcNote: { fontSize: 11, color: C.dim, marginTop: 12, lineHeight: 16 },
});

View File

@@ -1,65 +0,0 @@
import { SymbolView } from 'expo-symbols';
import { PropsWithChildren, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
return (
<ThemedView>
<Pressable
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
onPress={() => setIsOpen((value) => !value)}>
<ThemedView type="backgroundElement" style={styles.button}>
<SymbolView
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
size={14}
weight="bold"
tintColor={theme.text}
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
/>
</ThemedView>
<ThemedText type="small">{title}</ThemedText>
</Pressable>
{isOpen && (
<Animated.View entering={FadeIn.duration(200)}>
<ThemedView type="backgroundElement" style={styles.content}>
{children}
</ThemedView>
</Animated.View>
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.two,
},
pressedHeading: {
opacity: 0.7,
},
button: {
width: Spacing.four,
height: Spacing.four,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
content: {
marginTop: Spacing.three,
borderRadius: Spacing.three,
marginLeft: Spacing.four,
padding: Spacing.four,
},
});

View File

@@ -1,43 +0,0 @@
import { version } from 'expo/package.json';
import { Image } from 'expo-image';
import { useColorScheme, StyleSheet } from 'react-native';
import { ThemedText } from './themed-text';
import { ThemedView } from './themed-view';
import { Spacing } from '@/constants/theme';
export function WebBadge() {
const scheme = useColorScheme();
return (
<ThemedView style={styles.container}>
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
v{version}
</ThemedText>
<Image
source={
scheme === 'dark'
? require('@/assets/images/expo-badge-white.png')
: require('@/assets/images/expo-badge.png')
}
style={styles.badgeImage}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
padding: Spacing.five,
alignItems: 'center',
gap: Spacing.two,
},
versionText: {
textAlign: 'center',
},
badgeImage: {
width: 123,
aspectRatio: 123 / 24,
},
});

View File

@@ -1,65 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import '@/global.css';
import { Platform } from 'react-native';
export const Colors = {
light: {
text: '#000000',
background: '#ffffff',
backgroundElement: '#F0F0F3',
backgroundSelected: '#E0E1E6',
textSecondary: '#60646C',
},
dark: {
text: '#ffffff',
background: '#000000',
backgroundElement: '#212225',
backgroundSelected: '#2E3135',
textSecondary: '#B0B4BA',
},
} as const;
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'var(--font-display)',
serif: 'var(--font-serif)',
rounded: 'var(--font-rounded)',
mono: 'var(--font-mono)',
},
});
export const Spacing = {
half: 2,
one: 4,
two: 8,
three: 16,
four: 24,
five: 32,
six: 64,
} as const;
export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0;
export const MaxContentWidth = 800;

View File

@@ -1,9 +0,0 @@
:root {
--font-display:
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
}

View File

@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View File

@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -1,14 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useTheme() {
const scheme = useColorScheme();
const theme = scheme === 'unspecified' ? 'light' : scheme;
return Colors[theme];
}

144
src/lib/api.tsx Normal file
View File

@@ -0,0 +1,144 @@
import * as SecureStore from 'expo-secure-store';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
export const BASE = 'https://health.rehbock.xyz';
export const EP = {
meta: `${BASE}/health-meta.json`,
biomarkers: `${BASE}/api/health/v1/biomarkers`,
draws: `${BASE}/api/health/v1/draws`,
workouts: `${BASE}/api/health/v1/workouts`,
sleepApi: `${BASE}/api/health/v1/sleep`,
sleepCycle: `${BASE}/sleep-data.json`,
dexa: `${BASE}/dexa-scans.json`,
medStats: `${BASE}/api/meditation/v1/stats`,
medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`,
traits: `${BASE}/ancestry-traits.json`,
} as const;
export type EpKey = keyof typeof EP;
const AUTH_KEY = 'hd.auth';
let token: string | null = null;
let onUnauthorized: (() => void) | null = null;
/* base64 without Buffer/btoa dependency quirks across Hermes versions */
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function b64(str: string): string {
const bytes: number[] = [];
for (let i = 0; i < str.length; i++) {
const code = str.codePointAt(i)!;
if (code > 0xffff) i++;
if (code < 0x80) bytes.push(code);
else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 63));
else if (code < 0x10000)
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
else
bytes.push(
0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63),
0x80 | ((code >> 6) & 63), 0x80 | (code & 63),
);
}
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const [a, b, c] = [bytes[i], bytes[i + 1], bytes[i + 2]];
out += B64[a >> 2] + B64[((a & 3) << 4) | (b ?? 0) >> 4];
out += b === undefined ? '=' : B64[((b & 15) << 2) | (c ?? 0) >> 6];
out += c === undefined ? '=' : B64[c & 63];
}
return out;
}
async function authFetch<T>(url: string): Promise<T> {
const r = await fetch(url, {
headers: { Authorization: 'Basic ' + token },
cache: 'no-store',
});
if (r.status === 401 || r.status === 403) {
onUnauthorized?.();
throw new Error('unauthorized');
}
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json();
}
/* module-level promise cache, cleared on refresh/logout */
let cache: Partial<Record<EpKey, Promise<unknown>>> = {};
export function load<T>(key: EpKey): Promise<T> {
if (!cache[key])
cache[key] = authFetch<T>(EP[key]).catch((e) => {
delete cache[key];
throw e;
});
return cache[key] as Promise<T>;
}
export function clearCache() {
cache = {};
}
/* ---- auth context ---- */
interface AuthCtx {
ready: boolean;
authed: boolean;
login: (user: string, pass: string) => Promise<boolean>;
logout: () => void;
}
const Ctx = createContext<AuthCtx>({
ready: false,
authed: false,
login: async () => false,
logout: () => {},
});
export const useAuth = () => useContext(Ctx);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(false);
useEffect(() => {
SecureStore.getItemAsync(AUTH_KEY)
.then((t) => {
if (t) {
token = t;
setAuthed(true);
}
})
.finally(() => setReady(true));
}, []);
const logout = useCallback(() => {
token = null;
clearCache();
SecureStore.deleteItemAsync(AUTH_KEY).catch(() => {});
setAuthed(false);
}, []);
useEffect(() => {
onUnauthorized = logout;
return () => {
onUnauthorized = null;
};
}, [logout]);
const login = useCallback(async (user: string, pass: string) => {
const tok = b64(`${user}:${pass}`);
const r = await fetch(EP.meta, {
headers: { Authorization: 'Basic ' + tok },
cache: 'no-store',
});
if (!r.ok) return false;
token = tok;
clearCache();
await SecureStore.setItemAsync(AUTH_KEY, tok);
setAuthed(true);
return true;
}, []);
return <Ctx.Provider value={{ ready, authed, login, logout }}>{children}</Ctx.Provider>;
}

35
src/lib/format.ts Normal file
View File

@@ -0,0 +1,35 @@
/* date-only strings must parse as LOCAL dates — new Date('2026-07-09') is UTC
midnight and renders as the previous day west of Greenwich */
export const parseD = (s: string | Date): Date => {
if (s instanceof Date) return s;
const str = String(s);
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str.slice(0, 10));
return m && str.length <= 10 ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(str);
};
export const fmtD = (iso: string) =>
parseD(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
export const fmtDY = (iso: string) =>
parseD(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
export const hm = (sec: number) => {
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
return `${h}h ${String(m).padStart(2, '0')}m`;
};
export const kfmt = (n: number) =>
n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(Math.round(n));
export const num = (n: number) => Number(n).toLocaleString('en-US');
export const cap = (s: string | null | undefined) => String(s || '').replace(/_/g, ' ');
export const daysAgo = (iso: string) =>
Math.floor((Date.now() - parseD(iso).getTime()) / 86400000);
export const agoTxt = (iso: string) => {
const d = daysAgo(iso);
return d === 0 ? 'today' : d === 1 ? 'yesterday' : `${d} days ago`;
};

94
src/lib/shape.ts Normal file
View File

@@ -0,0 +1,94 @@
import type {
BiomarkersResponse, MarkerGroup, Night, SleepApiResponse, SleepCycleExport,
Week, Workout,
} from './types';
export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[] {
const byDate = new Map<string, Night>();
(sc.sessions || []).forEach((r) => {
const o: Record<string, any> = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]]));
const date = String(o.end || '').slice(0, 10);
if (!date) return;
byDate.set(date, {
date,
total: o.inbed_s || o.asleep_s,
asleep: o.asleep_s,
deep: o.deep_s,
rem: o.rem_s,
light: o.light_s || (o.deep_s || o.rem_s ? 0 : o.asleep_s),
awake: o.awake_s,
quality: o.quality,
hrv: null,
hr: null,
src: 'Sleep Cycle',
});
});
(api.sessions || []).forEach((s) => {
const date = s.ended_at.slice(0, 10);
byDate.set(date, {
date,
total: s.total_seconds,
asleep: s.sleep_seconds,
deep: s.deep_seconds,
rem: s.rem_seconds,
light: s.light_seconds,
awake: s.awake_seconds,
quality: null,
hrv: s.avg_hrv,
hr: s.avg_hr,
src: 'Eight Sleep',
});
});
return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1));
}
export function workoutVolume(w: Workout): { vol: number; sets: number } {
let vol = 0;
let sets = 0;
(w.exercises || []).forEach((ex) =>
(ex.sets || []).forEach((s) => {
sets++;
if (s.weight_kg && s.reps) vol += s.weight_kg * s.reps;
}),
);
return { vol, sets };
}
export function weekStart(d: string): string {
const dt = new Date(d);
const day = (dt.getDay() + 6) % 7;
dt.setDate(dt.getDate() - day);
return dt.toISOString().slice(0, 10);
}
export function shapeWeeks(workouts: Workout[], n: number): Week[] {
const map = new Map<string, Week>();
workouts.forEach((w) => {
const k = weekStart(w.start_time);
if (!map.has(k)) map.set(k, { start: k, vol: 0, count: 0, sets: 0 });
const e = map.get(k)!;
const { vol, sets } = workoutVolume(w);
e.vol += vol;
e.count++;
e.sets += sets;
});
return [...map.values()].sort((a, b) => (a.start < b.start ? -1 : 1)).slice(-n);
}
export function shapeBiomarkers(bm: BiomarkersResponse): MarkerGroup[] {
const groups = new Map<string, typeof bm.biomarkers>();
bm.biomarkers.forEach((b) => {
if (!groups.has(b.name)) groups.set(b.name, []);
groups.get(b.name)!.push(b);
});
const out: MarkerGroup[] = [];
groups.forEach((hist, name) => {
hist.sort((a, b) => (a.collected < b.collected ? -1 : 1));
const latest = hist[hist.length - 1];
const series = hist
.filter((h) => h.unit === latest.unit && h.value_num != null)
.map((h) => ({ d: h.collected, v: h.value_num! }));
out.push({ name, latest, series, panel: latest.panel });
});
return out.sort((a, b) => a.name.localeCompare(b.name));
}

25
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,25 @@
/* Dark palette ported from the health.rehbock.xyz PWA */
export const C = {
bg: '#0d0d0f',
surface: '#141417',
surface2: '#1c1c21',
border: '#252529',
text: '#e8e8ed',
muted: '#8a8a99',
dim: '#4a4a55',
green: '#34c759',
greenDim: 'rgba(52,199,89,0.12)',
yellow: '#f5c542',
yellowDim: 'rgba(245,197,66,0.12)',
red: '#ff453a',
redDim: 'rgba(255,69,58,0.12)',
accent: '#6e6af0',
accentDim: 'rgba(110,106,240,0.12)',
// validated series palette (dark, surface #141417)
s1: '#3987e5',
s2: '#d95926',
s3: '#199e70',
s4: '#c98500',
awake: '#3c3c46',
gridline: '#232328',
};

186
src/lib/types.ts Normal file
View File

@@ -0,0 +1,186 @@
export interface Priority {
title: string;
severity: 'attention' | 'flag' | string;
detail: string;
}
export interface HealthMeta {
current_priorities?: Priority[];
tests_never_done?: string[];
}
export interface Biomarker {
name: string;
panel: string;
collected: string; // YYYY-MM-DD
value: string | number;
value_num: number | null;
unit: string | null;
reference_range: string | null;
flagged: boolean;
}
export interface BiomarkersResponse {
biomarkers: Biomarker[];
}
export interface DrawsResponse {
draws: unknown[];
}
export interface WorkoutSet {
weight_kg: number | null;
reps: number | null;
duration_seconds: number | null;
}
export interface WorkoutExercise {
title: string;
sets: WorkoutSet[];
}
export interface Workout {
title: string;
start_time: string;
end_time: string;
exercises: WorkoutExercise[];
}
export interface WorkoutsResponse {
workouts: Workout[];
synced_at: string;
}
export interface EightSleepSession {
ended_at: string;
total_seconds: number;
sleep_seconds: number;
deep_seconds: number;
rem_seconds: number;
light_seconds: number;
awake_seconds: number;
avg_hrv: number | null;
avg_hr: number | null;
}
export interface SleepApiResponse {
sessions: EightSleepSession[];
}
/* Sleep Cycle export: columnar rows keyed by `fields` */
export interface SleepCycleExport {
exported_at: string;
fields: string[];
sessions: (string | number | null)[][];
}
export interface DexaSummary {
total_body_fat_pct: number;
total_lean_mass_kg: number;
total_fat_mass_kg: number;
total_mass_kg: number;
est_vat_area_cm2: number;
est_vat_risk: string;
appendicular_lean_mass_to_height2_kg_m2: number;
appendicular_lean_mass_normal_threshold_kg_m2: number;
total_body_bmd_g_cm2: number;
resting_metabolic_rate_kcal_day: number;
}
export interface DexaRegion {
region: string;
fat_pct: number | null;
lean_mass_g: number | null;
fat_mass_g: number | null;
}
export interface DexaIndex {
measure: string;
result: number | string;
unit?: string | null;
percentile_yn?: number | null;
}
export interface DexaScan {
scan_date: string;
provider: string;
model?: string;
summary: DexaSummary;
body_composition_by_region?: DexaRegion[];
adipose_indices?: DexaIndex[];
lean_indices?: DexaIndex[];
}
export interface DexaResponse {
scans: DexaScan[];
}
export interface MeditationStats {
totalMinutes: number;
totalSessions: number;
currentStreak: number;
}
export interface MeditationSession {
startedAt: string;
endedAt: string;
plannedMin: number;
actualMin: number;
completionPct: number;
completed: boolean;
rating: number | null;
note: string | null;
}
export interface MeditationSessionsResponse {
sessions: MeditationSession[];
}
export interface TraitScale {
left: string;
right: string;
}
export interface Trait {
trait: string;
category: string;
result: string;
position_estimate: number | null;
scale: TraitScale | null;
}
export interface TraitsResponse {
source: string;
trait_count: number;
traits: Trait[];
}
/* ---- shaped ---- */
export interface Night {
date: string;
total: number;
asleep: number;
deep: number;
rem: number;
light: number;
awake: number;
quality: number | null;
hrv: number | null;
hr: number | null;
src: string;
}
export interface Week {
start: string;
vol: number;
count: number;
sets: number;
}
export interface MarkerGroup {
name: string;
latest: Biomarker;
series: { d: string; v: number }[];
panel: string;
}

47
src/lib/use-load.ts Normal file
View File

@@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import { EpKey, load } from './api';
interface LoadState<T> {
data?: T;
err?: string;
loading: boolean;
}
/* Load one or more endpoints; refresh() re-runs after the cache is cleared. */
export function useLoad<T>(fn: () => Promise<T>): LoadState<T> & { refresh: () => void } {
const [state, setState] = useState<LoadState<T>>({ loading: true });
const [tick, setTick] = useState(0);
useEffect(() => {
let live = true;
setState((s) => ({ ...s, loading: true, err: undefined }));
fn()
.then((data) => live && setState({ data, loading: false }))
.catch((e) => {
if (!live) return;
const msg = e instanceof Error ? e.message : String(e);
if (msg !== 'unauthorized') setState({ err: msg, loading: false });
});
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tick]);
const refresh = useCallback(() => setTick((t) => t + 1), []);
return { ...state, refresh };
}
/* Load several endpoints; failures resolve to null per-key (Today screen). */
export function loadAll<T extends Record<string, EpKey>>(
spec: T,
): Promise<{ [K in keyof T]: unknown | null }> {
const keys = Object.keys(spec) as (keyof T)[];
return Promise.all(
keys.map((k) => load(spec[k]).catch(() => null)),
).then((vals) => {
const out = {} as { [K in keyof T]: unknown | null };
keys.forEach((k, i) => (out[k] = vals[i]));
return out;
});
}