From 2a5c956c32809d5762c9685dbb90e9f006e30d0d Mon Sep 17 00:00:00 2001 From: Marcus Rehbock Date: Fri, 7 Aug 2026 16:38:52 -0700 Subject: [PATCH] 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 --- app.json | 15 +- package-lock.json | 167 ++++++++++++++++ package.json | 2 + scripts/reset-project.js | 114 ----------- src/app/_layout.tsx | 50 +++-- src/app/blood.tsx | 160 +++++++++++++++ src/app/body.tsx | 143 +++++++++++++ src/app/dna.tsx | 99 +++++++++ src/app/explore.tsx | 180 ----------------- src/app/index.tsx | 227 ++++++++++++--------- src/app/mind.tsx | 79 ++++++++ src/app/sleep.tsx | 67 +++++++ src/app/train.tsx | 102 ++++++++++ src/components/animated-icon.module.css | 6 - src/components/animated-icon.tsx | 148 -------------- src/components/animated-icon.web.tsx | 108 ---------- src/components/app-tabs.tsx | 32 --- src/components/app-tabs.web.tsx | 115 ----------- src/components/charts.tsx | 189 ++++++++++++++++++ src/components/external-link.tsx | 25 --- src/components/hint-row.tsx | 35 ---- src/components/login.tsx | 84 ++++++++ src/components/themed-text.tsx | 73 ------- src/components/themed-view.tsx | 16 -- src/components/ui.tsx | 255 ++++++++++++++++++++++++ src/components/ui/collapsible.tsx | 65 ------ src/components/web-badge.tsx | 43 ---- src/constants/theme.ts | 65 ------ src/global.css | 9 - src/hooks/use-color-scheme.ts | 1 - src/hooks/use-color-scheme.web.ts | 21 -- src/hooks/use-theme.ts | 14 -- src/lib/api.tsx | 144 +++++++++++++ src/lib/format.ts | 35 ++++ src/lib/shape.ts | 94 +++++++++ src/lib/theme.ts | 25 +++ src/lib/types.ts | 186 +++++++++++++++++ src/lib/use-load.ts | 47 +++++ 38 files changed, 2060 insertions(+), 1180 deletions(-) delete mode 100644 scripts/reset-project.js create mode 100644 src/app/blood.tsx create mode 100644 src/app/body.tsx create mode 100644 src/app/dna.tsx delete mode 100644 src/app/explore.tsx create mode 100644 src/app/mind.tsx create mode 100644 src/app/sleep.tsx create mode 100644 src/app/train.tsx delete mode 100644 src/components/animated-icon.module.css delete mode 100644 src/components/animated-icon.tsx delete mode 100644 src/components/animated-icon.web.tsx delete mode 100644 src/components/app-tabs.tsx delete mode 100644 src/components/app-tabs.web.tsx create mode 100644 src/components/charts.tsx delete mode 100644 src/components/external-link.tsx delete mode 100644 src/components/hint-row.tsx create mode 100644 src/components/login.tsx delete mode 100644 src/components/themed-text.tsx delete mode 100644 src/components/themed-view.tsx create mode 100644 src/components/ui.tsx delete mode 100644 src/components/ui/collapsible.tsx delete mode 100644 src/components/web-badge.tsx delete mode 100644 src/constants/theme.ts delete mode 100644 src/global.css delete mode 100644 src/hooks/use-color-scheme.ts delete mode 100644 src/hooks/use-color-scheme.web.ts delete mode 100644 src/hooks/use-theme.ts create mode 100644 src/lib/api.tsx create mode 100644 src/lib/format.ts create mode 100644 src/lib/shape.ts create mode 100644 src/lib/theme.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/use-load.ts diff --git a/app.json b/app.json index 70cdc8d..0a7fd92 100644 --- a/app.json +++ b/app.json @@ -1,18 +1,22 @@ { "expo": { - "name": "health-app", + "name": "Health", "slug": "health-app", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "healthapp", - "userInterfaceStyle": "automatic", + "userInterfaceStyle": "dark", + "backgroundColor": "#0d0d0f", "ios": { - "icon": "./assets/expo.icon" + "icon": "./assets/expo.icon", + "bundleIdentifier": "xyz.rehbock.health", + "supportsTablet": true }, "android": { + "package": "xyz.rehbock.health", "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#0d0d0f", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" @@ -25,10 +29,11 @@ }, "plugins": [ "expo-router", + "expo-secure-store", [ "expo-splash-screen", { - "backgroundColor": "#208AEF", + "backgroundColor": "#0d0d0f", "image": "./assets/images/splash-icon.png", "imageWidth": 76 } diff --git a/package-lock.json b/package-lock.json index a714fda..23566d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-image": "~57.0.2", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", + "expo-secure-store": "~57.0.1", "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", "expo-symbols": "~57.0.2", @@ -29,6 +30,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.1" }, @@ -2975,6 +2977,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -3404,6 +3412,56 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3518,6 +3576,61 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3545,6 +3658,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -3913,6 +4038,15 @@ } } }, + "node_modules/expo-secure-store": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz", + "integrity": "sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz", @@ -5311,6 +5445,12 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -5835,6 +5975,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -6474,6 +6626,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", diff --git a/package.json b/package.json index 7260a38..2864822 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "expo-image": "~57.0.2", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", + "expo-secure-store": "~57.0.1", "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", "expo-symbols": "~57.0.2", @@ -24,6 +25,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", "react-native-web": "~0.21.0", "react-native-worklets": "0.10.1" }, diff --git a/scripts/reset-project.js b/scripts/reset-project.js deleted file mode 100644 index 055d15b..0000000 --- a/scripts/reset-project.js +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node - -/** - * This script is used to reset the project to a blank state. - * It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file. - * You can remove the `reset-project` script from package.json and safely delete this file after running it. - */ - -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); - -const root = process.cwd(); -const oldDirs = ["src", "scripts"]; -const exampleDir = "example"; -const newAppDir = "src/app"; -const exampleDirPath = path.join(root, exampleDir); - -const indexContent = `import { Text, View, StyleSheet } from "react-native"; - -export default function Index() { - return ( - - Edit src/app/index.tsx to edit this screen. - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, -}); -`; - -const layoutContent = `import { Stack } from "expo-router"; - -export default function RootLayout() { - return ; -} -`; - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -const moveDirectories = async (userInput) => { - try { - if (userInput === "y") { - // Create the app-example directory - await fs.promises.mkdir(exampleDirPath, { recursive: true }); - console.log(`📁 /${exampleDir} directory created.`); - } - - // Move old directories to new app-example directory or delete them - for (const dir of oldDirs) { - const oldDirPath = path.join(root, dir); - if (fs.existsSync(oldDirPath)) { - if (userInput === "y") { - const newDirPath = path.join(root, exampleDir, dir); - await fs.promises.rename(oldDirPath, newDirPath); - console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`); - } else { - await fs.promises.rm(oldDirPath, { recursive: true, force: true }); - console.log(`❌ /${dir} deleted.`); - } - } else { - console.log(`➡️ /${dir} does not exist, skipping.`); - } - } - - // Create new /src/app directory - const newAppDirPath = path.join(root, newAppDir); - await fs.promises.mkdir(newAppDirPath, { recursive: true }); - console.log("\n📁 New /src/app directory created."); - - // Create index.tsx - const indexPath = path.join(newAppDirPath, "index.tsx"); - await fs.promises.writeFile(indexPath, indexContent); - console.log("📄 src/app/index.tsx created."); - - // Create _layout.tsx - const layoutPath = path.join(newAppDirPath, "_layout.tsx"); - await fs.promises.writeFile(layoutPath, layoutContent); - console.log("📄 src/app/_layout.tsx created."); - - console.log("\n✅ Project reset complete. Next steps:"); - console.log( - `1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${ - userInput === "y" - ? `\n4. Delete the /${exampleDir} directory when you're done referencing it.` - : "" - }` - ); - } catch (error) { - console.error(`❌ Error during script execution: ${error.message}`); - } -}; - -rl.question( - "Do you want to move existing files to /example instead of deleting them? (Y/n): ", - (answer) => { - const userInput = answer.trim().toLowerCase() || "y"; - if (userInput === "y" || userInput === "n") { - moveDirectories(userInput).finally(() => rl.close()); - } else { - console.log("❌ Invalid input. Please enter 'Y' or 'N'."); - rl.close(); - } - } -); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index b3fd50c..21a87bf 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,18 +1,40 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router'; -import * as SplashScreen from 'expo-splash-screen'; -import { useColorScheme } from 'react-native'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import React from 'react'; +import { View } from 'react-native'; +import { AuthProvider, useAuth } from '../lib/api'; +import { C } from '../lib/theme'; +import { LoginScreen } from '../components/login'; -import { AnimatedSplashOverlay } from '@/components/animated-icon'; -import AppTabs from '@/components/app-tabs'; - -SplashScreen.preventAutoHideAsync(); - -export default function TabLayout() { - const colorScheme = useColorScheme(); +function Gate() { + const { ready, authed } = useAuth(); + if (!ready) return ; + if (!authed) return ; return ( - - - - + + + + + + + + + + ); +} + +export default function RootLayout() { + return ( + + + + ); } diff --git a/src/app/blood.tsx b/src/app/blood.tsx new file mode 100644 index 0000000..307e998 --- /dev/null +++ b/src/app/blood.tsx @@ -0,0 +1,160 @@ +import React, { useMemo, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Sparkline } from '../components/charts'; +import { ErrBox, Loading, PriorityCard, Screen, SearchInput, Sec } from '../components/ui'; +import { load } from '../lib/api'; +import { cap, fmtD, fmtDY } from '../lib/format'; +import { shapeBiomarkers } from '../lib/shape'; +import { C } from '../lib/theme'; +import type { BiomarkersResponse, DrawsResponse, HealthMeta, MarkerGroup } from '../lib/types'; +import { useLoad } from '../lib/use-load'; +import { useWindowDimensions } from 'react-native'; + +function MarkerCard({ m, sparkW }: { m: MarkerGroup; sparkW: number }) { + const L = m.latest; + return ( + + + + + {cap(m.name)} + + + + {String(L.value)} + + {L.unit || ''} + + + {L.reference_range ? `ref ${L.reference_range} · ` : ''} + {fmtDY(L.collected)} · {cap(L.panel)} + + + + + {L.flagged ? 'FLAG' : 'OK'} + + + + + + ); +} + +export default function Blood() { + const { width } = useWindowDimensions(); + const sparkW = Math.min(608, width - 32) - 28; + const [q, setQ] = useState(''); + const { data, err, loading, refresh } = useLoad(() => + Promise.all([ + load('biomarkers'), + load('meta'), + load('draws'), + ]), + ); + + const marks = useMemo(() => (data ? shapeBiomarkers(data[0]) : []), [data]); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const [bio, meta, draws] = data; + const latestDraw = bio.biomarkers.reduce((m, b) => (b.collected > m ? b.collected : m), ''); + const flaggedNow = marks.filter((m) => m.latest.flagged); + const ql = q.trim().toLowerCase(); + const subset = ql + ? marks.filter((m) => cap(m.name).toLowerCase().includes(ql) || m.panel.includes(ql)) + : marks; + const flagged = subset.filter((m) => m.latest.flagged); + const rest = subset.filter((m) => !m.latest.flagged); + + body = ( + <> + + + + + + + + {meta.current_priorities?.length ? ( + <> + + {meta.current_priorities.map((p, i) => ( + + ))} + + ) : null} + + + + {flagged.length ? ( + <> + + {flagged.map((m) => )} + + ) : null} + {rest.length ? ( + <> + {flagged.length ? : null} + {rest.map((m) => )} + + ) : null} + {!subset.length ? ( + + No markers match. + + ) : null} + + {meta.tests_never_done?.length ? ( + <> + + {meta.tests_never_done.map((t) => ( + + ○ {t} + + ))} + + ) : null} + + ); + } + + return {body}; +} + +function Pill({ num, lbl, color, small }: { num: string; lbl: string; color?: string; small?: boolean }) { + return ( + + + {num} + + {lbl.toUpperCase()} + + ); +} + +const s = StyleSheet.create({ + card: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12, + paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, + }, + status: { paddingVertical: 2, paddingHorizontal: 8, borderRadius: 10 }, + statusOk: { backgroundColor: C.greenDim }, + statusFlag: { backgroundColor: C.redDim }, + pill: { + borderWidth: 1, borderColor: C.border, borderRadius: 10, paddingVertical: 8, + paddingHorizontal: 14, backgroundColor: C.surface, + }, + missing: { + borderWidth: 1, borderStyle: 'dashed', borderColor: C.border, borderRadius: 10, + paddingVertical: 9, paddingHorizontal: 13, backgroundColor: C.surface, marginBottom: 6, + }, +}); diff --git a/src/app/body.tsx b/src/app/body.tsx new file mode 100644 index 0000000..519c5f9 --- /dev/null +++ b/src/app/body.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { ErrBox, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui'; +import { load } from '../lib/api'; +import { fmtD, fmtDY, num } from '../lib/format'; +import { C } from '../lib/theme'; +import type { DexaIndex, DexaResponse } from '../lib/types'; +import { useLoad } from '../lib/use-load'; + +function delta( + curV: number | null | undefined, + prevV: number | null | undefined, + goodDir: -1 | 0 | 1, + unit: string, + prevDate: string, + dp = 1, +): { text: string; tone: 'good' | 'bad' | 'neutral' } | undefined { + if (prevV == null || curV == null) return undefined; + const d = curV - prevV; + if (Math.abs(d) < 1e-9) return { text: 'unchanged', tone: 'neutral' }; + const good = goodDir === 0 ? null : (d > 0) === (goodDir > 0); + return { + text: `${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(dp)}${unit} vs ${fmtD(prevDate)}`, + tone: good == null ? 'neutral' : good ? 'good' : 'bad', + }; +} + +function IdxTable({ title, arr }: { title: string; arr?: DexaIndex[] }) { + if (!Array.isArray(arr) || !arr.length) return null; + return ( + <> + + + + MEASURE + RESULT + %ILE (YN) + + {arr.map((r) => ( + + {r.measure} + + {r.result} + {r.unit ? ` ${r.unit}` : ''} + + {r.percentile_yn != null ? `${r.percentile_yn}%` : '—'} + + ))} + + + ); +} + +export default function Body() { + const { data, err, loading, refresh } = useLoad(() => load('dexa')); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const scans = data.scans; + const cur = scans[scans.length - 1]; + const prev = scans.length > 1 ? scans[scans.length - 2] : null; + const sm = cur.summary; + const p = prev?.summary; + const pd = prev?.scan_date ?? ''; + const reg = cur.body_composition_by_region; + + body = ( + <> + + + + + + + + + + + + DXA scan {fmtDY(cur.scan_date)} — {cur.provider}, {cur.model || 'Hologic Horizon'}. + {prev ? ` Deltas vs ${fmtDY(prev.scan_date)}.` : ''} + + + {Array.isArray(reg) && reg.length ? ( + <> + + + + REGION + FAT % + LEAN KG + FAT KG + + {reg.map((r) => ( + + {r.region} + {r.fat_pct ?? '—'} + {r.lean_mass_g ? (r.lean_mass_g / 1000).toFixed(1) : '—'} + {r.fat_mass_g ? (r.fat_mass_g / 1000).toFixed(1) : '—'} + + ))} + + + ) : null} + + + + + ); + } + + return {body}; +} + +function toDelta(d?: { text: string; tone: 'good' | 'bad' | 'neutral' }) { + return d ? { delta: d.text, deltaTone: d.tone } : {}; +} + +const s = StyleSheet.create({ + tr: { + flexDirection: 'row', gap: 8, paddingVertical: 6, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#1e1e23', + }, + th: { flex: 1, fontSize: 10, letterSpacing: 0.5, color: C.muted, fontWeight: '600' }, + td: { flex: 1, fontSize: 12, color: C.text, fontVariant: ['tabular-nums'] }, +}); diff --git a/src/app/dna.tsx b/src/app/dna.tsx new file mode 100644 index 0000000..f6ccf8e --- /dev/null +++ b/src/app/dna.tsx @@ -0,0 +1,99 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ErrBox, Loading, Screen, SearchInput, SrcNote } from '../components/ui'; +import { load } from '../lib/api'; +import { C } from '../lib/theme'; +import type { TraitsResponse } from '../lib/types'; +import { useLoad } from '../lib/use-load'; + +export default function Dna() { + const [cat, setCat] = useState('all'); + const [q, setQ] = useState(''); + const { data, err, loading, refresh } = useLoad(() => load('traits')); + + const cats = useMemo( + () => (data ? ['all', ...new Set(data.traits.map((t) => t.category))] : ['all']), + [data], + ); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const ql = q.trim().toLowerCase(); + const subset = data.traits.filter( + (t) => + (cat === 'all' || t.category === cat) && + (!ql || + t.trait.toLowerCase().includes(ql) || + (t.result || '').toLowerCase().includes(ql)), + ); + + body = ( + <> + + + {cats.map((c) => ( + setCat(c)} + style={[s.chip, cat === c && s.chipActive]}> + + {c} + + + ))} + + {subset.map((t) => ( + + + {t.trait} + + {t.category} + + + {t.result} + {t.position_estimate != null && t.scale ? ( + + + + + + {t.scale.left} + {t.scale.right} + + + ) : null} + + ))} + {!subset.length ? ( + + No traits match. + + ) : null} + {data.source} — slider positions are visual estimates from the report. + + ); + } + + return {body}; +} + +const s = StyleSheet.create({ + chip: { + paddingVertical: 5, paddingHorizontal: 12, borderRadius: 16, borderWidth: 1, + borderColor: C.border, backgroundColor: C.surface, + }, + chipActive: { borderColor: C.accent, backgroundColor: C.accentDim }, + trait: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12, + paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, + }, + track: { height: 4, borderRadius: 2, backgroundColor: C.surface2 }, + dot: { + position: 'absolute', top: -4, marginLeft: -5.5, width: 11, height: 11, + borderRadius: 5.5, backgroundColor: C.accent, borderWidth: 2, borderColor: C.bg, + }, +}); diff --git a/src/app/explore.tsx b/src/app/explore.tsx deleted file mode 100644 index 2934085..0000000 --- a/src/app/explore.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Image } from 'expo-image'; -import { SymbolView } from 'expo-symbols'; -import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { ExternalLink } from '@/components/external-link'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export default function TabTwoScreen() { - const safeAreaInsets = useSafeAreaInsets(); - const insets = { - ...safeAreaInsets, - bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, - }; - const theme = useTheme(); - - const contentPlatformStyle = Platform.select({ - android: { - paddingTop: insets.top, - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: insets.bottom, - }, - web: { - paddingTop: Spacing.six, - paddingBottom: Spacing.four, - }, - }); - - return ( - - - - Explore - - This starter app includes example{'\n'}code to help you get started. - - - - pressed && styles.pressed}> - - Expo documentation - - - - - - - - - - This app has two screens: src/app/index.tsx and{' '} - src/app/explore.tsx - - - The layout file in src/app/_layout.tsx sets up - the tab navigator. - - - Learn more - - - - - - - You can open this project on Android, iOS, and the web. To open the web version, - press w in the terminal running this - project. - - - - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different - screen densities. - - - - Learn more - - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the - user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - - This template includes an example of an animated component. The{' '} - src/components/ui/collapsible.tsx component uses - the powerful react-native-reanimated library to - animate opening this hint. - - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - scrollView: { - flex: 1, - }, - contentContainer: { - flexDirection: 'row', - justifyContent: 'center', - }, - container: { - maxWidth: MaxContentWidth, - flexGrow: 1, - }, - titleContainer: { - gap: Spacing.three, - alignItems: 'center', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.six, - }, - centerText: { - textAlign: 'center', - }, - pressed: { - opacity: 0.7, - }, - linkButton: { - flexDirection: 'row', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - borderRadius: Spacing.five, - justifyContent: 'center', - gap: Spacing.one, - alignItems: 'center', - }, - sectionsWrapper: { - gap: Spacing.five, - paddingHorizontal: Spacing.four, - paddingTop: Spacing.three, - }, - collapsibleContent: { - alignItems: 'center', - }, - imageTutorial: { - width: '100%', - aspectRatio: 296 / 171, - borderRadius: Spacing.three, - marginTop: Spacing.two, - }, - imageReact: { - width: 100, - height: 100, - alignSelf: 'center', - }, -}); diff --git a/src/app/index.tsx b/src/app/index.tsx index 8ec3e6f..7c24d1a 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,98 +1,143 @@ -import * as Device from 'expo-device'; -import { Platform, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { Stack } from 'expo-router'; +import React from 'react'; +import { Alert, Pressable, Text, View } from 'react-native'; +import { NavCard, PriorityCard, Screen, Sec } from '../components/ui'; +import { useAuth } from '../lib/api'; +import { agoTxt, fmtD, fmtDY, hm, num, cap } from '../lib/format'; +import { shapeBiomarkers, shapeSleep, weekStart, workoutVolume } from '../lib/shape'; +import { C } from '../lib/theme'; +import type { + BiomarkersResponse, DexaResponse, HealthMeta, MeditationStats, SleepApiResponse, + SleepCycleExport, WorkoutsResponse, +} from '../lib/types'; +import { loadAll, useLoad } from '../lib/use-load'; -import { AnimatedIcon } from '@/components/animated-icon'; -import { HintRow } from '@/components/hint-row'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; +export default function Today() { + const { logout } = useAuth(); + const { data, loading, refresh } = useLoad(() => + loadAll({ + meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi', + sc: 'sleepCycle', dexa: 'dexa', med: 'medStats', + } as const), + ); -function getDevMenuHint() { - if (Platform.OS === 'web') { - return use browser devtools; - } - if (Device.isDevice) { - return ( - - shake device or press m in terminal - + const r = data as + | { + meta: HealthMeta | null; + bio: BiomarkersResponse | null; + wo: WorkoutsResponse | null; + sApi: SleepApiResponse | null; + sc: SleepCycleExport | null; + dexa: DexaResponse | null; + med: MeditationStats | null; + } + | undefined; + + const cards: React.ReactNode[] = []; + if (r) { + if (r.sApi && r.sc) { + const nights = shapeSleep(r.sApi, r.sc); + const last = nights[nights.length - 1]; + cards.push( + , + ); + } else cards.push(); + + if (r.wo) { + const ws = [...r.wo.workouts].sort((a, b) => (a.start_time < b.start_time ? -1 : 1)); + const last = ws[ws.length - 1]; + const { vol } = workoutVolume(last); + const wkCount = ws.filter( + (w) => weekStart(w.start_time) === weekStart(new Date().toISOString()), + ).length; + cards.push( + , + ); + } else cards.push(); + + if (r.bio) { + const marks = shapeBiomarkers(r.bio); + const latestDraw = r.bio.biomarkers.reduce((m, b) => (b.collected > m ? b.collected : m), ''); + const flagged = marks.filter((m) => m.latest.flagged && m.latest.collected === latestDraw); + cards.push( + cap(f.name)).join(', ')}${flagged.length > 3 ? '…' : ''}` + : undefined + } + okline={flagged.length ? undefined : 'All markers in range'} + note={`Latest draw ${fmtDY(latestDraw)}`} />, + ); + } else cards.push(); + + if (r.dexa) { + const scan = r.dexa.scans[r.dexa.scans.length - 1]; + cards.push( + , + ); + } else cards.push(); + + if (r.med) { + cards.push( + , + ); + } else cards.push(); + + cards.push( + , ); } - const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d'; + return ( - - press {shortcut} - + <> + ( + + Alert.alert('Lock the dashboard?', 'You will need the password to get back in.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Lock', style: 'destructive', onPress: logout }, + ]) + }> + 🔒 + + ), + }} + /> + + + {loading && !r ? ( + + Loading… + + ) : ( + {cards} + )} + {r?.meta?.current_priorities?.length ? ( + <> + + {r.meta.current_priorities.map((p, i) => ( + + ))} + + ) : null} + + Personal health data · served from rehbock.xyz over HTTPS · nothing cached on device + + + ); } - -export default function HomeScreen() { - return ( - - - - - - Welcome to Expo - - - - - get started - - - - src/app/index.tsx} - /> - - npm run reset-project} - /> - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - flexDirection: 'row', - }, - safeArea: { - flex: 1, - paddingHorizontal: Spacing.four, - alignItems: 'center', - gap: Spacing.three, - paddingBottom: BottomTabInset + Spacing.three, - maxWidth: MaxContentWidth, - }, - heroSection: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - paddingHorizontal: Spacing.four, - gap: Spacing.four, - }, - title: { - textAlign: 'center', - }, - code: { - textTransform: 'uppercase', - }, - stepContainer: { - gap: Spacing.three, - alignSelf: 'stretch', - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.four, - borderRadius: Spacing.four, - }, -}); diff --git a/src/app/mind.tsx b/src/app/mind.tsx new file mode 100644 index 0000000..1b960fe --- /dev/null +++ b/src/app/mind.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { ErrBox, Loading, Screen, Sec, Tile, TileGrid } from '../components/ui'; +import { load } from '../lib/api'; +import { agoTxt, fmtDY, hm } from '../lib/format'; +import { C } from '../lib/theme'; +import type { MeditationSessionsResponse, MeditationStats } from '../lib/types'; +import { useLoad } from '../lib/use-load'; + +export default function Mind() { + const { data, err, loading, refresh } = useLoad(() => + Promise.all([ + load('medStats'), + load('medSessions'), + ]), + ); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const [stats, sess] = data; + const list = [...(sess.sessions || [])].sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1)); + const rated = list.filter((x) => x.rating); + const avgR = rated.length ? rated.reduce((sum, x) => sum + x.rating!, 0) / rated.length : null; + + body = ( + <> + + + + + + + + {list.map((x) => ( + + + + {x.actualMin} min + {!x.completed && ( + + {' '}· {x.completionPct}% of {x.plannedMin} + + )} + + {fmtDY(x.startedAt)} + + {x.rating ? ( + + {'★'.repeat(x.rating)}{'☆'.repeat(5 - x.rating)} + + ) : null} + {x.note ? ( + + {x.note} + + ) : null} + + ))} + + ); + } + + return {body}; +} + +const s = StyleSheet.create({ + row: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12, + paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, + }, +}); diff --git a/src/app/sleep.tsx b/src/app/sleep.tsx new file mode 100644 index 0000000..a121a7a --- /dev/null +++ b/src/app/sleep.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { useWindowDimensions } from 'react-native'; +import { SleepChart } from '../components/charts'; +import { ErrBox, LegendRow, Loading, Panel, Screen, Sec, SrcNote, Tile, TileGrid } from '../components/ui'; +import { load } from '../lib/api'; +import { fmtDY, hm, num } from '../lib/format'; +import { shapeSleep } from '../lib/shape'; +import { C } from '../lib/theme'; +import type { SleepApiResponse, SleepCycleExport } from '../lib/types'; +import { useLoad } from '../lib/use-load'; + +export default function Sleep() { + const { width } = useWindowDimensions(); + const chartW = Math.min(608, width - 32) - 28; + const { data, err, loading, refresh } = useLoad(() => + Promise.all([load('sleepApi'), load('sleepCycle')]), + ); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data) { + const [api, sc] = data; + const nights = shapeSleep(api, sc); + const recent = nights.slice(-30); + const last7 = nights.slice(-7); + const avg = (arr: typeof nights, f: (t: (typeof nights)[0]) => number) => + arr.reduce((sum, x) => sum + f(x), 0) / (arr.length || 1); + const a7 = avg(last7, (t) => t.asleep); + const deepPct = + avg(last7.filter((t) => t.deep), (t) => t.deep / (t.asleep || 1)) * 100; + const esNights = nights.filter((t) => t.hrv); + const lastHrv = esNights.length ? esNights[esNights.length - 1] : null; + const last = nights[nights.length - 1]; + + body = ( + <> + + + + + {lastHrv ? ( + + ) : null} + + + + + + + + Sources: Eight Sleep pod (through Feb 2026, incl. HRV/HR) and Sleep Cycle app export + (through {sc.exported_at}). {num(nights.length)} nights total on record. + + + ); + } + + return {body}; +} diff --git a/src/app/train.tsx b/src/app/train.tsx new file mode 100644 index 0000000..c1ae800 --- /dev/null +++ b/src/app/train.tsx @@ -0,0 +1,102 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; +import { VolumeChart } from '../components/charts'; +import { ErrBox, Loading, Panel, Screen, Sec, Tile, TileGrid } from '../components/ui'; +import { load } from '../lib/api'; +import { agoTxt, fmtD, fmtDY, num } from '../lib/format'; +import { shapeWeeks, weekStart, workoutVolume } from '../lib/shape'; +import { C } from '../lib/theme'; +import type { Workout, WorkoutsResponse } from '../lib/types'; +import { useLoad } from '../lib/use-load'; + +function WorkoutRow({ w }: { w: Workout }) { + const [open, setOpen] = useState(false); + const { vol, sets } = workoutVolume(w); + const mins = Math.round( + (new Date(w.end_time).getTime() - new Date(w.start_time).getTime()) / 60000, + ); + return ( + setOpen(!open)}> + + {w.title} + {fmtDY(w.start_time)} + + + {mins} min · {w.exercises.length} exercises · {sets} sets · {num(Math.round(vol))} kg + + {open && ( + + {(w.exercises || []).map((ex, i) => { + const ss = (ex.sets || []) + .map((st) => { + if (st.reps != null) return st.weight_kg ? `${st.reps}×${st.weight_kg}kg` : `${st.reps}`; + if (st.duration_seconds) return `${Math.round(st.duration_seconds / 60)}min`; + return '—'; + }) + .join(', '); + return ( + + {ex.title} + {ss} + + ); + })} + + )} + + ); +} + +export default function Train() { + const { width } = useWindowDimensions(); + const chartW = Math.min(608, width - 32) - 28; + const { data, err, loading, refresh } = useLoad(() => load('workouts')); + + const shaped = useMemo(() => { + if (!data) return null; + const ws = [...data.workouts].sort((a, b) => (a.start_time < b.start_time ? -1 : 1)); + return { ws, weeks: shapeWeeks(ws, 16) }; + }, [data]); + + let body: React.ReactNode = null; + if (loading && !data) body = ; + else if (err) body = ; + else if (data && shaped) { + const { ws, weeks } = shaped; + const thisWk = weekStart(new Date().toISOString()); + const wkCount = ws.filter((w) => weekStart(w.start_time) === thisWk).length; + const last8 = weeks.slice(-8); + const avgWk = last8.reduce((sum, w) => sum + w.count, 0) / (last8.length || 1); + const last = ws[ws.length - 1]; + + body = ( + <> + + + + + + + + + + + + {ws.slice(-20).reverse().map((w) => ( + + ))} + + ); + } + + return {body}; +} + +const s = StyleSheet.create({ + row: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 12, + paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, + }, + detail: { marginTop: 10, borderTopWidth: 1, borderTopColor: C.border, paddingTop: 10 }, +}); diff --git a/src/components/animated-icon.module.css b/src/components/animated-icon.module.css deleted file mode 100644 index f8156fe..0000000 --- a/src/components/animated-icon.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.expoLogoBackground { - background-image: linear-gradient(180deg, #3c9ffe, #0274df); - border-radius: 40px; - width: 128px; - height: 128px; -} diff --git a/src/components/animated-icon.tsx b/src/components/animated-icon.tsx deleted file mode 100644 index c7c2911..0000000 --- a/src/components/animated-icon.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { Image } from 'expo-image'; -import * as SplashScreen from 'expo-splash-screen'; -import { useState } from 'react'; -import { Dimensions, StyleSheet, View } from 'react-native'; -import Animated, { Easing, Keyframe } from 'react-native-reanimated'; -import { scheduleOnRN } from 'react-native-worklets'; - -const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; -const DURATION = 600; - -export function AnimatedSplashOverlay() { - const [animate, setAnimate] = useState(false); - const [visible, setVisible] = useState(true); - - if (!visible) return null; - - const splashKeyframe = new Keyframe({ - 0: { - transform: [{ scale: 1 }], - opacity: 1, - }, - 20: { - opacity: 1, - }, - 70: { - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 0, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, - }); - - const image = ; - - return animate ? ( - { - 'worklet'; - if (finished) { - scheduleOnRN(setVisible, false); - } - })} - style={styles.splashOverlay}> - {image} - - ) : ( - { - SplashScreen.hideAsync().finally(() => { - setAnimate(true); - }); - }} - style={styles.splashOverlay}> - {image} - - ); -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: INITIAL_SCALE_FACTOR }], - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - transform: [{ scale: 1.3 }], - opacity: 0, - }, - 40: { - transform: [{ scale: 1.3 }], - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 1, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '0deg' }], - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - zIndex: 100, - }, - image: { - width: 76, - height: 71, - }, - background: { - borderRadius: 40, - experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`, - width: 128, - height: 128, - position: 'absolute', - }, - splashOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: '#208AEF', - alignItems: 'center', - justifyContent: 'center', - zIndex: 1000, - }, -}); diff --git a/src/components/animated-icon.web.tsx b/src/components/animated-icon.web.tsx deleted file mode 100644 index dfbb1fd..0000000 --- a/src/components/animated-icon.web.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Image } from 'expo-image'; -import { StyleSheet, View } from 'react-native'; -import Animated, { Keyframe, Easing } from 'react-native-reanimated'; - -import classes from './animated-icon.module.css'; -const DURATION = 300; - -export function AnimatedSplashOverlay() { - return null; -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: 0 }], - }, - 60: { - transform: [{ scale: 1.2 }], - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(1.2), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - opacity: 0, - }, - 60: { - transform: [{ scale: 1.2 }], - opacity: 0, - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - opacity: 1, - easing: Easing.elastic(1.2), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }], - opacity: 0, - }, - [DURATION / 1000]: { - transform: [{ rotateZ: '0deg' }, { scale: 1 }], - opacity: 1, - easing: Easing.elastic(0.7), - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - -
- - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - width: '100%', - zIndex: 1000, - position: 'absolute', - top: 128 / 2 + 138, - }, - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - }, - image: { - position: 'absolute', - width: 76, - height: 71, - }, - background: { - width: 128, - height: 128, - position: 'absolute', - }, -}); diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx deleted file mode 100644 index 80719bc..0000000 --- a/src/components/app-tabs.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import { useColorScheme } from 'react-native'; - -import { Colors } from '@/constants/theme'; - -export default function AppTabs() { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - Home - - - - - Explore - - - - ); -} diff --git a/src/components/app-tabs.web.tsx b/src/components/app-tabs.web.tsx deleted file mode 100644 index ca2787d..0000000 --- a/src/components/app-tabs.web.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { - Tabs, - TabList, - TabTrigger, - TabSlot, - TabTriggerSlotProps, - TabListProps, -} from 'expo-router/ui'; -import { SymbolView } from 'expo-symbols'; -import { Pressable, useColorScheme, View, StyleSheet } from 'react-native'; - -import { ExternalLink } from './external-link'; -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Colors, MaxContentWidth, Spacing } from '@/constants/theme'; - -export default function AppTabs() { - return ( - - - - - - Home - - - Explore - - - - - ); -} - -export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) { - return ( - pressed && styles.pressed}> - - - {children} - - - - ); -} - -export function CustomTabList(props: TabListProps) { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - - Expo Starter - - - {props.children} - - - - Docs - - - - - - ); -} - -const styles = StyleSheet.create({ - tabListContainer: { - position: 'absolute', - width: '100%', - padding: Spacing.three, - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', - }, - innerContainer: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.five, - borderRadius: Spacing.five, - flexDirection: 'row', - alignItems: 'center', - flexGrow: 1, - gap: Spacing.two, - maxWidth: MaxContentWidth, - }, - brandText: { - marginRight: 'auto', - }, - pressed: { - opacity: 0.7, - }, - tabButtonView: { - paddingVertical: Spacing.one, - paddingHorizontal: Spacing.three, - borderRadius: Spacing.three, - }, - externalPressable: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: Spacing.one, - marginLeft: Spacing.three, - }, -}); diff --git a/src/components/charts.tsx b/src/components/charts.tsx new file mode 100644 index 0000000..f970a0f --- /dev/null +++ b/src/components/charts.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { Text, View } from 'react-native'; +import Svg, { Circle, G, Line, Path, Rect, Text as SvgText } from 'react-native-svg'; +import { fmtD, fmtDY, hm, kfmt, num } from '../lib/format'; +import { C } from '../lib/theme'; +import type { Night, Week } from '../lib/types'; + +const DetailRow = ({ k, v, dot }: { k: string; v: string; dot?: string }) => ( + + + {dot ? : null} + {k} + + {v} + +); + +function DetailCard({ title, rows }: { title: string; rows: [string, string, string?][] }) { + return ( + + + {title} + + {rows.map(([k, v, dot]) => ( + + ))} + + ); +} + +/* stacked nightly sleep bars — tap a bar for the night's detail */ +export function SleepChart({ nights, width }: { nights: Night[]; width: number }) { + const [sel, setSel] = useState(null); + const H = 150, padL = 26, padB = 16, W = width; + const iw = W - padL, n = nights.length, bw = Math.max(3, iw / n - 2); + const maxH = Math.max(...nights.map((t) => t.total / 3600), 9); + const yMax = Math.ceil(maxH); + const y = (v: number) => H - padB - (v / yMax) * (H - padB - 6); + + const grid = []; + for (let g = 0; g <= yMax; g += 3) grid.push(g); + + const selN = sel != null ? nights[sel] : null; + + return ( + + + {grid.map((g) => ( + + + + {g}h + + + ))} + {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 ( + setSel(i === sel ? null : i)}> + + {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 ( + + ); + })} + + ); + })} + + {fmtD(nights[0].date)} + + + {fmtD(nights[n - 1].date)} + + + {selN && ( + + )} + + ); +} + +/* weekly training volume bars — tap a bar for the week's detail */ +export function VolumeChart({ weeks, width }: { weeks: Week[]; width: number }) { + const [sel, setSel] = useState(null); + const H = 140, padL = 30, padB = 16, W = width; + const iw = W - padL, n = weeks.length, bw = Math.max(4, iw / n - 3); + const maxV = Math.max(...weeks.map((w) => w.vol), 1); + const y = (v: number) => H - padB - (v / maxV) * (H - padB - 8); + const selW = sel != null ? weeks[sel] : null; + + return ( + + + {[0, 0.5, 1].map((f) => ( + + + + {kfmt(maxV * f)} + + + ))} + {weeks.map((w, i) => { + const x = padL + (i + 0.5) * (iw / n) - bw / 2; + return ( + setSel(i === sel ? null : i)}> + + + + ); + })} + + {fmtD(weeks[0].start)} + + + {fmtD(weeks[n - 1].start)} + + + {selW && ( + + )} + + ); +} + +/* biomarker sparkline */ +export function Sparkline({ + pts, flagged, width, +}: { + pts: { d: string; v: number }[]; + flagged: boolean; + width: number; +}) { + if (pts.length < 2) return null; + const W = width, H = 30, p = 4; + const vs = pts.map((q) => q.v); + const min = Math.min(...vs), max = Math.max(...vs), span = max - min || 1; + const x = (i: number) => p + (i * (W - 2 * p)) / (pts.length - 1); + const y = (v: number) => H - p - ((v - min) / span) * (H - 2 * p); + const d = pts.map((q, i) => (i ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(q.v).toFixed(1)).join(' '); + const last = pts[pts.length - 1]; + return ( + + + + + + + {fmtD(pts[0].d)} + {fmtD(last.d)} + + + ); +} diff --git a/src/components/external-link.tsx b/src/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/src/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/src/components/hint-row.tsx b/src/components/hint-row.tsx deleted file mode 100644 index acf4dc5..0000000 --- a/src/components/hint-row.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { ReactNode } from 'react'; -import { View, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -type HintRowProps = { - title?: string; - hint?: ReactNode; -}; - -export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) { - return ( - - {title} - - {hint} - - - ); -} - -const styles = StyleSheet.create({ - stepRow: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - codeSnippet: { - borderRadius: Spacing.two, - paddingVertical: Spacing.half, - paddingHorizontal: Spacing.two, - }, -}); diff --git a/src/components/login.tsx b/src/components/login.tsx new file mode 100644 index 0000000..e22e7cd --- /dev/null +++ b/src/components/login.tsx @@ -0,0 +1,84 @@ +import React, { useState } from 'react'; +import { + KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View, +} from 'react-native'; +import { useAuth } from '../lib/api'; +import { C } from '../lib/theme'; + +export function LoginScreen() { + const { login } = useAuth(); + const [user, setUser] = useState('admin'); + const [pass, setPass] = useState(''); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + + const go = async () => { + setBusy(true); + setErr(''); + try { + const ok = await login(user.trim(), pass); + if (!ok) setErr('Wrong password.'); + else setPass(''); + } catch { + setErr('Network error — are you online?'); + } finally { + setBusy(false); + } + }; + + return ( + + + ⚕️ + Health Dashboard + health.rehbock.xyz — private + USER + + PASSWORD + + + Unlock + + {err} + + + ); +} + +const s = StyleSheet.create({ + wrap: { + flex: 1, backgroundColor: C.bg, alignItems: 'center', justifyContent: 'center', padding: 24, + }, + card: { + width: '100%', maxWidth: 340, backgroundColor: C.surface, borderWidth: 1, + borderColor: C.border, borderRadius: 16, paddingVertical: 28, paddingHorizontal: 24, + }, + h1: { fontSize: 18, fontWeight: '600', color: C.text, marginBottom: 4 }, + sub: { fontSize: 12, color: C.muted, marginBottom: 20 }, + lbl: { fontSize: 11, color: C.muted, letterSpacing: 0.5, marginTop: 12, marginBottom: 5 }, + input: { + paddingVertical: 11, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, + borderColor: C.border, backgroundColor: C.surface2, color: C.text, fontSize: 15, + }, + go: { + marginTop: 20, padding: 12, borderRadius: 10, backgroundColor: C.accent, alignItems: 'center', + }, + err: { marginTop: 12, fontSize: 12.5, color: C.red, minHeight: 18 }, +}); diff --git a/src/components/themed-text.tsx b/src/components/themed-text.tsx deleted file mode 100644 index 799c8b1..0000000 --- a/src/components/themed-text.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Platform, StyleSheet, Text, type TextProps } from 'react-native'; - -import { Fonts, ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedTextProps = TextProps & { - type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code'; - themeColor?: ThemeColor; -}; - -export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) { - const theme = useTheme(); - - return ( - - ); -} - -const styles = StyleSheet.create({ - small: { - fontSize: 14, - lineHeight: 20, - fontWeight: 500, - }, - smallBold: { - fontSize: 14, - lineHeight: 20, - fontWeight: 700, - }, - default: { - fontSize: 16, - lineHeight: 24, - fontWeight: 500, - }, - title: { - fontSize: 48, - fontWeight: 600, - lineHeight: 52, - }, - subtitle: { - fontSize: 32, - lineHeight: 44, - fontWeight: 600, - }, - link: { - lineHeight: 30, - fontSize: 14, - }, - linkPrimary: { - lineHeight: 30, - fontSize: 14, - color: '#3c87f7', - }, - code: { - fontFamily: Fonts.mono, - fontWeight: Platform.select({ android: 700 }) ?? 500, - fontSize: 12, - }, -}); diff --git a/src/components/themed-view.tsx b/src/components/themed-view.tsx deleted file mode 100644 index c710df9..0000000 --- a/src/components/themed-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; - type?: ThemeColor; -}; - -export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) { - const theme = useTheme(); - - return ; -} diff --git a/src/components/ui.tsx b/src/components/ui.tsx new file mode 100644 index 0000000..d9457d2 --- /dev/null +++ b/src/components/ui.tsx @@ -0,0 +1,255 @@ +import { router } from 'expo-router'; +import React, { useState } from 'react'; +import { + ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, + TextInput, View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { clearCache } from '../lib/api'; +import { C } from '../lib/theme'; +import type { Priority } from '../lib/types'; + +/* Scrollable screen shell with pull-to-refresh (clears the API cache) */ +export function Screen({ + children, onRefresh, +}: { + children: React.ReactNode; + onRefresh?: () => void; +}) { + const insets = useSafeAreaInsets(); + const [refreshing, setRefreshing] = useState(false); + return ( + { + setRefreshing(true); + clearCache(); + onRefresh(); + setTimeout(() => setRefreshing(false), 400); + }} + /> + ) : undefined + }> + {children} + + ); +} + +export function Loading() { + return ( + + + + ); +} + +export function ErrBox({ msg }: { msg: string }) { + return ( + + + Could not load data — {msg}. Pull down to retry. + + + ); +} + +export function Sec({ title, count }: { title: string; count?: number | string }) { + return ( + + {title.toUpperCase()} + {count != null && {count}} + + ); +} + +export function TileGrid({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function Tile({ + label, value, unit, sub, delta, deltaTone, +}: { + label: string; + value: string; + unit?: string; + sub?: string; + delta?: string; + deltaTone?: 'good' | 'bad' | 'neutral'; +}) { + const toneColor = deltaTone === 'good' ? C.green : deltaTone === 'bad' ? C.red : C.muted; + return ( + + {label.toUpperCase()} + + {value} + {unit ? {unit} : null} + + {delta ? {delta} : null} + {sub ? {sub} : null} + + ); +} + +/* Today-screen navigation card */ +export function NavCard({ + title, big, unit, note, flagline, okline, href, +}: { + title: string; + big: string; + unit?: string; + note?: string; + flagline?: string; + okline?: string; + href: string; +}) { + return ( + [s.navcard, pressed && { borderColor: C.accent }]} + onPress={() => router.push(href as any)}> + + {title.toUpperCase()} + + + + {big} + {unit ? {unit} : null} + + {flagline ? {flagline} : null} + {okline ? {okline} : null} + {note ? ( + + {note} + + ) : null} + + ); +} + +export function Panel({ + title, sub, children, +}: { + title?: string; + sub?: string; + children: React.ReactNode; +}) { + return ( + + {title ? ( + + {title} + {sub ? {sub} : null} + + ) : null} + {children} + + ); +} + +export function PriorityCard({ p, rank }: { p: Priority; rank: number }) { + const color = p.severity === 'flag' ? C.red : C.yellow; + const border = p.severity === 'flag' ? 'rgba(255,69,58,0.35)' : 'rgba(245,197,66,0.35)'; + return ( + + + #{rank} · {String(p.severity).toUpperCase()} + + + {p.title} + + {p.detail} + + ); +} + +export function SearchInput({ + value, onChange, placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( + + ); +} + +export function SrcNote({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function LegendRow({ items }: { items: [string, string][] }) { + return ( + + {items.map(([label, color]) => ( + + + {label} + + ))} + + ); +} + +const s = StyleSheet.create({ + errBox: { + borderWidth: 1, borderColor: 'rgba(255,69,58,0.3)', backgroundColor: C.redDim, + borderRadius: 12, padding: 14, marginVertical: 12, + }, + sec: { + fontSize: 11, fontWeight: '600', letterSpacing: 0.8, color: C.muted, + marginTop: 26, marginBottom: 10, + }, + tiles: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + tile: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14, + padding: 14, flexBasis: '47%', flexGrow: 1, + }, + tileLbl: { fontSize: 11, color: C.muted, letterSpacing: 0.5, marginBottom: 6 }, + tileVal: { fontSize: 24, fontWeight: '300', letterSpacing: -0.6, color: C.text }, + tileUnit: { fontSize: 12, color: C.muted, fontWeight: '400', letterSpacing: 0 }, + tileSub: { fontSize: 11.5, color: C.muted, marginTop: 5 }, + navcard: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14, + padding: 14, flexBasis: '47%', flexGrow: 1, + }, + navHead: { + flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, + }, + navT: { fontSize: 12, fontWeight: '600', color: C.muted, letterSpacing: 0.6 }, + navBig: { fontSize: 22, fontWeight: '300', letterSpacing: -0.5, color: C.text }, + navNote: { fontSize: 11.5, color: C.muted, marginTop: 4 }, + panel: { + backgroundColor: C.surface, borderWidth: 1, borderColor: C.border, borderRadius: 14, + padding: 14, marginBottom: 10, + }, + panelHead: { + flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10, + }, + priority: { + borderWidth: 1, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 14, + backgroundColor: C.surface, marginBottom: 8, + }, + search: { + paddingVertical: 10, paddingHorizontal: 14, borderRadius: 12, borderWidth: 1, + borderColor: C.border, backgroundColor: C.surface, color: C.text, fontSize: 14, + marginTop: 4, marginBottom: 12, + }, + srcNote: { fontSize: 11, color: C.dim, marginTop: 12, lineHeight: 16 }, +}); diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx deleted file mode 100644 index d0d745b..0000000 --- a/src/components/ui/collapsible.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { SymbolView } from 'expo-symbols'; -import { PropsWithChildren, useState } from 'react'; -import { Pressable, StyleSheet } from 'react-native'; -import Animated, { FadeIn } from 'react-native-reanimated'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useTheme(); - - return ( - - [styles.heading, pressed && styles.pressedHeading]} - onPress={() => setIsOpen((value) => !value)}> - - - - - {title} - - {isOpen && ( - - - {children} - - - )} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - }, - pressedHeading: { - opacity: 0.7, - }, - button: { - width: Spacing.four, - height: Spacing.four, - borderRadius: 12, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - marginTop: Spacing.three, - borderRadius: Spacing.three, - marginLeft: Spacing.four, - padding: Spacing.four, - }, -}); diff --git a/src/components/web-badge.tsx b/src/components/web-badge.tsx deleted file mode 100644 index 6667898..0000000 --- a/src/components/web-badge.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { version } from 'expo/package.json'; -import { Image } from 'expo-image'; -import { useColorScheme, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -export function WebBadge() { - const scheme = useColorScheme(); - - return ( - - - v{version} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - padding: Spacing.five, - alignItems: 'center', - gap: Spacing.two, - }, - versionText: { - textAlign: 'center', - }, - badgeImage: { - width: 123, - aspectRatio: 123 / 24, - }, -}); diff --git a/src/constants/theme.ts b/src/constants/theme.ts deleted file mode 100644 index c10ed27..0000000 --- a/src/constants/theme.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. - */ - -import '@/global.css'; - -import { Platform } from 'react-native'; - -export const Colors = { - light: { - text: '#000000', - background: '#ffffff', - backgroundElement: '#F0F0F3', - backgroundSelected: '#E0E1E6', - textSecondary: '#60646C', - }, - dark: { - text: '#ffffff', - background: '#000000', - backgroundElement: '#212225', - backgroundSelected: '#2E3135', - textSecondary: '#B0B4BA', - }, -} as const; - -export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark; - -export const Fonts = Platform.select({ - ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', - }, - default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', - }, - web: { - sans: 'var(--font-display)', - serif: 'var(--font-serif)', - rounded: 'var(--font-rounded)', - mono: 'var(--font-mono)', - }, -}); - -export const Spacing = { - half: 2, - one: 4, - two: 8, - three: 16, - four: 24, - five: 32, - six: 64, -} as const; - -export const BottomTabInset = Platform.select({ ios: 50, android: 80 }) ?? 0; -export const MaxContentWidth = 800; diff --git a/src/global.css b/src/global.css deleted file mode 100644 index c8fe503..0000000 --- a/src/global.css +++ /dev/null @@ -1,9 +0,0 @@ -:root { - --font-display: - Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, - Segoe UI Symbol, Noto Color Emoji; - --font-mono: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; - --font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif; - --font-serif: Georgia, 'Times New Roman', serif; -} diff --git a/src/hooks/use-color-scheme.ts b/src/hooks/use-color-scheme.ts deleted file mode 100644 index 17e3c63..0000000 --- a/src/hooks/use-color-scheme.ts +++ /dev/null @@ -1 +0,0 @@ -export { useColorScheme } from 'react-native'; diff --git a/src/hooks/use-color-scheme.web.ts b/src/hooks/use-color-scheme.web.ts deleted file mode 100644 index 7eb1c1b..0000000 --- a/src/hooks/use-color-scheme.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme as useRNColorScheme } from 'react-native'; - -/** - * To support static rendering, this value needs to be re-calculated on the client side for web - */ -export function useColorScheme() { - const [hasHydrated, setHasHydrated] = useState(false); - - useEffect(() => { - setHasHydrated(true); - }, []); - - const colorScheme = useRNColorScheme(); - - if (hasHydrated) { - return colorScheme; - } - - return 'light'; -} diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts deleted file mode 100644 index 677e015..0000000 --- a/src/hooks/use-theme.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Learn more about light and dark modes: - * https://docs.expo.dev/guides/color-schemes/ - */ - -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function useTheme() { - const scheme = useColorScheme(); - const theme = scheme === 'unspecified' ? 'light' : scheme; - - return Colors[theme]; -} diff --git a/src/lib/api.tsx b/src/lib/api.tsx new file mode 100644 index 0000000..b204148 --- /dev/null +++ b/src/lib/api.tsx @@ -0,0 +1,144 @@ +import * as SecureStore from 'expo-secure-store'; +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +export const BASE = 'https://health.rehbock.xyz'; + +export const EP = { + meta: `${BASE}/health-meta.json`, + biomarkers: `${BASE}/api/health/v1/biomarkers`, + draws: `${BASE}/api/health/v1/draws`, + workouts: `${BASE}/api/health/v1/workouts`, + sleepApi: `${BASE}/api/health/v1/sleep`, + sleepCycle: `${BASE}/sleep-data.json`, + dexa: `${BASE}/dexa-scans.json`, + medStats: `${BASE}/api/meditation/v1/stats`, + medSessions: `${BASE}/api/meditation/v1/sessions?limit=1000`, + traits: `${BASE}/ancestry-traits.json`, +} as const; + +export type EpKey = keyof typeof EP; + +const AUTH_KEY = 'hd.auth'; + +let token: string | null = null; +let onUnauthorized: (() => void) | null = null; + +/* base64 without Buffer/btoa dependency quirks across Hermes versions */ +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +export function b64(str: string): string { + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + const code = str.codePointAt(i)!; + if (code > 0xffff) i++; + if (code < 0x80) bytes.push(code); + else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 63)); + else if (code < 0x10000) + bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63)); + else + bytes.push( + 0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), + 0x80 | ((code >> 6) & 63), 0x80 | (code & 63), + ); + } + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const [a, b, c] = [bytes[i], bytes[i + 1], bytes[i + 2]]; + out += B64[a >> 2] + B64[((a & 3) << 4) | (b ?? 0) >> 4]; + out += b === undefined ? '=' : B64[((b & 15) << 2) | (c ?? 0) >> 6]; + out += c === undefined ? '=' : B64[c & 63]; + } + return out; +} + +async function authFetch(url: string): Promise { + const r = await fetch(url, { + headers: { Authorization: 'Basic ' + token }, + cache: 'no-store', + }); + if (r.status === 401 || r.status === 403) { + onUnauthorized?.(); + throw new Error('unauthorized'); + } + if (!r.ok) throw new Error(`${url} → ${r.status}`); + return r.json(); +} + +/* module-level promise cache, cleared on refresh/logout */ +let cache: Partial>> = {}; + +export function load(key: EpKey): Promise { + if (!cache[key]) + cache[key] = authFetch(EP[key]).catch((e) => { + delete cache[key]; + throw e; + }); + return cache[key] as Promise; +} + +export function clearCache() { + cache = {}; +} + +/* ---- auth context ---- */ + +interface AuthCtx { + ready: boolean; + authed: boolean; + login: (user: string, pass: string) => Promise; + logout: () => void; +} + +const Ctx = createContext({ + ready: false, + authed: false, + login: async () => false, + logout: () => {}, +}); + +export const useAuth = () => useContext(Ctx); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const [authed, setAuthed] = useState(false); + + useEffect(() => { + SecureStore.getItemAsync(AUTH_KEY) + .then((t) => { + if (t) { + token = t; + setAuthed(true); + } + }) + .finally(() => setReady(true)); + }, []); + + const logout = useCallback(() => { + token = null; + clearCache(); + SecureStore.deleteItemAsync(AUTH_KEY).catch(() => {}); + setAuthed(false); + }, []); + + useEffect(() => { + onUnauthorized = logout; + return () => { + onUnauthorized = null; + }; + }, [logout]); + + const login = useCallback(async (user: string, pass: string) => { + const tok = b64(`${user}:${pass}`); + const r = await fetch(EP.meta, { + headers: { Authorization: 'Basic ' + tok }, + cache: 'no-store', + }); + if (!r.ok) return false; + token = tok; + clearCache(); + await SecureStore.setItemAsync(AUTH_KEY, tok); + setAuthed(true); + return true; + }, []); + + return {children}; +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..0e3cb43 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,35 @@ +/* date-only strings must parse as LOCAL dates — new Date('2026-07-09') is UTC + midnight and renders as the previous day west of Greenwich */ +export const parseD = (s: string | Date): Date => { + if (s instanceof Date) return s; + const str = String(s); + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str.slice(0, 10)); + return m && str.length <= 10 ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(str); +}; + +export const fmtD = (iso: string) => + parseD(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +export const fmtDY = (iso: string) => + parseD(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + +export const hm = (sec: number) => { + const h = Math.floor(sec / 3600); + const m = Math.round((sec % 3600) / 60); + return `${h}h ${String(m).padStart(2, '0')}m`; +}; + +export const kfmt = (n: number) => + n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(Math.round(n)); + +export const num = (n: number) => Number(n).toLocaleString('en-US'); + +export const cap = (s: string | null | undefined) => String(s || '').replace(/_/g, ' '); + +export const daysAgo = (iso: string) => + Math.floor((Date.now() - parseD(iso).getTime()) / 86400000); + +export const agoTxt = (iso: string) => { + const d = daysAgo(iso); + return d === 0 ? 'today' : d === 1 ? 'yesterday' : `${d} days ago`; +}; diff --git a/src/lib/shape.ts b/src/lib/shape.ts new file mode 100644 index 0000000..2b10cf6 --- /dev/null +++ b/src/lib/shape.ts @@ -0,0 +1,94 @@ +import type { + BiomarkersResponse, MarkerGroup, Night, SleepApiResponse, SleepCycleExport, + Week, Workout, +} from './types'; + +export function shapeSleep(api: SleepApiResponse, sc: SleepCycleExport): Night[] { + const byDate = new Map(); + (sc.sessions || []).forEach((r) => { + const o: Record = Object.fromEntries(sc.fields.map((f, i) => [f, r[i]])); + const date = String(o.end || '').slice(0, 10); + if (!date) return; + byDate.set(date, { + date, + total: o.inbed_s || o.asleep_s, + asleep: o.asleep_s, + deep: o.deep_s, + rem: o.rem_s, + light: o.light_s || (o.deep_s || o.rem_s ? 0 : o.asleep_s), + awake: o.awake_s, + quality: o.quality, + hrv: null, + hr: null, + src: 'Sleep Cycle', + }); + }); + (api.sessions || []).forEach((s) => { + const date = s.ended_at.slice(0, 10); + byDate.set(date, { + date, + total: s.total_seconds, + asleep: s.sleep_seconds, + deep: s.deep_seconds, + rem: s.rem_seconds, + light: s.light_seconds, + awake: s.awake_seconds, + quality: null, + hrv: s.avg_hrv, + hr: s.avg_hr, + src: 'Eight Sleep', + }); + }); + return [...byDate.values()].sort((a, b) => (a.date < b.date ? -1 : 1)); +} + +export function workoutVolume(w: Workout): { vol: number; sets: number } { + let vol = 0; + let sets = 0; + (w.exercises || []).forEach((ex) => + (ex.sets || []).forEach((s) => { + sets++; + if (s.weight_kg && s.reps) vol += s.weight_kg * s.reps; + }), + ); + return { vol, sets }; +} + +export function weekStart(d: string): string { + const dt = new Date(d); + const day = (dt.getDay() + 6) % 7; + dt.setDate(dt.getDate() - day); + return dt.toISOString().slice(0, 10); +} + +export function shapeWeeks(workouts: Workout[], n: number): Week[] { + const map = new Map(); + workouts.forEach((w) => { + const k = weekStart(w.start_time); + if (!map.has(k)) map.set(k, { start: k, vol: 0, count: 0, sets: 0 }); + const e = map.get(k)!; + const { vol, sets } = workoutVolume(w); + e.vol += vol; + e.count++; + e.sets += sets; + }); + return [...map.values()].sort((a, b) => (a.start < b.start ? -1 : 1)).slice(-n); +} + +export function shapeBiomarkers(bm: BiomarkersResponse): MarkerGroup[] { + const groups = new Map(); + bm.biomarkers.forEach((b) => { + if (!groups.has(b.name)) groups.set(b.name, []); + groups.get(b.name)!.push(b); + }); + const out: MarkerGroup[] = []; + groups.forEach((hist, name) => { + hist.sort((a, b) => (a.collected < b.collected ? -1 : 1)); + const latest = hist[hist.length - 1]; + const series = hist + .filter((h) => h.unit === latest.unit && h.value_num != null) + .map((h) => ({ d: h.collected, v: h.value_num! })); + out.push({ name, latest, series, panel: latest.panel }); + }); + return out.sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..430a382 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,25 @@ +/* Dark palette ported from the health.rehbock.xyz PWA */ +export const C = { + bg: '#0d0d0f', + surface: '#141417', + surface2: '#1c1c21', + border: '#252529', + text: '#e8e8ed', + muted: '#8a8a99', + dim: '#4a4a55', + green: '#34c759', + greenDim: 'rgba(52,199,89,0.12)', + yellow: '#f5c542', + yellowDim: 'rgba(245,197,66,0.12)', + red: '#ff453a', + redDim: 'rgba(255,69,58,0.12)', + accent: '#6e6af0', + accentDim: 'rgba(110,106,240,0.12)', + // validated series palette (dark, surface #141417) + s1: '#3987e5', + s2: '#d95926', + s3: '#199e70', + s4: '#c98500', + awake: '#3c3c46', + gridline: '#232328', +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..1ffa77a --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,186 @@ +export interface Priority { + title: string; + severity: 'attention' | 'flag' | string; + detail: string; +} + +export interface HealthMeta { + current_priorities?: Priority[]; + tests_never_done?: string[]; +} + +export interface Biomarker { + name: string; + panel: string; + collected: string; // YYYY-MM-DD + value: string | number; + value_num: number | null; + unit: string | null; + reference_range: string | null; + flagged: boolean; +} + +export interface BiomarkersResponse { + biomarkers: Biomarker[]; +} + +export interface DrawsResponse { + draws: unknown[]; +} + +export interface WorkoutSet { + weight_kg: number | null; + reps: number | null; + duration_seconds: number | null; +} + +export interface WorkoutExercise { + title: string; + sets: WorkoutSet[]; +} + +export interface Workout { + title: string; + start_time: string; + end_time: string; + exercises: WorkoutExercise[]; +} + +export interface WorkoutsResponse { + workouts: Workout[]; + synced_at: string; +} + +export interface EightSleepSession { + ended_at: string; + total_seconds: number; + sleep_seconds: number; + deep_seconds: number; + rem_seconds: number; + light_seconds: number; + awake_seconds: number; + avg_hrv: number | null; + avg_hr: number | null; +} + +export interface SleepApiResponse { + sessions: EightSleepSession[]; +} + +/* Sleep Cycle export: columnar rows keyed by `fields` */ +export interface SleepCycleExport { + exported_at: string; + fields: string[]; + sessions: (string | number | null)[][]; +} + +export interface DexaSummary { + total_body_fat_pct: number; + total_lean_mass_kg: number; + total_fat_mass_kg: number; + total_mass_kg: number; + est_vat_area_cm2: number; + est_vat_risk: string; + appendicular_lean_mass_to_height2_kg_m2: number; + appendicular_lean_mass_normal_threshold_kg_m2: number; + total_body_bmd_g_cm2: number; + resting_metabolic_rate_kcal_day: number; +} + +export interface DexaRegion { + region: string; + fat_pct: number | null; + lean_mass_g: number | null; + fat_mass_g: number | null; +} + +export interface DexaIndex { + measure: string; + result: number | string; + unit?: string | null; + percentile_yn?: number | null; +} + +export interface DexaScan { + scan_date: string; + provider: string; + model?: string; + summary: DexaSummary; + body_composition_by_region?: DexaRegion[]; + adipose_indices?: DexaIndex[]; + lean_indices?: DexaIndex[]; +} + +export interface DexaResponse { + scans: DexaScan[]; +} + +export interface MeditationStats { + totalMinutes: number; + totalSessions: number; + currentStreak: number; +} + +export interface MeditationSession { + startedAt: string; + endedAt: string; + plannedMin: number; + actualMin: number; + completionPct: number; + completed: boolean; + rating: number | null; + note: string | null; +} + +export interface MeditationSessionsResponse { + sessions: MeditationSession[]; +} + +export interface TraitScale { + left: string; + right: string; +} + +export interface Trait { + trait: string; + category: string; + result: string; + position_estimate: number | null; + scale: TraitScale | null; +} + +export interface TraitsResponse { + source: string; + trait_count: number; + traits: Trait[]; +} + +/* ---- shaped ---- */ + +export interface Night { + date: string; + total: number; + asleep: number; + deep: number; + rem: number; + light: number; + awake: number; + quality: number | null; + hrv: number | null; + hr: number | null; + src: string; +} + +export interface Week { + start: string; + vol: number; + count: number; + sets: number; +} + +export interface MarkerGroup { + name: string; + latest: Biomarker; + series: { d: string; v: number }[]; + panel: string; +} diff --git a/src/lib/use-load.ts b/src/lib/use-load.ts new file mode 100644 index 0000000..cf3534a --- /dev/null +++ b/src/lib/use-load.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react'; +import { EpKey, load } from './api'; + +interface LoadState { + data?: T; + err?: string; + loading: boolean; +} + +/* Load one or more endpoints; refresh() re-runs after the cache is cleared. */ +export function useLoad(fn: () => Promise): LoadState & { refresh: () => void } { + const [state, setState] = useState>({ loading: true }); + const [tick, setTick] = useState(0); + + useEffect(() => { + let live = true; + setState((s) => ({ ...s, loading: true, err: undefined })); + fn() + .then((data) => live && setState({ data, loading: false })) + .catch((e) => { + if (!live) return; + const msg = e instanceof Error ? e.message : String(e); + if (msg !== 'unauthorized') setState({ err: msg, loading: false }); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tick]); + + const refresh = useCallback(() => setTick((t) => t + 1), []); + return { ...state, refresh }; +} + +/* Load several endpoints; failures resolve to null per-key (Today screen). */ +export function loadAll>( + spec: T, +): Promise<{ [K in keyof T]: unknown | null }> { + const keys = Object.keys(spec) as (keyof T)[]; + return Promise.all( + keys.map((k) => load(spec[k]).catch(() => null)), + ).then((vals) => { + const out = {} as { [K in keyof T]: unknown | null }; + keys.forEach((k, i) => (out[k] = vals[i])); + return out; + }); +}