diff --git a/app.json b/app.json
index 70cdc8d..0a7fd92 100644
--- a/app.json
+++ b/app.json
@@ -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
}
diff --git a/package-lock.json b/package-lock.json
index a714fda..23566d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7260a38..2864822 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/scripts/reset-project.js b/scripts/reset-project.js
deleted file mode 100644
index 055d15b..0000000
--- a/scripts/reset-project.js
+++ /dev/null
@@ -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 (
-
- Edit src/app/index.tsx to edit this screen.
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: "center",
- justifyContent: "center",
- },
-});
-`;
-
-const layoutContent = `import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return ;
-}
-`;
-
-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();
- }
- }
-);
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index b3fd50c..21a87bf 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -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 ;
+ if (!authed) return ;
return (
-
-
-
-
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function RootLayout() {
+ return (
+
+
+
+
);
}
diff --git a/src/app/blood.tsx b/src/app/blood.tsx
new file mode 100644
index 0000000..307e998
--- /dev/null
+++ b/src/app/blood.tsx
@@ -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 (
+
+
+
+
+ {cap(m.name)}
+
+
+
+ {String(L.value)}
+
+ {L.unit || ''}
+
+
+ {L.reference_range ? `ref ${L.reference_range} · ` : ''}
+ {fmtDY(L.collected)} · {cap(L.panel)}
+
+
+
+
+ {L.flagged ? 'FLAG' : 'OK'}
+
+
+
+
+
+ );
+}
+
+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('biomarkers'),
+ load('meta'),
+ load('draws'),
+ ]),
+ );
+
+ const marks = useMemo(() => (data ? shapeBiomarkers(data[0]) : []), [data]);
+
+ let body: React.ReactNode = null;
+ if (loading && !data) body = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+
+
+
+
+
+ {meta.current_priorities?.length ? (
+ <>
+
+ {meta.current_priorities.map((p, i) => (
+
+ ))}
+ >
+ ) : null}
+
+
+
+ {flagged.length ? (
+ <>
+
+ {flagged.map((m) => )}
+ >
+ ) : null}
+ {rest.length ? (
+ <>
+ {flagged.length ? : null}
+ {rest.map((m) => )}
+ >
+ ) : null}
+ {!subset.length ? (
+
+ No markers match.
+
+ ) : null}
+
+ {meta.tests_never_done?.length ? (
+ <>
+
+ {meta.tests_never_done.map((t) => (
+
+ ○ {t}
+
+ ))}
+ >
+ ) : null}
+ >
+ );
+ }
+
+ return {body};
+}
+
+function Pill({ num, lbl, color, small }: { num: string; lbl: string; color?: string; small?: boolean }) {
+ return (
+
+
+ {num}
+
+ {lbl.toUpperCase()}
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/app/body.tsx b/src/app/body.tsx
new file mode 100644
index 0000000..519c5f9
--- /dev/null
+++ b/src/app/body.tsx
@@ -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 (
+ <>
+
+
+
+ MEASURE
+ RESULT
+ %ILE (YN)
+
+ {arr.map((r) => (
+
+ {r.measure}
+
+ {r.result}
+ {r.unit ? ` ${r.unit}` : ''}
+
+ {r.percentile_yn != null ? `${r.percentile_yn}%` : '—'}
+
+ ))}
+
+ >
+ );
+}
+
+export default function Body() {
+ const { data, err, loading, refresh } = useLoad(() => load('dexa'));
+
+ let body: React.ReactNode = null;
+ if (loading && !data) body = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ DXA scan {fmtDY(cur.scan_date)} — {cur.provider}, {cur.model || 'Hologic Horizon'}.
+ {prev ? ` Deltas vs ${fmtDY(prev.scan_date)}.` : ''}
+
+
+ {Array.isArray(reg) && reg.length ? (
+ <>
+
+
+
+ REGION
+ FAT %
+ LEAN KG
+ FAT KG
+
+ {reg.map((r) => (
+
+ {r.region}
+ {r.fat_pct ?? '—'}
+ {r.lean_mass_g ? (r.lean_mass_g / 1000).toFixed(1) : '—'}
+ {r.fat_mass_g ? (r.fat_mass_g / 1000).toFixed(1) : '—'}
+
+ ))}
+
+ >
+ ) : null}
+
+
+
+ >
+ );
+ }
+
+ return {body};
+}
+
+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'] },
+});
diff --git a/src/app/dna.tsx b/src/app/dna.tsx
new file mode 100644
index 0000000..f6ccf8e
--- /dev/null
+++ b/src/app/dna.tsx
@@ -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('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 = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+ {cats.map((c) => (
+ setCat(c)}
+ style={[s.chip, cat === c && s.chipActive]}>
+
+ {c}
+
+
+ ))}
+
+ {subset.map((t) => (
+
+
+ {t.trait}
+
+ {t.category}
+
+
+ {t.result}
+ {t.position_estimate != null && t.scale ? (
+
+
+
+
+
+ {t.scale.left}
+ {t.scale.right}
+
+
+ ) : null}
+
+ ))}
+ {!subset.length ? (
+
+ No traits match.
+
+ ) : null}
+ {data.source} — slider positions are visual estimates from the report.
+ >
+ );
+ }
+
+ return {body};
+}
+
+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,
+ },
+});
diff --git a/src/app/explore.tsx b/src/app/explore.tsx
deleted file mode 100644
index 2934085..0000000
--- a/src/app/explore.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Explore
-
- This starter app includes example{'\n'}code to help you get started.
-
-
-
- pressed && styles.pressed}>
-
- Expo documentation
-
-
-
-
-
-
-
-
-
- This app has two screens: src/app/index.tsx and{' '}
- src/app/explore.tsx
-
-
- The layout file in src/app/_layout.tsx sets up
- the tab navigator.
-
-
- Learn more
-
-
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version,
- press w in the terminal running this
- project.
-
-
-
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for different
- screen densities.
-
-
-
- Learn more
-
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect what the
- user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
-
- This template includes an example of an animated component. The{' '}
- src/components/ui/collapsible.tsx component uses
- the powerful react-native-reanimated library to
- animate opening this hint.
-
-
-
- {Platform.OS === 'web' && }
-
-
- );
-}
-
-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',
- },
-});
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 8ec3e6f..7c24d1a 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -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 use browser devtools;
- }
- if (Device.isDevice) {
- return (
-
- shake device or press m in terminal
-
+ 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(
+ ,
+ );
+ } else cards.push();
+
+ 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(
+ ,
+ );
+ } else cards.push();
+
+ 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(
+ cap(f.name)).join(', ')}${flagged.length > 3 ? '…' : ''}`
+ : undefined
+ }
+ okline={flagged.length ? undefined : 'All markers in range'}
+ note={`Latest draw ${fmtDY(latestDraw)}`} />,
+ );
+ } else cards.push();
+
+ if (r.dexa) {
+ const scan = r.dexa.scans[r.dexa.scans.length - 1];
+ cards.push(
+ ,
+ );
+ } else cards.push();
+
+ if (r.med) {
+ cards.push(
+ ,
+ );
+ } else cards.push();
+
+ cards.push(
+ ,
);
}
- const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
+
return (
-
- press {shortcut}
-
+ <>
+ (
+
+ Alert.alert('Lock the dashboard?', 'You will need the password to get back in.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Lock', style: 'destructive', onPress: logout },
+ ])
+ }>
+ 🔒
+
+ ),
+ }}
+ />
+
+
+ {loading && !r ? (
+
+ Loading…
+
+ ) : (
+ {cards}
+ )}
+ {r?.meta?.current_priorities?.length ? (
+ <>
+
+ {r.meta.current_priorities.map((p, i) => (
+
+ ))}
+ >
+ ) : null}
+
+ Personal health data · served from rehbock.xyz over HTTPS · nothing cached on device
+
+
+ >
);
}
-
-export default function HomeScreen() {
- return (
-
-
-
-
-
- Welcome to Expo
-
-
-
-
- get started
-
-
-
- src/app/index.tsx}
- />
-
- npm run reset-project}
- />
-
-
- {Platform.OS === 'web' && }
-
-
- );
-}
-
-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,
- },
-});
diff --git a/src/app/mind.tsx b/src/app/mind.tsx
new file mode 100644
index 0000000..1b960fe
--- /dev/null
+++ b/src/app/mind.tsx
@@ -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('medStats'),
+ load('medSessions'),
+ ]),
+ );
+
+ let body: React.ReactNode = null;
+ if (loading && !data) body = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+
+
+
+
+
+ {list.map((x) => (
+
+
+
+ {x.actualMin} min
+ {!x.completed && (
+
+ {' '}· {x.completionPct}% of {x.plannedMin}
+
+ )}
+
+ {fmtDY(x.startedAt)}
+
+ {x.rating ? (
+
+ {'★'.repeat(x.rating)}{'☆'.repeat(5 - x.rating)}
+
+ ) : null}
+ {x.note ? (
+
+ {x.note}
+
+ ) : null}
+
+ ))}
+ >
+ );
+ }
+
+ return {body};
+}
+
+const s = StyleSheet.create({
+ row: {
+ backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12,
+ paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8,
+ },
+});
diff --git a/src/app/sleep.tsx b/src/app/sleep.tsx
new file mode 100644
index 0000000..a121a7a
--- /dev/null
+++ b/src/app/sleep.tsx
@@ -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('sleepApi'), load('sleepCycle')]),
+ );
+
+ let body: React.ReactNode = null;
+ if (loading && !data) body = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+
+
+ {lastHrv ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ 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.
+
+ >
+ );
+ }
+
+ return {body};
+}
diff --git a/src/app/train.tsx b/src/app/train.tsx
new file mode 100644
index 0000000..c1ae800
--- /dev/null
+++ b/src/app/train.tsx
@@ -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 (
+ setOpen(!open)}>
+
+ {w.title}
+ {fmtDY(w.start_time)}
+
+
+ {mins} min · {w.exercises.length} exercises · {sets} sets · {num(Math.round(vol))} kg
+
+ {open && (
+
+ {(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 (
+
+ {ex.title}
+ {ss}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default function Train() {
+ const { width } = useWindowDimensions();
+ const chartW = Math.min(608, width - 32) - 28;
+ const { data, err, loading, refresh } = useLoad(() => load('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 = ;
+ else if (err) body = ;
+ 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 = (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {ws.slice(-20).reverse().map((w) => (
+
+ ))}
+ >
+ );
+ }
+
+ return {body};
+}
+
+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 },
+});
diff --git a/src/components/animated-icon.module.css b/src/components/animated-icon.module.css
deleted file mode 100644
index f8156fe..0000000
--- a/src/components/animated-icon.module.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.expoLogoBackground {
- background-image: linear-gradient(180deg, #3c9ffe, #0274df);
- border-radius: 40px;
- width: 128px;
- height: 128px;
-}
diff --git a/src/components/animated-icon.tsx b/src/components/animated-icon.tsx
deleted file mode 100644
index c7c2911..0000000
--- a/src/components/animated-icon.tsx
+++ /dev/null
@@ -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 = ;
-
- return animate ? (
- {
- 'worklet';
- if (finished) {
- scheduleOnRN(setVisible, false);
- }
- })}
- style={styles.splashOverlay}>
- {image}
-
- ) : (
- {
- SplashScreen.hideAsync().finally(() => {
- setAnimate(true);
- });
- }}
- style={styles.splashOverlay}>
- {image}
-
- );
-}
-
-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 (
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-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,
- },
-});
diff --git a/src/components/animated-icon.web.tsx b/src/components/animated-icon.web.tsx
deleted file mode 100644
index dfbb1fd..0000000
--- a/src/components/animated-icon.web.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-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',
- },
-});
diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx
deleted file mode 100644
index 80719bc..0000000
--- a/src/components/app-tabs.tsx
+++ /dev/null
@@ -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 (
-
-
- Home
-
-
-
-
- Explore
-
-
-
- );
-}
diff --git a/src/components/app-tabs.web.tsx b/src/components/app-tabs.web.tsx
deleted file mode 100644
index ca2787d..0000000
--- a/src/components/app-tabs.web.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
- Home
-
-
- Explore
-
-
-
-
- );
-}
-
-export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
- return (
- pressed && styles.pressed}>
-
-
- {children}
-
-
-
- );
-}
-
-export function CustomTabList(props: TabListProps) {
- const scheme = useColorScheme();
- const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
-
- return (
-
-
-
- Expo Starter
-
-
- {props.children}
-
-
-
- Docs
-
-
-
-
-
- );
-}
-
-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,
- },
-});
diff --git a/src/components/charts.tsx b/src/components/charts.tsx
new file mode 100644
index 0000000..f970a0f
--- /dev/null
+++ b/src/components/charts.tsx
@@ -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 }) => (
+
+
+ {dot ? : null}
+ {k}
+
+ {v}
+
+);
+
+function DetailCard({ title, rows }: { title: string; rows: [string, string, string?][] }) {
+ return (
+
+
+ {title}
+
+ {rows.map(([k, v, dot]) => (
+
+ ))}
+
+ );
+}
+
+/* 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(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 (
+
+
+ {selN && (
+
+ )}
+
+ );
+}
+
+/* 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(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 (
+
+
+ {selW && (
+
+ )}
+
+ );
+}
+
+/* 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 (
+
+
+
+ {fmtD(pts[0].d)}
+ {fmtD(last.d)}
+
+
+ );
+}
diff --git a/src/components/external-link.tsx b/src/components/external-link.tsx
deleted file mode 100644
index 883e515..0000000
--- a/src/components/external-link.tsx
+++ /dev/null
@@ -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, 'href'> & { href: Href & string };
-
-export function ExternalLink({ href, ...rest }: Props) {
- return (
- {
- 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,
- });
- }
- }}
- />
- );
-}
diff --git a/src/components/hint-row.tsx b/src/components/hint-row.tsx
deleted file mode 100644
index acf4dc5..0000000
--- a/src/components/hint-row.tsx
+++ /dev/null
@@ -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 (
-
- {title}
-
- {hint}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- stepRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- codeSnippet: {
- borderRadius: Spacing.two,
- paddingVertical: Spacing.half,
- paddingHorizontal: Spacing.two,
- },
-});
diff --git a/src/components/login.tsx b/src/components/login.tsx
new file mode 100644
index 0000000..e22e7cd
--- /dev/null
+++ b/src/components/login.tsx
@@ -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 (
+
+
+ ⚕️
+ Health Dashboard
+ health.rehbock.xyz — private
+ USER
+
+ PASSWORD
+
+
+ Unlock
+
+ {err}
+
+
+ );
+}
+
+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 },
+});
diff --git a/src/components/themed-text.tsx b/src/components/themed-text.tsx
deleted file mode 100644
index 799c8b1..0000000
--- a/src/components/themed-text.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
-
-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,
- },
-});
diff --git a/src/components/themed-view.tsx b/src/components/themed-view.tsx
deleted file mode 100644
index c710df9..0000000
--- a/src/components/themed-view.tsx
+++ /dev/null
@@ -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 ;
-}
diff --git a/src/components/ui.tsx b/src/components/ui.tsx
new file mode 100644
index 0000000..d9457d2
--- /dev/null
+++ b/src/components/ui.tsx
@@ -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 (
+ {
+ setRefreshing(true);
+ clearCache();
+ onRefresh();
+ setTimeout(() => setRefreshing(false), 400);
+ }}
+ />
+ ) : undefined
+ }>
+ {children}
+
+ );
+}
+
+export function Loading() {
+ return (
+
+
+
+ );
+}
+
+export function ErrBox({ msg }: { msg: string }) {
+ return (
+
+
+ Could not load data — {msg}. Pull down to retry.
+
+
+ );
+}
+
+export function Sec({ title, count }: { title: string; count?: number | string }) {
+ return (
+
+ {title.toUpperCase()}
+ {count != null && {count}}
+
+ );
+}
+
+export function TileGrid({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+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 (
+
+ {label.toUpperCase()}
+
+ {value}
+ {unit ? {unit} : null}
+
+ {delta ? {delta} : null}
+ {sub ? {sub} : null}
+
+ );
+}
+
+/* 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 (
+ [s.navcard, pressed && { borderColor: C.accent }]}
+ onPress={() => router.push(href as any)}>
+
+ {title.toUpperCase()}
+ ›
+
+
+ {big}
+ {unit ? {unit} : null}
+
+ {flagline ? {flagline} : null}
+ {okline ? {okline} : null}
+ {note ? (
+
+ {note}
+
+ ) : null}
+
+ );
+}
+
+export function Panel({
+ title, sub, children,
+}: {
+ title?: string;
+ sub?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {title ? (
+
+ {title}
+ {sub ? {sub} : null}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+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 (
+
+
+ #{rank} · {String(p.severity).toUpperCase()}
+
+
+ {p.title}
+
+ {p.detail}
+
+ );
+}
+
+export function SearchInput({
+ value, onChange, placeholder,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+}) {
+ return (
+
+ );
+}
+
+export function SrcNote({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+export function LegendRow({ items }: { items: [string, string][] }) {
+ return (
+
+ {items.map(([label, color]) => (
+
+
+ {label}
+
+ ))}
+
+ );
+}
+
+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 },
+});
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
deleted file mode 100644
index d0d745b..0000000
--- a/src/components/ui/collapsible.tsx
+++ /dev/null
@@ -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 (
-
- [styles.heading, pressed && styles.pressedHeading]}
- onPress={() => setIsOpen((value) => !value)}>
-
-
-
-
- {title}
-
- {isOpen && (
-
-
- {children}
-
-
- )}
-
- );
-}
-
-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,
- },
-});
diff --git a/src/components/web-badge.tsx b/src/components/web-badge.tsx
deleted file mode 100644
index 6667898..0000000
--- a/src/components/web-badge.tsx
+++ /dev/null
@@ -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 (
-
-
- v{version}
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- padding: Spacing.five,
- alignItems: 'center',
- gap: Spacing.two,
- },
- versionText: {
- textAlign: 'center',
- },
- badgeImage: {
- width: 123,
- aspectRatio: 123 / 24,
- },
-});
diff --git a/src/constants/theme.ts b/src/constants/theme.ts
deleted file mode 100644
index c10ed27..0000000
--- a/src/constants/theme.ts
+++ /dev/null
@@ -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;
diff --git a/src/global.css b/src/global.css
deleted file mode 100644
index c8fe503..0000000
--- a/src/global.css
+++ /dev/null
@@ -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;
-}
diff --git a/src/hooks/use-color-scheme.ts b/src/hooks/use-color-scheme.ts
deleted file mode 100644
index 17e3c63..0000000
--- a/src/hooks/use-color-scheme.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useColorScheme } from 'react-native';
diff --git a/src/hooks/use-color-scheme.web.ts b/src/hooks/use-color-scheme.web.ts
deleted file mode 100644
index 7eb1c1b..0000000
--- a/src/hooks/use-color-scheme.web.ts
+++ /dev/null
@@ -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';
-}
diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts
deleted file mode 100644
index 677e015..0000000
--- a/src/hooks/use-theme.ts
+++ /dev/null
@@ -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];
-}
diff --git a/src/lib/api.tsx b/src/lib/api.tsx
new file mode 100644
index 0000000..b204148
--- /dev/null
+++ b/src/lib/api.tsx
@@ -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(url: string): Promise {
+ 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>> = {};
+
+export function load(key: EpKey): Promise {
+ if (!cache[key])
+ cache[key] = authFetch(EP[key]).catch((e) => {
+ delete cache[key];
+ throw e;
+ });
+ return cache[key] as Promise;
+}
+
+export function clearCache() {
+ cache = {};
+}
+
+/* ---- auth context ---- */
+
+interface AuthCtx {
+ ready: boolean;
+ authed: boolean;
+ login: (user: string, pass: string) => Promise;
+ logout: () => void;
+}
+
+const Ctx = createContext({
+ 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 {children};
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..0e3cb43
--- /dev/null
+++ b/src/lib/format.ts
@@ -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`;
+};
diff --git a/src/lib/shape.ts b/src/lib/shape.ts
new file mode 100644
index 0000000..2b10cf6
--- /dev/null
+++ b/src/lib/shape.ts
@@ -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();
+ (sc.sessions || []).forEach((r) => {
+ const o: Record = 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();
+ 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();
+ 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));
+}
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..430a382
--- /dev/null
+++ b/src/lib/theme.ts
@@ -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',
+};
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..1ffa77a
--- /dev/null
+++ b/src/lib/types.ts
@@ -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;
+}
diff --git a/src/lib/use-load.ts b/src/lib/use-load.ts
new file mode 100644
index 0000000..cf3534a
--- /dev/null
+++ b/src/lib/use-load.ts
@@ -0,0 +1,47 @@
+import { useCallback, useEffect, useState } from 'react';
+import { EpKey, load } from './api';
+
+interface LoadState {
+ data?: T;
+ err?: string;
+ loading: boolean;
+}
+
+/* Load one or more endpoints; refresh() re-runs after the cache is cleared. */
+export function useLoad(fn: () => Promise): LoadState & { refresh: () => void } {
+ const [state, setState] = useState>({ 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>(
+ 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;
+ });
+}