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": {
|
"expo": {
|
||||||
"name": "health-app",
|
"name": "Health",
|
||||||
"slug": "health-app",
|
"slug": "health-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "healthapp",
|
"scheme": "healthapp",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "dark",
|
||||||
|
"backgroundColor": "#0d0d0f",
|
||||||
"ios": {
|
"ios": {
|
||||||
"icon": "./assets/expo.icon"
|
"icon": "./assets/expo.icon",
|
||||||
|
"bundleIdentifier": "xyz.rehbock.health",
|
||||||
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"package": "xyz.rehbock.health",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#0d0d0f",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@@ -25,10 +29,11 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#208AEF",
|
"backgroundColor": "#0d0d0f",
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"imageWidth": 76
|
"imageWidth": 76
|
||||||
}
|
}
|
||||||
|
|||||||
167
package-lock.json
generated
167
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"expo-image": "~57.0.2",
|
"expo-image": "~57.0.2",
|
||||||
"expo-linking": "~57.0.5",
|
"expo-linking": "~57.0.5",
|
||||||
"expo-router": "~57.0.11",
|
"expo-router": "~57.0.11",
|
||||||
|
"expo-secure-store": "~57.0.1",
|
||||||
"expo-splash-screen": "~57.0.5",
|
"expo-splash-screen": "~57.0.5",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-symbols": "~57.0.2",
|
"expo-symbols": "~57.0.2",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.10.1"
|
"react-native-worklets": "0.10.1"
|
||||||
},
|
},
|
||||||
@@ -2975,6 +2977,12 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||||
@@ -3404,6 +3412,56 @@
|
|||||||
"hyphenate-style-name": "^1.0.3"
|
"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": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -3518,6 +3576,61 @@
|
|||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -3545,6 +3658,18 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/error-stack-parser": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
|
"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": {
|
"node_modules/expo-server": {
|
||||||
"version": "57.0.1",
|
"version": "57.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz",
|
||||||
@@ -5311,6 +5445,12 @@
|
|||||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"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": "^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": {
|
"node_modules/nullthrows": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||||
@@ -6474,6 +6626,21 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"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-image": "~57.0.2",
|
||||||
"expo-linking": "~57.0.5",
|
"expo-linking": "~57.0.5",
|
||||||
"expo-router": "~57.0.11",
|
"expo-router": "~57.0.11",
|
||||||
|
"expo-secure-store": "~57.0.1",
|
||||||
"expo-splash-screen": "~57.0.5",
|
"expo-splash-screen": "~57.0.5",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-symbols": "~57.0.2",
|
"expo-symbols": "~57.0.2",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0",
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.10.1"
|
"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 { Stack } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useColorScheme } from 'react-native';
|
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';
|
function Gate() {
|
||||||
import AppTabs from '@/components/app-tabs';
|
const { ready, authed } = useAuth();
|
||||||
|
if (!ready) return <View style={{ flex: 1, backgroundColor: C.bg }} />;
|
||||||
SplashScreen.preventAutoHideAsync();
|
if (!authed) return <LoginScreen />;
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<Stack
|
||||||
<AnimatedSplashOverlay />
|
screenOptions={{
|
||||||
<AppTabs />
|
headerStyle: { backgroundColor: C.bg },
|
||||||
</ThemeProvider>
|
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 { Stack } from 'expo-router';
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import React from 'react';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
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';
|
export default function Today() {
|
||||||
import { HintRow } from '@/components/hint-row';
|
const { logout } = useAuth();
|
||||||
import { ThemedText } from '@/components/themed-text';
|
const { data, loading, refresh } = useLoad(() =>
|
||||||
import { ThemedView } from '@/components/themed-view';
|
loadAll({
|
||||||
import { WebBadge } from '@/components/web-badge';
|
meta: 'meta', bio: 'biomarkers', wo: 'workouts', sApi: 'sleepApi',
|
||||||
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
sc: 'sleepCycle', dexa: 'dexa', med: 'medStats',
|
||||||
|
} as const),
|
||||||
|
);
|
||||||
|
|
||||||
function getDevMenuHint() {
|
const r = data as
|
||||||
if (Platform.OS === 'web') {
|
| {
|
||||||
return <ThemedText type="small">use browser devtools</ThemedText>;
|
meta: HealthMeta | null;
|
||||||
}
|
bio: BiomarkersResponse | null;
|
||||||
if (Device.isDevice) {
|
wo: WorkoutsResponse | null;
|
||||||
return (
|
sApi: SleepApiResponse | null;
|
||||||
<ThemedText type="small">
|
sc: SleepCycleExport | null;
|
||||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
dexa: DexaResponse | null;
|
||||||
</ThemedText>
|
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 (
|
return (
|
||||||
<ThemedText type="small">
|
<>
|
||||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
<Stack.Screen
|
||||||
</ThemedText>
|
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