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:
15
app.json
15
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
|
||||
}
|
||||
|
||||
167
package-lock.json
generated
167
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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
160
src/app/blood.tsx
Normal 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
143
src/app/body.tsx
Normal 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
99
src/app/dna.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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'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',
|
||||
},
|
||||
});
|
||||
@@ -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="Couldn’t load" href="/sleep" />);
|
||||
|
||||
if (r.wo) {
|
||||
const ws = [...r.wo.workouts].sort((a, b) => (a.start_time < b.start_time ? -1 : 1));
|
||||
const last = ws[ws.length - 1];
|
||||
const { vol } = workoutVolume(last);
|
||||
const wkCount = ws.filter(
|
||||
(w) => weekStart(w.start_time) === weekStart(new Date().toISOString()),
|
||||
).length;
|
||||
cards.push(
|
||||
<NavCard key="train" title="Training" big={last.title} href="/train"
|
||||
note={`${agoTxt(last.start_time)} · ${num(Math.round(vol))} kg · ${wkCount} this week`} />,
|
||||
);
|
||||
} else cards.push(<NavCard key="train" title="Training" big="—" note="Couldn’t load" href="/train" />);
|
||||
|
||||
if (r.bio) {
|
||||
const marks = shapeBiomarkers(r.bio);
|
||||
const latestDraw = r.bio.biomarkers.reduce((m, b) => (b.collected > m ? b.collected : m), '');
|
||||
const flagged = marks.filter((m) => m.latest.flagged && m.latest.collected === latestDraw);
|
||||
cards.push(
|
||||
<NavCard key="blood" title="Blood" big={String(marks.length)} unit="markers" href="/blood"
|
||||
flagline={
|
||||
flagged.length
|
||||
? `${flagged.length} flagged: ${flagged.slice(0, 3).map((f) => cap(f.name)).join(', ')}${flagged.length > 3 ? '…' : ''}`
|
||||
: undefined
|
||||
}
|
||||
okline={flagged.length ? undefined : 'All markers in range'}
|
||||
note={`Latest draw ${fmtDY(latestDraw)}`} />,
|
||||
);
|
||||
} else cards.push(<NavCard key="blood" title="Blood" big="—" note="Couldn’t load" href="/blood" />);
|
||||
|
||||
if (r.dexa) {
|
||||
const scan = r.dexa.scans[r.dexa.scans.length - 1];
|
||||
cards.push(
|
||||
<NavCard key="body" title="Body" big={`${scan.summary.total_body_fat_pct}`} unit="% fat"
|
||||
href="/body"
|
||||
note={`${scan.summary.total_lean_mass_kg} kg lean · DXA ${fmtD(scan.scan_date)}`} />,
|
||||
);
|
||||
} else cards.push(<NavCard key="body" title="Body" big="—" note="Couldn’t load" href="/body" />);
|
||||
|
||||
if (r.med) {
|
||||
cards.push(
|
||||
<NavCard key="mind" title="Mind" big={String(r.med.totalMinutes)} unit="min" href="/mind"
|
||||
note={`${r.med.totalSessions} sessions · streak ${r.med.currentStreak}`} />,
|
||||
);
|
||||
} else cards.push(<NavCard key="mind" title="Mind" big="—" note="Couldn’t load" href="/mind" />);
|
||||
|
||||
cards.push(
|
||||
<NavCard key="dna" title="DNA" big="99" unit="traits" href="/dna"
|
||||
note="AncestryDNA trait report" />,
|
||||
);
|
||||
}
|
||||
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 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
79
src/app/mind.tsx
Normal 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
67
src/app/sleep.tsx
Normal 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
102
src/app/train.tsx
Normal 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 },
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
.expoLogoBackground {
|
||||
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||
border-radius: 40px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
189
src/components/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
84
src/components/login.tsx
Normal 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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
255
src/components/ui.tsx
Normal 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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
144
src/lib/api.tsx
Normal 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
35
src/lib/format.ts
Normal 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
94
src/lib/shape.ts
Normal 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
25
src/lib/theme.ts
Normal 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
186
src/lib/types.ts
Normal 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
47
src/lib/use-load.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user