Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c279e86750 |
Binary file not shown.
@@ -4,7 +4,7 @@ android {
|
|||||||
namespace = "io.ionic.starter"
|
namespace = "io.ionic.starter"
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.ionic.starter"
|
applicationId "com.habitmode.llemba.com"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">healthcare</string>
|
<string name="app_name">app</string>
|
||||||
<string name="title_activity_main">healthcare</string>
|
<string name="title_activity_main">app</string>
|
||||||
<string name="package_name">io.ionic.starter</string>
|
<string name="package_name">io.ionic.starter</string>
|
||||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'com.healthcare.app',
|
appId: 'com.habitmode.llemba.com',
|
||||||
appName: 'healthcare',
|
appName: 'HabitMode',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
plugins: {
|
plugins: {
|
||||||
FirebaseAuthentication: {
|
FirebaseAuthentication: {
|
||||||
skipNativeAuth: false,
|
skipNativeAuth: false,
|
||||||
providers: [],
|
providers: ["google.com", "apple.com"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ workflows:
|
|||||||
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
|
||||||
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
|
||||||
fi
|
fi
|
||||||
|
- name: Check native credentials
|
||||||
|
script: |
|
||||||
|
if [ -n "$VITE_GOOGLE_MAPS_KEY" ]; then
|
||||||
|
echo "VITE_GOOGLE_MAPS_KEY: set (length=${#VITE_GOOGLE_MAPS_KEY}, ends …${VITE_GOOGLE_MAPS_KEY: -4})"
|
||||||
|
else
|
||||||
|
echo "WARNING: VITE_GOOGLE_MAPS_KEY not set — Google Maps will not render"
|
||||||
|
fi
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
script: |
|
script: |
|
||||||
cd android
|
cd android
|
||||||
@@ -68,6 +75,21 @@ workflows:
|
|||||||
else
|
else
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug --no-daemon
|
||||||
fi
|
fi
|
||||||
|
- name: Verify manifest substitution
|
||||||
|
script: |
|
||||||
|
MANIFEST="android/app/build/intermediates/merged_manifests/debug/AndroidManifest.xml"
|
||||||
|
if [ -f "$MANIFEST" ]; then
|
||||||
|
VAL=$(grep -A1 'geo.API_KEY' "$MANIFEST" | grep -o 'android:value="[^"]*"' | cut -d'"' -f2)
|
||||||
|
if [ -z "$VAL" ]; then
|
||||||
|
echo "WARNING: geo.API_KEY meta-data not found in merged manifest"
|
||||||
|
elif [ "$VAL" = '${googleMapsApiKey}' ]; then
|
||||||
|
echo "WARNING: geo.API_KEY placeholder was not substituted — key missing from build env"
|
||||||
|
else
|
||||||
|
echo "geo.API_KEY substituted OK (ends …${VAL: -4})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Merged manifest not found at $MANIFEST"
|
||||||
|
fi
|
||||||
artifacts:
|
artifacts:
|
||||||
- android/app/build/outputs/apk/debug/app-debug.apk
|
- android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthcare.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.healthcare.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>production</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
import FirebaseCore
|
||||||
|
import FirebaseMessaging
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
@@ -7,43 +9,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
FirebaseApp.configure()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillResignActive(_ application: UIApplication) {
|
func applicationWillResignActive(_ application: UIApplication) {}
|
||||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
|
||||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {}
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {}
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {}
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {}
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
|
||||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
// Called when the app was launched with an activity, including Universal Links.
|
|
||||||
// Feel free to add additional processing here, but if you want the App API to support
|
|
||||||
// tracking app url opens, make sure to keep this call
|
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
Messaging.messaging().apnsToken = deviceToken
|
||||||
|
Messaging.messaging().token { token, error in
|
||||||
|
if let error = error {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
} else if let token = token {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>healthcare</string>
|
<string>HabitMode</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -50,11 +50,9 @@
|
|||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>com.healthcare.app</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.healthcare.app</string>
|
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
|||||||
Generated
+3404
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -20,11 +20,10 @@
|
|||||||
"@capacitor/keyboard": "8.0.3",
|
"@capacitor/keyboard": "8.0.3",
|
||||||
"@capacitor/push-notifications": "^8.1.1",
|
"@capacitor/push-notifications": "^8.1.1",
|
||||||
"@capacitor/status-bar": "8.0.2",
|
"@capacitor/status-bar": "8.0.2",
|
||||||
"@ionic/pwa-elements": "^3.0.0",
|
"@ionic/pwa-elements": "^3.4.0",
|
||||||
"@ionic/react": "^8.5.0",
|
"@ionic/react": "^8.5.0",
|
||||||
"@ionic/react-router": "^8.5.0",
|
"@ionic/react-router": "^8.5.0",
|
||||||
"@supabase/supabase-js": "^2.108.1",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
"date-fns": "^4.4.0",
|
|
||||||
"firebase": "^11.0.0",
|
"firebase": "^11.0.0",
|
||||||
"ionicons": "^7.4.0",
|
"ionicons": "^7.4.0",
|
||||||
"katex": "^0.16.0",
|
"katex": "^0.16.0",
|
||||||
|
|||||||
+11
-29
@@ -1,22 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect, Route } from 'react-router-dom';
|
import { Redirect, Route } from 'react-router-dom';
|
||||||
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
|
import { IonApp, IonRouterOutlet } from '@ionic/react';
|
||||||
import { IonReactRouter } from '@ionic/react-router';
|
import { IonReactRouter } from '@ionic/react-router';
|
||||||
import Home from './pages/Home';
|
|
||||||
import Checkups from './pages/Checkups';
|
|
||||||
import Diet from './pages/Diet';
|
|
||||||
import Sleep from './pages/Sleep';
|
|
||||||
import Insights from './pages/Insights';
|
|
||||||
|
|
||||||
/* Core CSS required for Ionic components to work properly */
|
|
||||||
import '@ionic/react/css/core.css';
|
import '@ionic/react/css/core.css';
|
||||||
|
|
||||||
/* Basic CSS for apps built with Ionic */
|
|
||||||
import '@ionic/react/css/normalize.css';
|
import '@ionic/react/css/normalize.css';
|
||||||
import '@ionic/react/css/structure.css';
|
import '@ionic/react/css/structure.css';
|
||||||
import '@ionic/react/css/typography.css';
|
import '@ionic/react/css/typography.css';
|
||||||
|
|
||||||
/* Optional CSS utils that can be commented out */
|
|
||||||
import '@ionic/react/css/padding.css';
|
import '@ionic/react/css/padding.css';
|
||||||
import '@ionic/react/css/float-elements.css';
|
import '@ionic/react/css/float-elements.css';
|
||||||
import '@ionic/react/css/text-alignment.css';
|
import '@ionic/react/css/text-alignment.css';
|
||||||
@@ -24,31 +14,23 @@ import '@ionic/react/css/text-transformation.css';
|
|||||||
import '@ionic/react/css/flex-utils.css';
|
import '@ionic/react/css/flex-utils.css';
|
||||||
import '@ionic/react/css/display.css';
|
import '@ionic/react/css/display.css';
|
||||||
|
|
||||||
/**
|
|
||||||
* Ionic Dark Mode
|
|
||||||
* -----------------------------------------------------
|
|
||||||
* For more info, please see:
|
|
||||||
* https://ionicframework.com/docs/theming/dark-mode
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* import '@ionic/react/css/palettes/dark.always.css'; */
|
|
||||||
/* import '@ionic/react/css/palettes/dark.class.css'; */
|
|
||||||
import '@ionic/react/css/palettes/dark.system.css';
|
|
||||||
|
|
||||||
/* Theme variables */
|
|
||||||
import './theme/variables.css';
|
import './theme/variables.css';
|
||||||
|
|
||||||
setupIonicReact();
|
import Home from './pages/Home';
|
||||||
|
import Account from './pages/Account';
|
||||||
|
import Auth from './pages/Auth';
|
||||||
|
import Stats from './pages/Stats';
|
||||||
|
import HabitDetail from './pages/HabitDetail';
|
||||||
|
|
||||||
const App: React.FC = () => (
|
const App = (): any => (
|
||||||
<IonApp>
|
<IonApp>
|
||||||
<IonReactRouter>
|
<IonReactRouter>
|
||||||
<IonRouterOutlet>
|
<IonRouterOutlet>
|
||||||
|
<Route exact path="/auth" component={Auth} />
|
||||||
<Route exact path="/home" component={Home} />
|
<Route exact path="/home" component={Home} />
|
||||||
<Route exact path="/checkups" component={Checkups} />
|
<Route exact path="/stats" component={Stats} />
|
||||||
<Route exact path="/diet" component={Diet} />
|
<Route exact path="/habit/:id" component={HabitDetail} />
|
||||||
<Route exact path="/sleep" component={Sleep} />
|
<Route exact path="/account" component={Account} />
|
||||||
<Route exact path="/insights" component={Insights} />
|
|
||||||
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
<Route exact path="/" render={() => <Redirect to="/home" />} />
|
||||||
</IonRouterOutlet>
|
</IonRouterOutlet>
|
||||||
</IonReactRouter>
|
</IonReactRouter>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 771 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 752 KiB |
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface CustomProgressBarProps {
|
||||||
|
value: number; // 0 to 1
|
||||||
|
label?: string;
|
||||||
|
statusText?: string;
|
||||||
|
color?: string; // Hex, rgb, or css variable
|
||||||
|
showPercent?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomProgressBar: React.FC<CustomProgressBarProps> = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
statusText,
|
||||||
|
color = 'var(--ion-color-primary)',
|
||||||
|
showPercent = true,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const percentage = Math.round(Math.min(Math.max(value, 0), 1) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', ...style }}>
|
||||||
|
{/* Label and Status */}
|
||||||
|
{(label || statusText || showPercent) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontWeight: '600',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--ion-color-medium)' }}>
|
||||||
|
{label}{' '}
|
||||||
|
{statusText && (
|
||||||
|
<span style={{ fontWeight: 'normal', opacity: 0.85 }}>
|
||||||
|
• {statusText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showPercent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: color,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontWeight: '700',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress Track */}
|
||||||
|
<IonProgressBar
|
||||||
|
value={value}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
height: '6px',
|
||||||
|
'--background': 'rgba(148, 163, 184, 0.1)',
|
||||||
|
'--progress-background': color,
|
||||||
|
borderRadius: '10px',
|
||||||
|
transition: 'value 0.3s ease',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomProgressBar;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonButton, IonIcon } from '@ionic/react';
|
||||||
|
import { sparklesOutline } from 'ionicons/icons';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="empty-state-card home-empty-state-card">
|
||||||
|
<div className="empty-state-icon">
|
||||||
|
<IonIcon icon={sparklesOutline} />
|
||||||
|
</div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
<IonButton className="home-empty-state-button" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonButton, IonIcon, IonSpinner } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
trashOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
|
||||||
|
interface HabitCardProps {
|
||||||
|
habit: Tables<'habits'>;
|
||||||
|
completedToday: boolean;
|
||||||
|
currentStreak: number;
|
||||||
|
completionRate: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HabitCard: React.FC<HabitCardProps> = ({
|
||||||
|
habit,
|
||||||
|
completedToday,
|
||||||
|
currentStreak,
|
||||||
|
completionRate,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
busy,
|
||||||
|
}) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`habit-card habit-card--editorial ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => history.push(`/habit/${habit.id}`)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
history.push(`/habit/${habit.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View details for ${habit.name}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`habit-check-button ${completedToday ? 'is-complete' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={
|
||||||
|
completedToday
|
||||||
|
? `Mark ${habit.name} as not done`
|
||||||
|
: `Mark ${habit.name} as done`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<IonSpinner name="crescent" />
|
||||||
|
) : completedToday ? (
|
||||||
|
<IonIcon icon={checkmarkOutline} />
|
||||||
|
) : (
|
||||||
|
<IonIcon icon={ellipseOutline} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="habit-card-main">
|
||||||
|
<div className="habit-copy">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<h3>{habit.name}</h3>
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
<div className="habit-metrics-row">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={flameOutline} /> {currentStreak} streak
|
||||||
|
</span>
|
||||||
|
<span>{completionRate}% this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
className="habit-delete-button"
|
||||||
|
fill="clear"
|
||||||
|
color="medium"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitCard;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonModal,
|
||||||
|
IonTextarea,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onSubmit: (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
initialValues?: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#7c3aed',
|
||||||
|
'#0f766e',
|
||||||
|
'#ea580c',
|
||||||
|
'#dc2626',
|
||||||
|
'#2563eb',
|
||||||
|
'#059669',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HabitFormModal: React.FC<HabitFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onDismiss,
|
||||||
|
onSubmit,
|
||||||
|
initialValues,
|
||||||
|
}) => {
|
||||||
|
const isEditing = !!initialValues;
|
||||||
|
const [name, setName] = useState(initialValues?.name ?? '');
|
||||||
|
const [targetDescription, setTargetDescription] = useState(
|
||||||
|
initialValues?.targetDescription ?? ''
|
||||||
|
);
|
||||||
|
const [color, setColor] = useState(initialValues?.color ?? COLORS[0]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => name.trim().length > 0 && name.trim().length <= 80,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName(initialValues?.name ?? '');
|
||||||
|
setTargetDescription(initialValues?.targetDescription ?? '');
|
||||||
|
setColor(initialValues?.color ?? COLORS[0]);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValues]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
targetDescription: targetDescription.trim(),
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
setName('');
|
||||||
|
setTargetDescription('');
|
||||||
|
setColor(COLORS[0]);
|
||||||
|
onDismiss();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to save habit.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonModal isOpen={isOpen} onDidDismiss={onDismiss}>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>{isEditing ? 'Edit Habit' : 'New Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
<IonButton onClick={onDismiss}>Close</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent className="ion-padding">
|
||||||
|
<div className="habit-form-grid">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
label="Habit name"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={name}
|
||||||
|
maxlength={80}
|
||||||
|
placeholder="Drink water"
|
||||||
|
onIonInput={(e) => setName(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonTextarea
|
||||||
|
label="Target or note"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={targetDescription}
|
||||||
|
placeholder="8 glasses, 20 minutes, 30 pushups..."
|
||||||
|
autoGrow
|
||||||
|
onIonInput={(e) => setTargetDescription(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IonLabel className="color-label">Color</IonLabel>
|
||||||
|
<div className="habit-color-row">
|
||||||
|
{COLORS.map((swatch) => (
|
||||||
|
<button
|
||||||
|
key={swatch}
|
||||||
|
type="button"
|
||||||
|
className={`habit-color-dot ${color === swatch ? 'selected' : ''}`}
|
||||||
|
style={{ background: swatch }}
|
||||||
|
onClick={() => setColor(swatch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="auth-feedback error-text">{error}</p> : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : isEditing ? 'Save Changes' : 'Create Habit'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitFormModal;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
IonHeader,
|
||||||
|
IonToolbar,
|
||||||
|
IonTitle,
|
||||||
|
IonButtons,
|
||||||
|
IonBackButton,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { setStatusBarStyle, Style } from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
defaultBackHref?: string;
|
||||||
|
endActions?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
toolbarStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
showBackButton = false,
|
||||||
|
defaultBackHref = '/home',
|
||||||
|
endActions,
|
||||||
|
style,
|
||||||
|
toolbarStyle,
|
||||||
|
}) => {
|
||||||
|
// Use Ionic lifecycle hook to set status bar style automatically for pages using PageHeader
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark); // Dark text/icons for light-colored headers
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonHeader className="ion-no-border" style={style}>
|
||||||
|
<IonToolbar
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--background': '#ffffff',
|
||||||
|
'--color': 'var(--ion-color-dark)',
|
||||||
|
'--border-width': '0 0 1px 0',
|
||||||
|
'--border-color': 'rgba(0, 0, 0, 0.05)',
|
||||||
|
...toolbarStyle,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showBackButton && (
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref={defaultBackHref} />
|
||||||
|
</IonButtons>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IonTitle
|
||||||
|
style={{
|
||||||
|
fontWeight: '800',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
letterSpacing: '-0.3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</IonTitle>
|
||||||
|
|
||||||
|
{endActions && <IonButtons slot="end">{endActions}</IonButtons>}
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonIcon } from '@ionic/react';
|
||||||
|
|
||||||
|
interface RevisionInstrumentCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: string;
|
||||||
|
iconColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
onClick: () => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RevisionInstrumentCard: React.FC<RevisionInstrumentCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconColor,
|
||||||
|
iconBgColor,
|
||||||
|
onClick,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '14px 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'none',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className="ion-activatable"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: iconBgColor,
|
||||||
|
color: iconColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 8px auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon icon={icon} style={{ fontSize: '18px' }} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{subtitle && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
display: 'block',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevisionInstrumentCard;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IonProgressBar } from '@ionic/react';
|
||||||
|
|
||||||
|
interface StatsHabitCardProps {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
completedCount: number;
|
||||||
|
totalDays: number;
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsHabitCard: React.FC<StatsHabitCardProps> = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
completedCount,
|
||||||
|
totalDays,
|
||||||
|
streak,
|
||||||
|
}) => {
|
||||||
|
const ratio = totalDays > 0 ? completedCount / totalDays : 0;
|
||||||
|
const percentage = Math.round(ratio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-card">
|
||||||
|
<div className="stats-card-header">
|
||||||
|
<div>
|
||||||
|
<h3>{name}</h3>
|
||||||
|
<p>
|
||||||
|
{completedCount} of {totalDays} days completed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stats-streak-badge" style={{ color }}>
|
||||||
|
{streak}🔥
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IonProgressBar
|
||||||
|
value={ratio}
|
||||||
|
style={{ '--progress-background': color } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<div className="stats-card-footer">
|
||||||
|
<span>{percentage}% consistency</span>
|
||||||
|
<span>Current streak {streak}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsHabitCard;
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Department } from '../../types/cohortHealth';
|
|
||||||
import {
|
|
||||||
IonLabel,
|
|
||||||
IonSegment,
|
|
||||||
IonSegmentButton,
|
|
||||||
IonBadge,
|
|
||||||
IonIcon,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { shieldCheckmarkOutline, peopleOutline } from 'ionicons/icons';
|
|
||||||
|
|
||||||
interface DepartmentSelectorProps {
|
|
||||||
currentDepartment: Department;
|
|
||||||
onDepartmentChange: (dept: Department) => void;
|
|
||||||
cohortSizes: Record<Department, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DepartmentSelector: React.FC<DepartmentSelectorProps> = ({
|
|
||||||
currentDepartment,
|
|
||||||
onDepartmentChange,
|
|
||||||
cohortSizes,
|
|
||||||
}) => {
|
|
||||||
const departments: { id: Department; label: string; minRequired: number }[] =
|
|
||||||
[
|
|
||||||
{ id: 'Engineering', label: 'Engineering', minRequired: 5 },
|
|
||||||
{ id: 'Sales', label: 'Sales', minRequired: 5 },
|
|
||||||
{ id: 'Operations', label: 'Operations', minRequired: 5 },
|
|
||||||
{ id: 'Executive Board', label: 'Exec Board', minRequired: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ margin: '12px 0 20px 0' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--ion-color-dark)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select Workforce Cohort
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
color: 'var(--ion-color-success)',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={shieldCheckmarkOutline} />
|
|
||||||
<span>k-Anonymity Protected</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
overflowX: 'auto',
|
|
||||||
paddingBottom: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{departments.map((dept) => {
|
|
||||||
const count = cohortSizes[dept.id] || 0;
|
|
||||||
const isSelected = currentDepartment === dept.id;
|
|
||||||
const isViolating = count > 0 && count < dept.minRequired;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={dept.id}
|
|
||||||
onClick={() => onDepartmentChange(dept.id)}
|
|
||||||
style={{
|
|
||||||
flex: '1 0 auto',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: isSelected
|
|
||||||
? '2px solid var(--ion-color-primary)'
|
|
||||||
: '1px solid var(--ion-color-light-shade)',
|
|
||||||
backgroundColor: isSelected
|
|
||||||
? 'rgba(56, 128, 255, 0.06)'
|
|
||||||
: 'var(--ion-color-light)',
|
|
||||||
color: isSelected
|
|
||||||
? 'var(--ion-color-primary-shade)'
|
|
||||||
: 'var(--ion-color-dark)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '0.9rem', fontWeight: 700 }}>
|
|
||||||
{dept.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={peopleOutline} style={{ opacity: 0.7 }} />
|
|
||||||
<span>N = {count}</span>
|
|
||||||
{isViolating && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
color: 'var(--ion-color-danger)',
|
|
||||||
backgroundColor: 'rgba(235, 68, 90, 0.1)',
|
|
||||||
padding: '1px 5px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Blocked
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
IonCard,
|
|
||||||
IonCardContent,
|
|
||||||
IonText,
|
|
||||||
IonIcon,
|
|
||||||
IonRow,
|
|
||||||
IonCol,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import {
|
|
||||||
moonOutline,
|
|
||||||
flashOutline,
|
|
||||||
fitnessOutline,
|
|
||||||
alertCircleOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
subtext: string;
|
|
||||||
type: 'sleep' | 'stress' | 'activity';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MetricSummaryCard: React.FC<MetricCardProps> = ({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
subtext,
|
|
||||||
type,
|
|
||||||
}) => {
|
|
||||||
const getCardTheme = () => {
|
|
||||||
switch (type) {
|
|
||||||
case 'sleep':
|
|
||||||
return {
|
|
||||||
icon: moonOutline,
|
|
||||||
color: 'var(--ion-color-tertiary)',
|
|
||||||
bg: 'rgba(82, 96, 255, 0.08)',
|
|
||||||
label: 'Sleep Quality',
|
|
||||||
};
|
|
||||||
case 'stress':
|
|
||||||
return {
|
|
||||||
icon: flashOutline,
|
|
||||||
color: 'var(--ion-color-danger)',
|
|
||||||
bg: 'rgba(235, 68, 90, 0.08)',
|
|
||||||
label: 'Stress Index',
|
|
||||||
};
|
|
||||||
case 'activity':
|
|
||||||
return {
|
|
||||||
icon: fitnessOutline,
|
|
||||||
color: 'var(--ion-color-success)',
|
|
||||||
bg: 'rgba(45, 211, 111, 0.08)',
|
|
||||||
label: 'Active Minutes',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const theme = getCardTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonCard
|
|
||||||
className="ion-no-margin"
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.04)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid var(--ion-color-light-shade)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonCardContent style={{ padding: '16px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: theme.bg,
|
|
||||||
color: theme.color,
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={theme.icon} style={{ fontSize: '20px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '6px' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '2rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--ion-color-dark)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{subtext}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</IonCardContent>
|
|
||||||
</IonCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonCard,
|
|
||||||
IonCardHeader,
|
|
||||||
IonCardSubtitle,
|
|
||||||
IonCardTitle,
|
|
||||||
IonCardContent,
|
|
||||||
IonText,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { AggregatedTrendPoint } from '../../types/cohortHealth';
|
|
||||||
import { moonOutline, flashOutline, eyeOutline } from 'ionicons/icons';
|
|
||||||
|
|
||||||
interface TrendChartCardProps {
|
|
||||||
trends: AggregatedTrendPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrendChartCard: React.FC<TrendChartCardProps> = ({ trends }) => {
|
|
||||||
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
|
|
||||||
|
|
||||||
if (!trends || trends.length === 0) {
|
|
||||||
return (
|
|
||||||
<IonCard style={{ borderRadius: '12px' }}>
|
|
||||||
<IonCardContent className="ion-text-center ion-padding">
|
|
||||||
No trend data to display.
|
|
||||||
</IonCardContent>
|
|
||||||
</IonCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart configuration
|
|
||||||
const width = 600;
|
|
||||||
const height = 240;
|
|
||||||
const paddingLeft = 40;
|
|
||||||
const paddingRight = 40;
|
|
||||||
const paddingTop = 20;
|
|
||||||
const paddingBottom = 40;
|
|
||||||
|
|
||||||
const chartWidth = width - paddingLeft - paddingRight;
|
|
||||||
const chartHeight = height - paddingTop - paddingBottom;
|
|
||||||
|
|
||||||
const maxPoints = trends.length;
|
|
||||||
|
|
||||||
// X coordinates helper
|
|
||||||
const getX = (index: number) => {
|
|
||||||
if (maxPoints <= 1) return paddingLeft + chartWidth / 2;
|
|
||||||
return paddingLeft + (index / (maxPoints - 1)) * chartWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Y coordinates helper for Sleep Score (0 - 100)
|
|
||||||
const getYSleep = (score: number) => {
|
|
||||||
// Normalizing between 40 and 100 to make the graph visually richer
|
|
||||||
const minScore = 40;
|
|
||||||
const maxScore = 100;
|
|
||||||
const percentage = (score - minScore) / (maxScore - minScore);
|
|
||||||
const clampedPct = Math.max(0, Math.min(1, percentage));
|
|
||||||
return height - paddingBottom - clampedPct * chartHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Y coordinates helper for Stress Index (1 - 10)
|
|
||||||
const getYStress = (stress: number) => {
|
|
||||||
// Normalizing between 1 and 10
|
|
||||||
const minStress = 1;
|
|
||||||
const maxStress = 10;
|
|
||||||
const percentage = (stress - minStress) / (maxStress - minStress);
|
|
||||||
const clampedPct = Math.max(0, Math.min(1, percentage));
|
|
||||||
return height - paddingBottom - clampedPct * chartHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build SVG Paths
|
|
||||||
let sleepPointsPath = '';
|
|
||||||
let stressPointsPath = '';
|
|
||||||
|
|
||||||
trends.forEach((t, i) => {
|
|
||||||
const x = getX(i);
|
|
||||||
const ySleep = getYSleep(t.avgSleepScore);
|
|
||||||
const yStress = getYStress(t.avgStressIndex);
|
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
sleepPointsPath = `M ${x} ${ySleep}`;
|
|
||||||
stressPointsPath = `M ${x} ${yStress}`;
|
|
||||||
} else {
|
|
||||||
sleepPointsPath += ` L ${x} ${ySleep}`;
|
|
||||||
stressPointsPath += ` L ${x} ${yStress}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonCard
|
|
||||||
style={{
|
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.04)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
border: '1px solid var(--ion-color-light-shade)',
|
|
||||||
overflow: 'visible',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonCardHeader style={{ padding: '16px 16px 4px 16px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<IonCardTitle
|
|
||||||
style={{
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--ion-color-dark)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
14-Day Cohort Biomarker Trends
|
|
||||||
</IonCardTitle>
|
|
||||||
<IonCardSubtitle
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tracking stress levels vs sleep restoration patterns
|
|
||||||
</IonCardSubtitle>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div style={{ display: 'flex', gap: '16px', fontSize: '0.8rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '12px',
|
|
||||||
height: '3px',
|
|
||||||
backgroundColor: 'var(--ion-color-tertiary)',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ color: 'var(--ion-color-dark)', fontWeight: 600 }}>
|
|
||||||
Sleep Quality
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '12px',
|
|
||||||
height: '3px',
|
|
||||||
backgroundColor: 'var(--ion-color-danger)',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ color: 'var(--ion-color-dark)', fontWeight: 600 }}>
|
|
||||||
Stress Index
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</IonCardHeader>
|
|
||||||
|
|
||||||
<IonCardContent
|
|
||||||
style={{ padding: '0px 12px 16px 12px', position: 'relative' }}
|
|
||||||
>
|
|
||||||
{/* SVG Drawing Canvas */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
overflowX: 'auto',
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
style={{ width: '100%', minWidth: '500px', display: 'block' }}
|
|
||||||
>
|
|
||||||
{/* Gridlines (Horizontal) */}
|
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((val, idx) => {
|
|
||||||
const y = paddingTop + val * chartHeight;
|
|
||||||
return (
|
|
||||||
<g key={idx}>
|
|
||||||
<line
|
|
||||||
x1={paddingLeft}
|
|
||||||
y1={y}
|
|
||||||
x2={width - paddingRight}
|
|
||||||
y2={y}
|
|
||||||
stroke="var(--ion-color-light-shade)"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
/>
|
|
||||||
{/* Left Label: Sleep Score Scales */}
|
|
||||||
<text
|
|
||||||
x={paddingLeft - 8}
|
|
||||||
y={y + 4}
|
|
||||||
textAnchor="end"
|
|
||||||
fill="var(--ion-color-medium)"
|
|
||||||
fontSize="9px"
|
|
||||||
fontWeight="500"
|
|
||||||
>
|
|
||||||
{Math.round(100 - val * 60)}
|
|
||||||
</text>
|
|
||||||
{/* Right Label: Stress Scales */}
|
|
||||||
<text
|
|
||||||
x={width - paddingRight + 8}
|
|
||||||
y={y + 4}
|
|
||||||
textAnchor="start"
|
|
||||||
fill="var(--ion-color-medium)"
|
|
||||||
fontSize="9px"
|
|
||||||
fontWeight="500"
|
|
||||||
>
|
|
||||||
{(10 - val * 9).toFixed(1)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Main Trend Lines */}
|
|
||||||
<path
|
|
||||||
d={sleepPointsPath}
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--ion-color-tertiary)"
|
|
||||||
strokeWidth="3.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d={stressPointsPath}
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--ion-color-danger)"
|
|
||||||
strokeWidth="3.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* X Axis Labels (Filtered to show 4 ticks to prevent clutter) */}
|
|
||||||
{trends.map((t, i) => {
|
|
||||||
const x = getX(i);
|
|
||||||
const isTick =
|
|
||||||
i === 0 ||
|
|
||||||
i === Math.floor(maxPoints / 3) ||
|
|
||||||
i === Math.floor((maxPoints * 2) / 3) ||
|
|
||||||
i === maxPoints - 1;
|
|
||||||
if (!isTick) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={x}
|
|
||||||
y={height - paddingBottom + 18}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="var(--ion-color-medium)"
|
|
||||||
fontSize="10px"
|
|
||||||
fontWeight="600"
|
|
||||||
>
|
|
||||||
{formatDate(t.date)}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Hover Guides & Interactive Circles */}
|
|
||||||
{trends.map((t, i) => {
|
|
||||||
const x = getX(i);
|
|
||||||
const ySleep = getYSleep(t.avgSleepScore);
|
|
||||||
const yStress = getYStress(t.avgStressIndex);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
{/* Invisible broad hover bar */}
|
|
||||||
<rect
|
|
||||||
x={x - chartWidth / maxPoints / 2}
|
|
||||||
y={paddingTop}
|
|
||||||
width={chartWidth / maxPoints}
|
|
||||||
height={chartHeight}
|
|
||||||
fill="transparent"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onMouseEnter={() => setHoveredPoint(i)}
|
|
||||||
onMouseLeave={() => setHoveredPoint(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Active Point Hover Guide Line */}
|
|
||||||
{hoveredPoint === i && (
|
|
||||||
<>
|
|
||||||
<line
|
|
||||||
x1={x}
|
|
||||||
y1={paddingTop}
|
|
||||||
x2={x}
|
|
||||||
y2={height - paddingBottom}
|
|
||||||
stroke="var(--ion-color-medium)"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeDasharray="2 2"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
{/* Sleep Dot */}
|
|
||||||
<circle
|
|
||||||
cx={x}
|
|
||||||
cy={ySleep}
|
|
||||||
r="6"
|
|
||||||
fill="var(--ion-color-tertiary)"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
{/* Stress Dot */}
|
|
||||||
<circle
|
|
||||||
cx={x}
|
|
||||||
cy={yStress}
|
|
||||||
r="6"
|
|
||||||
fill="var(--ion-color-danger)"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic Hover Tooltip Banner */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '8px',
|
|
||||||
backgroundColor: 'var(--ion-color-light)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '10px 14px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: hoveredPoint !== null ? 'space-between' : 'center',
|
|
||||||
border: '1px solid var(--ion-color-light-shade)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hoveredPoint !== null ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--ion-color-dark)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDate(trends[hoveredPoint].date)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
N = {trends[hoveredPoint].cohortSize} users
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', gap: '16px', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
color: 'var(--ion-color-tertiary)',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={moonOutline} /> Sleep:{' '}
|
|
||||||
{trends[hoveredPoint].avgSleepScore}/100
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 400,
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
({trends[hoveredPoint].poorSleepPercentage}% poor)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
color: 'var(--ion-color-danger)',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={flashOutline} /> Stress:{' '}
|
|
||||||
{trends[hoveredPoint].avgStressIndex}/10
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 400,
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
({trends[hoveredPoint].highStressPercentage}% peak)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--ion-color-medium)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={eyeOutline} style={{ fontSize: '14px' }} /> Hover
|
|
||||||
or tap along the grid timeline to inspect specific historical
|
|
||||||
dates
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</IonCardContent>
|
|
||||||
</IonCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon, IonButton } from '@ionic/react';
|
|
||||||
import {
|
|
||||||
trashOutline,
|
|
||||||
medkitOutline,
|
|
||||||
restaurantOutline,
|
|
||||||
moonOutline,
|
|
||||||
clipboardOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
interface HealthEntry {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
type: 'checkup' | 'diet' | 'sleep';
|
|
||||||
entries: HealthEntry[];
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
emptyTitle: string;
|
|
||||||
emptyActionLabel: string;
|
|
||||||
onEmptyAction: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HealthLogList: React.FC<Props> = ({
|
|
||||||
type,
|
|
||||||
entries,
|
|
||||||
onDelete,
|
|
||||||
emptyTitle,
|
|
||||||
emptyActionLabel,
|
|
||||||
onEmptyAction,
|
|
||||||
}) => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '32px 24px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={
|
|
||||||
type === 'checkup'
|
|
||||||
? medkitOutline
|
|
||||||
: type === 'diet'
|
|
||||||
? restaurantOutline
|
|
||||||
: moonOutline
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
fontSize: '42px',
|
|
||||||
color: 'rgba(3,105,161,0.36)',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emptyTitle}
|
|
||||||
</div>
|
|
||||||
<IonButton
|
|
||||||
onClick={onEmptyAction}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '44px',
|
|
||||||
marginTop: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emptyActionLabel}
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (type) {
|
|
||||||
case 'checkup':
|
|
||||||
return medkitOutline;
|
|
||||||
case 'diet':
|
|
||||||
return restaurantOutline;
|
|
||||||
case 'sleep':
|
|
||||||
return moonOutline;
|
|
||||||
default:
|
|
||||||
return clipboardOutline;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitle = (entry: any) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'checkup':
|
|
||||||
return `Mood: ${entry.mood} (${entry.energy}%)`;
|
|
||||||
case 'diet':
|
|
||||||
return `${entry.mealType} log`;
|
|
||||||
case 'sleep':
|
|
||||||
return `Sleep: ${entry.hours}h (${entry.quality})`;
|
|
||||||
default:
|
|
||||||
return 'Log entry';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitle = (entry: any) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'checkup':
|
|
||||||
return entry.symptoms || 'No symptoms noted';
|
|
||||||
case 'diet':
|
|
||||||
return `${entry.waterCups} cups water, ${entry.produceServings} servings produce`;
|
|
||||||
case 'sleep':
|
|
||||||
return `${entry.interruptions} interruptions`;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '12px',
|
|
||||||
borderBottom:
|
|
||||||
index === entries.length - 1
|
|
||||||
? 'none'
|
|
||||||
: '1px solid rgba(30,58,95,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'rgba(56,189,248,0.14)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={getIcon()}
|
|
||||||
style={{ fontSize: '20px', color: '#0369a1' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getTitle(entry)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'rgba(30,58,95,0.58)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{format(new Date(entry.createdAt), 'MMM d, h:mm a')} •{' '}
|
|
||||||
{getSubtitle(entry)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<IonButton
|
|
||||||
fill="clear"
|
|
||||||
onClick={() => onDelete(entry.id)}
|
|
||||||
style={{
|
|
||||||
'--color': 'rgba(30,58,95,0.38)',
|
|
||||||
'--padding-start': '8px',
|
|
||||||
'--padding-end': '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={trashOutline}
|
|
||||||
slot="icon-only"
|
|
||||||
style={{ fontSize: '18px' }}
|
|
||||||
/>
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HealthLogList;
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon } from '@ionic/react';
|
|
||||||
import {
|
|
||||||
checkmarkCircleOutline,
|
|
||||||
warningOutline,
|
|
||||||
informationCircleOutline,
|
|
||||||
waterOutline,
|
|
||||||
moonOutline,
|
|
||||||
heartOutline,
|
|
||||||
analyticsOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { WellbeingSummary, LocalInsight } from '../../types/health';
|
|
||||||
|
|
||||||
// Use this for mapping string names to IonIcons
|
|
||||||
const ICON_MAP: Record<string, string> = {
|
|
||||||
checkmarkCircleOutline,
|
|
||||||
warningOutline,
|
|
||||||
informationCircleOutline,
|
|
||||||
waterOutline,
|
|
||||||
moonOutline,
|
|
||||||
heartOutline,
|
|
||||||
analyticsOutline,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SummaryProps {
|
|
||||||
summary: WellbeingSummary;
|
|
||||||
primaryInsight: LocalInsight | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InsightSummaryCard: React.FC<SummaryProps> = ({
|
|
||||||
summary,
|
|
||||||
primaryInsight,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#1e3a5f',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
color: '#ffffff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(255,255,255,0.72)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your Pattern
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '20px',
|
|
||||||
fontWeight: '700',
|
|
||||||
lineHeight: '26px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{primaryInsight
|
|
||||||
? primaryInsight.title
|
|
||||||
: 'Keep tracking to reveal insights'}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
color: 'rgba(255,255,255,0.76)',
|
|
||||||
lineHeight: '20px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{primaryInsight
|
|
||||||
? primaryInsight.body
|
|
||||||
: 'Log your checkups, meals, and sleep regularly for personalized health guidance.'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'rgba(56,189,248,0.18)',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Score: {summary.score}/100
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'rgba(56,189,248,0.18)',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{summary.streakDays} day streak
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ListProps {
|
|
||||||
insights: LocalInsight[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RecommendationList: React.FC<ListProps> = ({ insights }) => {
|
|
||||||
if (insights.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insights.map((insight, index) => (
|
|
||||||
<div
|
|
||||||
key={insight.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
borderBottom:
|
|
||||||
index === insights.length - 1
|
|
||||||
? 'none'
|
|
||||||
: '1px solid rgba(30,58,95,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background:
|
|
||||||
insight.type === 'positive'
|
|
||||||
? 'rgba(52,168,83,0.10)'
|
|
||||||
: insight.type === 'warning'
|
|
||||||
? 'rgba(234,67,53,0.10)'
|
|
||||||
: 'rgba(3,105,161,0.10)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={ICON_MAP[insight.icon] || informationCircleOutline}
|
|
||||||
style={{
|
|
||||||
fontSize: '20px',
|
|
||||||
color:
|
|
||||||
insight.type === 'positive'
|
|
||||||
? '#34a853'
|
|
||||||
: insight.type === 'warning'
|
|
||||||
? '#ea4335'
|
|
||||||
: '#0369a1',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
marginBottom: '2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insight.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
lineHeight: '18px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insight.body}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon } from '@ionic/react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
helper: string;
|
|
||||||
progress: number;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MetricMiniCard: React.FC<Props> = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
helper,
|
|
||||||
progress,
|
|
||||||
icon,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '16px',
|
|
||||||
minHeight: '112px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'rgba(56,189,248,0.14)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={icon} style={{ fontSize: '18px', color: '#0369a1' }} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.58)',
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '12px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '26px',
|
|
||||||
fontWeight: '400',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
marginLeft: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{helper}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '7px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
background: '#e2f2f8',
|
|
||||||
marginTop: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${Math.min(progress * 100, 100)}%`,
|
|
||||||
background: '#38bdf8',
|
|
||||||
borderRadius: '999px',
|
|
||||||
transition: 'width 0.5s ease-out',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MetricMiniCard;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon, IonButton } from '@ionic/react';
|
|
||||||
import {
|
|
||||||
clipboardOutline,
|
|
||||||
medkitOutline,
|
|
||||||
restaurantOutline,
|
|
||||||
moonOutline,
|
|
||||||
chevronForwardOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import { ActivityEntry } from '../../types/health';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
entries: ActivityEntry[];
|
|
||||||
onCreate: () => void;
|
|
||||||
onSeeInsights: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RecentActivityList: React.FC<Props> = ({
|
|
||||||
entries,
|
|
||||||
onCreate,
|
|
||||||
onSeeInsights,
|
|
||||||
}) => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0 20px 20px',
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '32px 24px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={clipboardOutline}
|
|
||||||
style={{
|
|
||||||
fontSize: '42px',
|
|
||||||
color: 'rgba(3,105,161,0.36)',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No health logs yet
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'rgba(30,58,95,0.58)',
|
|
||||||
marginBottom: '24px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start your first self checkup to track your wellbeing.
|
|
||||||
</div>
|
|
||||||
<IonButton
|
|
||||||
onClick={onCreate}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '44px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start a checkup
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'checkup':
|
|
||||||
return medkitOutline;
|
|
||||||
case 'diet':
|
|
||||||
return restaurantOutline;
|
|
||||||
case 'sleep':
|
|
||||||
return moonOutline;
|
|
||||||
default:
|
|
||||||
return clipboardOutline;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0 20px 20px',
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '8px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '12px',
|
|
||||||
borderBottom:
|
|
||||||
index === entries.length - 1
|
|
||||||
? 'none'
|
|
||||||
: '1px solid rgba(30,58,95,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
background: 'rgba(3,105,161,0.10)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={getIcon(entry.type)}
|
|
||||||
style={{ fontSize: '20px', color: '#0369a1' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '15px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'rgba(30,58,95,0.58)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.subtitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
flexShrink: 0,
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDistanceToNow(new Date(entry.date), { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div
|
|
||||||
onClick={onSeeInsights}
|
|
||||||
style={{
|
|
||||||
padding: '16px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '13px', fontWeight: '700', color: '#0369a1' }}>
|
|
||||||
View all insights
|
|
||||||
</span>
|
|
||||||
<IonIcon
|
|
||||||
icon={chevronForwardOutline}
|
|
||||||
style={{ fontSize: '14px', color: '#0369a1' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RecentActivityList;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon } from '@ionic/react';
|
|
||||||
import { moonOutline } from 'ionicons/icons';
|
|
||||||
import { SleepEntry } from '../../types/health';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
entries: SleepEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SleepTrendCard: React.FC<Props> = ({ entries }) => {
|
|
||||||
const lastSeven = entries.slice(0, 7).reverse();
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '24px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={moonOutline}
|
|
||||||
style={{
|
|
||||||
fontSize: '32px',
|
|
||||||
color: 'rgba(3,105,161,0.20)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'rgba(30,58,95,0.50)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sleep trends appear after your first log
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxHours = Math.max(...lastSeven.map((e) => e.hours), 10);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
marginBottom: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
7-day sleep trend
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
height: '80px',
|
|
||||||
paddingBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lastSeven.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '12px',
|
|
||||||
height: `${(entry.hours / maxHours) * 60}px`,
|
|
||||||
background:
|
|
||||||
entry.quality === 'Restful'
|
|
||||||
? '#38bdf8'
|
|
||||||
: entry.quality === 'Okay'
|
|
||||||
? '#0369a1'
|
|
||||||
: '#1e3a5f',
|
|
||||||
borderRadius: '6px 6px 0 0',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{format(new Date(entry.createdAt), 'E')[0]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SleepTrendCard;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { IonIcon } from '@ionic/react';
|
|
||||||
import { pulseOutline } from 'ionicons/icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
score: number;
|
|
||||||
message: string;
|
|
||||||
progress: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WellbeingScoreCard: React.FC<Props> = ({ score, message, progress }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '0 20px 12px',
|
|
||||||
background: '#1e3a5f',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '20px',
|
|
||||||
minHeight: '164px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(255,255,255,0.72)',
|
|
||||||
marginBottom: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Today’s wellbeing score
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '48px',
|
|
||||||
fontWeight: '400',
|
|
||||||
color: '#ffffff',
|
|
||||||
lineHeight: '56px',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{score}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: '400',
|
|
||||||
color: 'rgba(255,255,255,0.52)',
|
|
||||||
marginLeft: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/100
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
background: 'rgba(56,189,248,0.16)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={pulseOutline}
|
|
||||||
style={{ fontSize: '22px', color: '#38bdf8' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'rgba(255,255,255,0.78)',
|
|
||||||
lineHeight: '20px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '8px',
|
|
||||||
background: 'rgba(255,255,255,0.16)',
|
|
||||||
borderRadius: '999px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${progress * 100}%`,
|
|
||||||
background: '#38bdf8',
|
|
||||||
borderRadius: '999px',
|
|
||||||
transition: 'width 0.5s ease-out',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WellbeingScoreCard;
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.5"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
habit_completions: {
|
||||||
|
Row: {
|
||||||
|
completed_on: string
|
||||||
|
created_at: string
|
||||||
|
habit_id: string
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
completed_on: string
|
||||||
|
created_at?: string
|
||||||
|
habit_id: string
|
||||||
|
id?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
completed_on?: string
|
||||||
|
created_at?: string
|
||||||
|
habit_id?: string
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "habit_completions_habit_id_fkey"
|
||||||
|
columns: ["habit_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "habits"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
habits: {
|
||||||
|
Row: {
|
||||||
|
color: string
|
||||||
|
created_at: string
|
||||||
|
fcm_token: string | null
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
target_description: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
target_description?: string | null
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
color?: string
|
||||||
|
created_at?: string
|
||||||
|
fcm_token?: string | null
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
target_description?: string | null
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
notes: {
|
||||||
|
Row: {
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
is_favorite: boolean
|
||||||
|
title: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
content: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
content?: string
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
is_favorite?: boolean
|
||||||
|
title?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
todos: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
|
id: string
|
||||||
|
is_completed: boolean
|
||||||
|
title: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
title: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
|
id?: string
|
||||||
|
is_completed?: boolean
|
||||||
|
title?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])
|
||||||
|
? (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Enums"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
|
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||||
|
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
IonText,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import {
|
||||||
|
waterOutline,
|
||||||
|
fitnessOutline,
|
||||||
|
shieldCheckmarkOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
|
||||||
|
|
||||||
|
const RedirectAny = RedirectRR as any;
|
||||||
|
|
||||||
|
const Account: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
const [signOutError, setSignOutError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(data.session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEmail(session?.user.email ?? null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
history.replace('/auth');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setSignOutError('');
|
||||||
|
setSigningOut(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSignOutError(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
await FirebaseAuthentication.signOut();
|
||||||
|
} catch {
|
||||||
|
// Ignore if no native Google session exists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmail(null);
|
||||||
|
history.replace('/auth');
|
||||||
|
} catch (error) {
|
||||||
|
setSignOutError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Unable to sign out right now.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSigningOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !email) {
|
||||||
|
return <RedirectAny to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonTitle>Account</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding account-shell">
|
||||||
|
<section className="account-hero-card">
|
||||||
|
<div className="account-hero-image-wrap">
|
||||||
|
<img
|
||||||
|
src={runnerDrinkingWaterImage}
|
||||||
|
alt="Runner drinking water after a run"
|
||||||
|
className="account-hero-image"
|
||||||
|
/>
|
||||||
|
<div className="account-hero-overlay" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="account-hero-content">
|
||||||
|
<span className="eyebrow">Recovery & account</span>
|
||||||
|
<h1>{email ?? 'Loading...'}</h1>
|
||||||
|
<p>
|
||||||
|
Stay hydrated, stay consistent, and keep your habit history
|
||||||
|
safely synced across sessions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="account-highlight-row">
|
||||||
|
<div className="account-highlight-chip">
|
||||||
|
<IonIcon icon={waterOutline} />
|
||||||
|
<span>Recovery mindset</span>
|
||||||
|
</div>
|
||||||
|
<div className="account-highlight-chip subtle">
|
||||||
|
<IonIcon icon={fitnessOutline} />
|
||||||
|
<span>Built for routines</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-card account-details-card">
|
||||||
|
<IonItem lines="none" className="account-info-item">
|
||||||
|
<IonLabel>
|
||||||
|
<h2>Email</h2>
|
||||||
|
<p>{email ?? '—'}</p>
|
||||||
|
</IonLabel>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<div className="account-trust-row">
|
||||||
|
<div className="account-trust-icon">
|
||||||
|
<IonIcon icon={shieldCheckmarkOutline} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Your data is synced</h3>
|
||||||
|
<p>
|
||||||
|
Sign in to keep your streaks and progress available whenever
|
||||||
|
you come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
className="account-primary-button"
|
||||||
|
routerLink="/home"
|
||||||
|
>
|
||||||
|
Back to Habits
|
||||||
|
</IonButton>
|
||||||
|
{signOutError ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{signOutError}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
color="danger"
|
||||||
|
className="account-secondary-button"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
disabled={signingOut}
|
||||||
|
>
|
||||||
|
{signingOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonCard,
|
||||||
|
IonCardContent,
|
||||||
|
IonContent,
|
||||||
|
IonIcon,
|
||||||
|
IonInput,
|
||||||
|
IonItem,
|
||||||
|
IonLabel,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonText,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
|
||||||
|
const Auth: React.FC = () => {
|
||||||
|
const history = useHistory();
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
const [appleLoading, setAppleLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [info, setInfo] = useState('');
|
||||||
|
const [verificationMessage, setVerificationMessage] = useState('');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setError(msg);
|
||||||
|
setTimeout(() => {
|
||||||
|
setError((current) => (current === msg ? '' : current));
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useIonViewWillEnter(() => {
|
||||||
|
setStatusBarStyle(Style.Dark);
|
||||||
|
setStatusBarBackground('#f6f7fb');
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(data.session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setHasSession(Boolean(session));
|
||||||
|
setSessionChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formValid = useMemo(() => {
|
||||||
|
return email.trim().length > 3 && password.length >= 6;
|
||||||
|
}, [email, password]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
showError(signInError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signUpError) {
|
||||||
|
showError(signUpError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
history.replace('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerificationMessage(
|
||||||
|
'Account created! Check your email for a verification link.'
|
||||||
|
);
|
||||||
|
setInfo('Once verified, come back and sign in.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setGoogleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setGoogleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithGoogle();
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Google sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'google',
|
||||||
|
token: idToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleAuthError) {
|
||||||
|
showError(googleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Google sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppleLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setInfo('');
|
||||||
|
setVerificationMessage('');
|
||||||
|
setAppleLoading(true);
|
||||||
|
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
showError(
|
||||||
|
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
||||||
|
);
|
||||||
|
setAppleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await FirebaseAuthentication.signInWithApple({
|
||||||
|
skipNativeAuth: true,
|
||||||
|
});
|
||||||
|
const idToken = result.credential?.idToken;
|
||||||
|
const rawNonce = result.credential?.nonce;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
showError('Apple sign-in did not return an ID token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: 'apple',
|
||||||
|
token: idToken,
|
||||||
|
nonce: rawNonce || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appleAuthError) {
|
||||||
|
showError(appleAuthError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replace('/home');
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
||||||
|
showError(message);
|
||||||
|
} finally {
|
||||||
|
setAppleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && hasSession) {
|
||||||
|
return <Redirect to="/home" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
||||||
|
<div className="auth-shell">
|
||||||
|
<div className="auth-hero">
|
||||||
|
<span className="eyebrow">Protect your streaks</span>
|
||||||
|
<h1>Build habits that actually stick.</h1>
|
||||||
|
<p>
|
||||||
|
Track daily wins, recover faster from misses, and see which habits
|
||||||
|
are getting real momentum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonCard className="auth-card">
|
||||||
|
<IonCardContent>
|
||||||
|
<IonSegment
|
||||||
|
value={mode}
|
||||||
|
onIonChange={(e) =>
|
||||||
|
setMode(e.detail.value as 'login' | 'signup')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="login">
|
||||||
|
<IonLabel>Login</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="signup">
|
||||||
|
<IonLabel>Sign Up</IonLabel>
|
||||||
|
</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
|
||||||
|
{verificationMessage ? (
|
||||||
|
<div className="auth-message-block">
|
||||||
|
<h2>Check your inbox</h2>
|
||||||
|
<p>{verificationMessage}</p>
|
||||||
|
<IonText color="medium">
|
||||||
|
<p>{info}</p>
|
||||||
|
</IonText>
|
||||||
|
<IonButton fill="clear" onClick={() => setMode('login')}>
|
||||||
|
Back to Login
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="auth-form">
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={email}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
<IonItem lines="none" className="auth-input-item">
|
||||||
|
<IonInput
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
labelPlacement="stacked"
|
||||||
|
value={password}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
||||||
|
/>
|
||||||
|
</IonItem>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<IonText color="danger">
|
||||||
|
<p className="auth-feedback">{error}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{info ? (
|
||||||
|
<IonText color="medium">
|
||||||
|
<p className="auth-feedback">{info}</p>
|
||||||
|
</IonText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
disabled={
|
||||||
|
!formValid || loading || googleLoading || appleLoading
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Please wait…'
|
||||||
|
: mode === 'login'
|
||||||
|
? 'Log In'
|
||||||
|
: 'Create Account'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<>
|
||||||
|
<div className="auth-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="google-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
className="google-svg-wrap"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
||||||
|
</IonButton>
|
||||||
|
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill="outline"
|
||||||
|
className="apple-auth-button"
|
||||||
|
disabled={loading || googleLoading || appleLoading}
|
||||||
|
onClick={handleAppleLogin}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
slot="start"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</IonCardContent>
|
||||||
|
</IonCard>
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Auth;
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonHeader,
|
|
||||||
IonPage,
|
|
||||||
IonTitle,
|
|
||||||
IonToolbar,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonRange,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonAlert,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
chevronBackOutline,
|
|
||||||
trashOutline,
|
|
||||||
medkitOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import {
|
|
||||||
getCheckups,
|
|
||||||
addCheckup,
|
|
||||||
deleteCheckup,
|
|
||||||
clearCheckups,
|
|
||||||
} from '../services/localHealthStore';
|
|
||||||
import { CheckupEntry, CheckupMood } from '../types/health';
|
|
||||||
import HealthLogList from '../components/health/HealthLogList';
|
|
||||||
|
|
||||||
const Checkups: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const [entries, setEntries] = useState<CheckupEntry[]>([]);
|
|
||||||
const [mood, setMood] = useState<CheckupMood | ''>('');
|
|
||||||
const [energy, setEnergy] = useState<number>(70);
|
|
||||||
const [symptoms, setSymptoms] = useState<string>('');
|
|
||||||
const [note, setNote] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showClearAlert, setShowClearAlert] = useState(false);
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
setEntries(getCheckups());
|
|
||||||
};
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
refreshData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!mood) {
|
|
||||||
setError('Please select a mood');
|
|
||||||
setTimeout(() => setError(null), 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addCheckup({
|
|
||||||
mood: mood as CheckupMood,
|
|
||||||
energy,
|
|
||||||
symptoms,
|
|
||||||
note,
|
|
||||||
});
|
|
||||||
|
|
||||||
setMood('');
|
|
||||||
setEnergy(70);
|
|
||||||
setSymptoms('');
|
|
||||||
setNote('');
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
deleteCheckup(id);
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
clearCheckups();
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const moods: CheckupMood[] = ['Great', 'Okay', 'Low', 'Unwell'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage style={{ background: '#eef7fb' }}>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#eef7fb', '--color': '#1e3a5f' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle style={{ fontWeight: '700' }}>Self Checkup</IonTitle>
|
|
||||||
<IonButtons slot="end">
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<IonButton onClick={() => setShowClearAlert(true)}>
|
|
||||||
<IonIcon
|
|
||||||
icon={trashOutline}
|
|
||||||
slot="icon-only"
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
</IonButton>
|
|
||||||
)}
|
|
||||||
</IonButtons>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent
|
|
||||||
style={{
|
|
||||||
'--background': '#eef7fb',
|
|
||||||
'--padding-start': '20px',
|
|
||||||
'--padding-end': '20px',
|
|
||||||
'--padding-top': '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Form Card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
How do you feel right now?
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Mood
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
{moods.map((m) => (
|
|
||||||
<div
|
|
||||||
key={m}
|
|
||||||
onClick={() => setMood(m)}
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: mood === m ? '#0369a1' : '#e2f2f8',
|
|
||||||
color: mood === m ? '#ffffff' : '#1e3a5f',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Energy
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#0369a1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{energy}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<IonRange
|
|
||||||
value={energy}
|
|
||||||
onIonChange={(e) => setEnergy(e.detail.value as number)}
|
|
||||||
style={{
|
|
||||||
'--bar-background': '#e2f2f8',
|
|
||||||
'--bar-background-active': '#38bdf8',
|
|
||||||
'--knob-background': '#0369a1',
|
|
||||||
padding: '0',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Symptoms
|
|
||||||
</div>
|
|
||||||
<IonInput
|
|
||||||
value={symptoms}
|
|
||||||
onIonInput={(e) => setSymptoms(e.detail.value!)}
|
|
||||||
placeholder="e.g. Headache, fatigue..."
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--padding-end': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
<IonTextarea
|
|
||||||
value={note}
|
|
||||||
onIonInput={(e) => setNote(e.detail.value!)}
|
|
||||||
placeholder="Add more details..."
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--padding-end': '16px',
|
|
||||||
'--padding-top': '12px',
|
|
||||||
'--padding-bottom': '12px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#dc2626',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
onClick={handleSave}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save checkup
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History Section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
margin: '0 0 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '17px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length} logs
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HealthLogList
|
|
||||||
type="checkup"
|
|
||||||
entries={entries}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
emptyTitle="No checkups yet"
|
|
||||||
emptyActionLabel="Save your first checkup"
|
|
||||||
onEmptyAction={() => {}} // Form is already on page
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ height: '40px' }} />
|
|
||||||
|
|
||||||
<IonAlert
|
|
||||||
isOpen={showClearAlert}
|
|
||||||
onDidDismiss={() => setShowClearAlert(false)}
|
|
||||||
header="Clear history?"
|
|
||||||
message="This will delete all checkup logs permanently."
|
|
||||||
buttons={[
|
|
||||||
{ text: 'Cancel', role: 'cancel' },
|
|
||||||
{ text: 'Clear All', role: 'destructive', handler: handleClear },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Checkups;
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonHeader,
|
|
||||||
IonPage,
|
|
||||||
IonTitle,
|
|
||||||
IonToolbar,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonSelect,
|
|
||||||
IonSelectOption,
|
|
||||||
IonAlert,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
chevronBackOutline,
|
|
||||||
trashOutline,
|
|
||||||
waterOutline,
|
|
||||||
leafOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import {
|
|
||||||
getDietEntries,
|
|
||||||
addDietEntry,
|
|
||||||
deleteDietEntry,
|
|
||||||
clearDietEntries,
|
|
||||||
getWellbeingSummary,
|
|
||||||
} from '../services/localHealthStore';
|
|
||||||
import { DietEntry, MealType, WellbeingSummary } from '../types/health';
|
|
||||||
import HealthLogList from '../components/health/HealthLogList';
|
|
||||||
import MetricMiniCard from '../components/health/MetricMiniCard';
|
|
||||||
|
|
||||||
const Diet: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const [entries, setEntries] = useState<DietEntry[]>([]);
|
|
||||||
const [summary, setSummary] = useState<WellbeingSummary | null>(null);
|
|
||||||
|
|
||||||
const [mealType, setMealType] = useState<MealType | ''>('');
|
|
||||||
const [waterCups, setWaterCups] = useState<string>('');
|
|
||||||
const [produceServings, setProduceServings] = useState<string>('');
|
|
||||||
const [notes, setNotes] = useState<string>('');
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showClearAlert, setShowClearAlert] = useState(false);
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
setEntries(getDietEntries());
|
|
||||||
setSummary(getWellbeingSummary());
|
|
||||||
};
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
refreshData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!mealType) {
|
|
||||||
setError('Please select a meal type');
|
|
||||||
setTimeout(() => setError(null), 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addDietEntry({
|
|
||||||
mealType: mealType as MealType,
|
|
||||||
waterCups: parseInt(waterCups) || 0,
|
|
||||||
produceServings: parseInt(produceServings) || 0,
|
|
||||||
notes,
|
|
||||||
});
|
|
||||||
|
|
||||||
setMealType('');
|
|
||||||
setWaterCups('');
|
|
||||||
setProduceServings('');
|
|
||||||
setNotes('');
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
deleteDietEntry(id);
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
clearDietEntries();
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const mealTypes: MealType[] = ['Breakfast', 'Lunch', 'Dinner', 'Snack'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage style={{ background: '#eef7fb' }}>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#eef7fb', '--color': '#1e3a5f' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle style={{ fontWeight: '700' }}>Diet</IonTitle>
|
|
||||||
<IonButtons slot="end">
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<IonButton onClick={() => setShowClearAlert(true)}>
|
|
||||||
<IonIcon
|
|
||||||
icon={trashOutline}
|
|
||||||
slot="icon-only"
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
</IonButton>
|
|
||||||
)}
|
|
||||||
</IonButtons>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent
|
|
||||||
style={{
|
|
||||||
'--background': '#eef7fb',
|
|
||||||
'--padding-start': '20px',
|
|
||||||
'--padding-end': '20px',
|
|
||||||
'--padding-top': '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Form Card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
Log today’s nutrition
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Meal Type
|
|
||||||
</div>
|
|
||||||
<IonSelect
|
|
||||||
value={mealType}
|
|
||||||
onIonChange={(e) => setMealType(e.detail.value)}
|
|
||||||
interface="action-sheet"
|
|
||||||
placeholder="Select a meal"
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--padding-end': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
width: '100%',
|
|
||||||
'--placeholder-color': 'rgba(30,58,95,0.38)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mealTypes.map((m) => (
|
|
||||||
<IonSelectOption key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</IonSelectOption>
|
|
||||||
))}
|
|
||||||
</IonSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Water (cups)
|
|
||||||
</div>
|
|
||||||
<IonInput
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={waterCups}
|
|
||||||
onIonInput={(e) =>
|
|
||||||
setWaterCups(e.detail.value!.replace(/[^0-9]/g, ''))
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Produce (servings)
|
|
||||||
</div>
|
|
||||||
<IonInput
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={produceServings}
|
|
||||||
onIonInput={(e) =>
|
|
||||||
setProduceServings(e.detail.value!.replace(/[^0-9]/g, ''))
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
<IonTextarea
|
|
||||||
value={notes}
|
|
||||||
onIonInput={(e) => setNotes(e.detail.value!)}
|
|
||||||
placeholder="Add more details..."
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--padding-end': '16px',
|
|
||||||
'--padding-top': '12px',
|
|
||||||
'--padding-bottom': '12px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#dc2626',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
onClick={handleSave}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save diet log
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Today Summary */}
|
|
||||||
{summary && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '12px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MetricMiniCard
|
|
||||||
label="Today's Water"
|
|
||||||
value={summary.hydrationCups.toString()}
|
|
||||||
helper="cups"
|
|
||||||
progress={summary.hydrationCups / 8}
|
|
||||||
icon={waterOutline}
|
|
||||||
/>
|
|
||||||
<MetricMiniCard
|
|
||||||
label="Today's Produce"
|
|
||||||
value={summary.produceServings.toString()}
|
|
||||||
helper="servings"
|
|
||||||
progress={summary.produceServings / 5}
|
|
||||||
icon={leafOutline}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* History Section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
margin: '0 0 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '17px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length} logs
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HealthLogList
|
|
||||||
type="diet"
|
|
||||||
entries={entries}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
emptyTitle="No diet logs yet"
|
|
||||||
emptyActionLabel="Log a meal"
|
|
||||||
onEmptyAction={() => {}} // Form is already on page
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ height: '40px' }} />
|
|
||||||
|
|
||||||
<IonAlert
|
|
||||||
isOpen={showClearAlert}
|
|
||||||
onDidDismiss={() => setShowClearAlert(false)}
|
|
||||||
header="Clear history?"
|
|
||||||
message="This will delete all diet logs permanently."
|
|
||||||
buttons={[
|
|
||||||
{ text: 'Cancel', role: 'cancel' },
|
|
||||||
{ text: 'Clear All', role: 'destructive', handler: handleClear },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Diet;
|
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonAlert,
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSkeletonText,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
useIonViewWillEnter,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import {
|
||||||
|
checkmarkOutline,
|
||||||
|
createOutline,
|
||||||
|
ellipseOutline,
|
||||||
|
flameOutline,
|
||||||
|
sparklesOutline,
|
||||||
|
trashOutline,
|
||||||
|
trendingUpOutline,
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
import { Redirect, useHistory, useParams } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
|
||||||
|
interface HabitWithCompletions extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HabitDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habit, setHabit] = useState<HabitWithCompletions | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||||
|
|
||||||
|
const loadHabit = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data, error: habitError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (habitError) {
|
||||||
|
if (habitError.code === 'PGRST116') {
|
||||||
|
setHabit(null);
|
||||||
|
} else {
|
||||||
|
setError(habitError.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabit(data as HabitWithCompletions);
|
||||||
|
setLoading(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHabit();
|
||||||
|
}, [loadHabit]);
|
||||||
|
|
||||||
|
const completionDates = useMemo(
|
||||||
|
() => habit?.completions.map((c) => c.completed_on) ?? [],
|
||||||
|
[habit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
const doneToday = completionDates.includes(today);
|
||||||
|
|
||||||
|
const currentStreak = useMemo(
|
||||||
|
() => calculateStreak(completionDates),
|
||||||
|
[completionDates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCompletions = completionDates.length;
|
||||||
|
|
||||||
|
// Calculate longest streak
|
||||||
|
const longestStreak = useMemo(() => {
|
||||||
|
const unique = Array.from(new Set(completionDates)).sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
if (unique.length === 0) return 0;
|
||||||
|
let maxStreak = 1;
|
||||||
|
let current = 1;
|
||||||
|
for (let i = 1; i < unique.length; i++) {
|
||||||
|
const prev = new Date(unique[i - 1]);
|
||||||
|
const curr = new Date(unique[i]);
|
||||||
|
const diffDays =
|
||||||
|
(curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
if (diffDays === 1) {
|
||||||
|
current += 1;
|
||||||
|
maxStreak = Math.max(maxStreak, current);
|
||||||
|
} else {
|
||||||
|
current = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxStreak;
|
||||||
|
}, [completionDates]);
|
||||||
|
|
||||||
|
// Days since habit creation (or first completion, whichever is earlier)
|
||||||
|
const daysSinceStart = useMemo(() => {
|
||||||
|
const startDate = habit
|
||||||
|
? new Date(
|
||||||
|
Math.min(
|
||||||
|
new Date(habit.created_at).getTime(),
|
||||||
|
...completionDates.map((d) => new Date(d).getTime()),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: new Date();
|
||||||
|
const diff = Math.ceil(
|
||||||
|
(Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
return Math.max(diff, 1);
|
||||||
|
}, [habit, completionDates]);
|
||||||
|
|
||||||
|
const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100);
|
||||||
|
|
||||||
|
// 30-day heatmap data
|
||||||
|
const heatmapDays = useMemo(() => {
|
||||||
|
return Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - (29 - i));
|
||||||
|
const key = formatDate(date);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
completed: completionDates.includes(key),
|
||||||
|
isToday: key === today,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [completionDates, today]);
|
||||||
|
|
||||||
|
const toggleToday = async () => {
|
||||||
|
if (!userId || !habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
if (doneToday) {
|
||||||
|
const existing = habit.completions.find((c) => c.completed_on === today);
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) setError(deleteError.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) setError(insertError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHabit();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId || !habit) throw new Error('Habit not found.');
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
})
|
||||||
|
.eq('id', habit.id);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
await loadHabit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async () => {
|
||||||
|
if (!habit) return;
|
||||||
|
setBusy(true);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habit.id);
|
||||||
|
if (deleteError) {
|
||||||
|
setError(deleteError.message);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
history.push('/home');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
||||||
|
<IonButtons slot="end">
|
||||||
|
{habit && (
|
||||||
|
<>
|
||||||
|
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
||||||
|
<IonIcon icon={createOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
fill="clear"
|
||||||
|
color="danger"
|
||||||
|
onClick={() => setShowDeleteAlert(true)}
|
||||||
|
>
|
||||||
|
<IonIcon icon={trashOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '160px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
/* Error state */
|
||||||
|
<div className="inline-message error-card">
|
||||||
|
<h3>Something went wrong</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
||||||
|
</div>
|
||||||
|
) : !habit ? (
|
||||||
|
/* Not found state */
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>Habit not found</h3>
|
||||||
|
<p>
|
||||||
|
This habit may have been deleted or you don't have access
|
||||||
|
to it.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Back to habits</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Habit info card */}
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<div className="habit-copy-topline">
|
||||||
|
<span
|
||||||
|
className="habit-color-pill"
|
||||||
|
style={{ background: habit.color }}
|
||||||
|
/>
|
||||||
|
<span className="eyebrow">Your routine</span>
|
||||||
|
</div>
|
||||||
|
<h1>{habit.name}</h1>
|
||||||
|
<p>
|
||||||
|
{habit.target_description ||
|
||||||
|
'Show up today and keep the chain alive.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Today toggle */}
|
||||||
|
<div style={{ marginTop: '18px' }}>
|
||||||
|
<IonButton
|
||||||
|
expand="block"
|
||||||
|
fill={doneToday ? 'solid' : 'outline'}
|
||||||
|
onClick={toggleToday}
|
||||||
|
disabled={busy}
|
||||||
|
style={
|
||||||
|
doneToday
|
||||||
|
? ({
|
||||||
|
'--background': habit.color,
|
||||||
|
'--background-hover': habit.color,
|
||||||
|
'--background-activated': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
: ({
|
||||||
|
'--border-color': habit.color,
|
||||||
|
'--color': habit.color,
|
||||||
|
'--border-radius': '18px',
|
||||||
|
minHeight: '50px',
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
||||||
|
slot="start"
|
||||||
|
/>
|
||||||
|
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All-time stats */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">All-time stats</p>
|
||||||
|
<div className="stack-list">
|
||||||
|
{/* Stats row */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalCompletions}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
total done
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{longestStreak}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
best streak
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: habit.color,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consistencyRate}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
consistency
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current streak highlight */}
|
||||||
|
<div
|
||||||
|
className="hero-card compact"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: `${habit.color}18`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IonIcon
|
||||||
|
icon={flameOutline}
|
||||||
|
style={{ color: habit.color, fontSize: '1.3rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--ion-color-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
||||||
|
counting
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '4px 0 0',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
fontSize: '0.84rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak > 0
|
||||||
|
? `You've shown up every day since ${(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - currentStreak + 1);
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
})()}.`
|
||||||
|
: "Start today and build a streak you're proud of."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 30-day heatmap */}
|
||||||
|
<section>
|
||||||
|
<p className="home-section-label">Last 30 days</p>
|
||||||
|
<div className="hero-card compact">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heatmapDays.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.key}
|
||||||
|
title={
|
||||||
|
day.isToday
|
||||||
|
? 'Today'
|
||||||
|
: new Date(day.key).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: day.completed
|
||||||
|
? habit.color
|
||||||
|
: `${habit.color}14`,
|
||||||
|
border: day.isToday
|
||||||
|
? `2px solid ${habit.color}`
|
||||||
|
: '2px solid transparent',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
color: 'var(--ion-color-medium)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>30 days ago</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon
|
||||||
|
icon={trendingUpOutline}
|
||||||
|
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
||||||
|
/>
|
||||||
|
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
||||||
|
</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<HabitFormModal
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onDismiss={() => setShowEditModal(false)}
|
||||||
|
onSubmit={updateHabit}
|
||||||
|
initialValues={{
|
||||||
|
name: habit.name,
|
||||||
|
targetDescription: habit.target_description ?? '',
|
||||||
|
color: habit.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<IonAlert
|
||||||
|
isOpen={showDeleteAlert}
|
||||||
|
onDidDismiss={() => setShowDeleteAlert(false)}
|
||||||
|
header="Delete habit?"
|
||||||
|
message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
role: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
role: 'destructive',
|
||||||
|
handler: deleteHabit,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HabitDetail;
|
||||||
+450
-199
@@ -1,239 +1,490 @@
|
|||||||
import React, { useState } from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
IonContent,
|
IonContent,
|
||||||
IonPage,
|
IonHeader,
|
||||||
IonIcon,
|
IonIcon,
|
||||||
IonText,
|
IonPage,
|
||||||
useIonViewWillEnter,
|
IonRefresher,
|
||||||
|
IonRefresherContent,
|
||||||
|
IonSkeletonText,
|
||||||
|
IonToolbar,
|
||||||
} from '@ionic/react';
|
} from '@ionic/react';
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
heartCircleOutline,
|
addOutline,
|
||||||
medkitOutline,
|
analyticsOutline,
|
||||||
restaurantOutline,
|
checkmarkCircle,
|
||||||
moonOutline,
|
personCircleOutline,
|
||||||
chevronForwardOutline,
|
sparklesOutline,
|
||||||
} from 'ionicons/icons';
|
} from 'ionicons/icons';
|
||||||
import WellbeingScoreCard from '../components/health/WellbeingScoreCard';
|
import { PushNotifications } from '@capacitor/push-notifications';
|
||||||
import MetricMiniCard from '../components/health/MetricMiniCard';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import RecentActivityList from '../components/health/RecentActivityList';
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
import {
|
import type { Tables } from '../database.types';
|
||||||
getWellbeingSummary,
|
import { supabase } from '../supabase';
|
||||||
getRecentActivity,
|
import EmptyState from '../components/EmptyState';
|
||||||
} from '../services/localHealthStore';
|
import HabitCard from '../components/HabitCard';
|
||||||
import { WellbeingSummary, ActivityEntry } from '../types/health';
|
import HabitFormModal from '../components/HabitFormModal';
|
||||||
|
|
||||||
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [summary, setSummary] = useState<WellbeingSummary>(
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
getWellbeingSummary()
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
);
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
const [recentEntries, setRecentEntries] =
|
const [loading, setLoading] = useState(true);
|
||||||
useState<ActivityEntry[]>(getRecentActivity());
|
const [error, setError] = useState('');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
|
||||||
|
const pushTokenRef = useRef<string | null>(null);
|
||||||
|
const pushListenersReadyRef = useRef(false);
|
||||||
|
|
||||||
const refreshData = () => {
|
const showError = (message: string) => {
|
||||||
setSummary(getWellbeingSummary());
|
setError(message);
|
||||||
setRecentEntries(getRecentActivity());
|
window.setTimeout(() => setError(''), 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
const registerPushNotifications = useCallback(
|
||||||
refreshData();
|
async (currentUserId: string) => {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pushListenersReadyRef.current) {
|
||||||
|
pushListenersReadyRef.current = true;
|
||||||
|
|
||||||
|
await PushNotifications.removeAllListeners();
|
||||||
|
|
||||||
|
PushNotifications.addListener('registration', async (token) => {
|
||||||
|
if (pushTokenRef.current === token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushTokenRef.current = token.value;
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.update({ fcm_token: token.value })
|
||||||
|
.eq('user_id', currentUserId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
showError(updateError.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
PushNotifications.addListener(
|
||||||
|
'registrationError',
|
||||||
|
(registrationError) => {
|
||||||
|
showError(registrationError.error ?? 'Push registration failed.');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionStatus = await PushNotifications.checkPermissions();
|
||||||
|
if (permissionStatus.receive === 'prompt') {
|
||||||
|
const requested = await PushNotifications.requestPermissions();
|
||||||
|
if (requested.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (permissionStatus.receive !== 'granted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushNotifications.register();
|
||||||
|
},
|
||||||
|
[showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
error: sessionError,
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
setError(sessionError.message);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setUserId(null);
|
||||||
|
setSessionChecked(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
await registerPushNotifications(session.user.id);
|
||||||
|
|
||||||
|
const { data, error: habitsError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
if (habitsError) {
|
||||||
|
setError(habitsError.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
setLoading(false);
|
||||||
|
}, [registerPushNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
|
||||||
|
const habitStats = useMemo(() => {
|
||||||
|
const last7Days = Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
return habits.reduce<
|
||||||
|
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
|
||||||
|
>((acc, habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on
|
||||||
|
);
|
||||||
|
const completedInWeek = completionDates.filter((date) =>
|
||||||
|
last7Days.includes(date)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
acc[habit.id] = {
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
|
||||||
|
doneToday: completionDates.includes(today),
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [habits, today]);
|
||||||
|
|
||||||
|
const totalCompletedToday = habits.filter(
|
||||||
|
(habit) => habitStats[habit.id]?.doneToday
|
||||||
|
).length;
|
||||||
|
const longestStreak = habits.reduce(
|
||||||
|
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const completionRatio = habits.length
|
||||||
|
? totalCompletedToday / habits.length
|
||||||
|
: 0;
|
||||||
|
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
|
||||||
|
|
||||||
|
const createHabit = async (values: {
|
||||||
|
name: string;
|
||||||
|
targetDescription: string;
|
||||||
|
color: string;
|
||||||
|
}) => {
|
||||||
|
if (!userId) throw new Error('You need to be logged in.');
|
||||||
|
|
||||||
|
const { error: insertError } = await supabase.from('habits').insert({
|
||||||
|
user_id: userId,
|
||||||
|
name: values.name,
|
||||||
|
target_description: values.targetDescription || null,
|
||||||
|
color: values.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) throw insertError;
|
||||||
|
await loadDashboard();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHabit = async (habit: HabitWithMeta) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setBusyHabitId(habit.id);
|
||||||
|
|
||||||
|
const existing = habit.completions.find(
|
||||||
|
(entry) => entry.completed_on === today
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (deleteError) showError(deleteError.message);
|
||||||
|
} else {
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('habit_completions')
|
||||||
|
.insert({
|
||||||
|
habit_id: habit.id,
|
||||||
|
user_id: userId,
|
||||||
|
completed_on: today,
|
||||||
|
});
|
||||||
|
if (insertError) {
|
||||||
|
showError(insertError.message);
|
||||||
|
} else {
|
||||||
|
const { error: pushError } = await supabase.functions.invoke(
|
||||||
|
'send-habit-completion-push',
|
||||||
|
{
|
||||||
|
body: { habitId: habit.id },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pushError) {
|
||||||
|
showError(pushError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHabit = async (habitId: string) => {
|
||||||
|
setBusyHabitId(habitId);
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.delete()
|
||||||
|
.eq('id', habitId);
|
||||||
|
if (deleteError) {
|
||||||
|
showError(deleteError.message);
|
||||||
|
}
|
||||||
|
await loadDashboard();
|
||||||
|
setBusyHabitId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
void PushNotifications.removeAllListeners();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IonPage style={{ background: '#eef7fb' }}>
|
<IonPage>
|
||||||
<IonContent
|
<IonHeader className="ion-no-border home-header">
|
||||||
fullscreen
|
<IonToolbar>
|
||||||
style={{
|
<IonButtons slot="end">
|
||||||
'--background':
|
<IonButton
|
||||||
'linear-gradient(160deg, rgba(56,189,248,0.16) 0%, #eef7fb 58%, #f8fbfd 100%)',
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/stats')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={analyticsOutline} />
|
||||||
|
</IonButton>
|
||||||
|
<IonButton
|
||||||
|
className="soft-icon-button"
|
||||||
|
onClick={() => history.push('/account')}
|
||||||
|
>
|
||||||
|
<IonIcon icon={personCircleOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</IonButtons>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
|
||||||
|
<IonContent fullscreen className="home-content">
|
||||||
|
<IonRefresher
|
||||||
|
slot="fixed"
|
||||||
|
onIonRefresh={async (e) => {
|
||||||
|
await loadDashboard();
|
||||||
|
e.detail.complete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top Content Row */}
|
<IonRefresherContent />
|
||||||
<div
|
</IonRefresher>
|
||||||
style={{
|
|
||||||
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
|
<div className="dashboard-shell home-shell ion-padding">
|
||||||
display: 'flex',
|
<section className="habits-hero">
|
||||||
alignItems: 'center',
|
<div className="habits-hero-top">
|
||||||
gap: '12px',
|
<div>
|
||||||
}}
|
<p className="hero-kicker">Habits ({habits.length})</p>
|
||||||
>
|
<div className="hero-title-wrap">
|
||||||
<div
|
<h1>Daily</h1>
|
||||||
style={{
|
<span>Weekly</span>
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
background: 'rgba(3,105,161,0.12)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={heartCircleOutline}
|
|
||||||
style={{ fontSize: '22px', color: '#0369a1' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
lineHeight: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Wellbeing
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '20px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
lineHeight: '24px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Good morning
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<IonButton
|
||||||
style={{
|
className="hero-add-button"
|
||||||
background: '#ffffff',
|
fill="clear"
|
||||||
borderRadius: '999px',
|
onClick={() => setShowCreateModal(true)}
|
||||||
padding: '8px 12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#0369a1',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Local
|
<IonIcon icon={addOutline} />
|
||||||
|
</IonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-progress-track" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="hero-progress-fill"
|
||||||
|
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
|
||||||
|
>
|
||||||
|
<span className="hero-progress-dot" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score Widget */}
|
<div className="hero-progress-meta">
|
||||||
<WellbeingScoreCard
|
<span>{totalCompletedToday} completed today</span>
|
||||||
score={summary.score}
|
<span>{Math.round(completionRatio * 100)}% completed</span>
|
||||||
message={summary.scoreMessage}
|
</div>
|
||||||
progress={summary.scoreProgress}
|
</section>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="home-routine-panel">
|
||||||
|
<div className="stack-list home-habit-stack">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
className="habit-card habit-card--editorial habit-card-skeleton"
|
||||||
|
key={`habit-skeleton-${index}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<IonSkeletonText
|
||||||
|
animated
|
||||||
|
className="habit-skeleton-check"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Metric Row */}
|
<div className="habit-card-main">
|
||||||
<div
|
<div className="habit-copy">
|
||||||
style={{
|
<div className="habit-copy-topline">
|
||||||
margin: '0 20px 12px',
|
<IonSkeletonText
|
||||||
display: 'grid',
|
animated
|
||||||
gridTemplateColumns: '1fr 1fr',
|
className="habit-skeleton-title"
|
||||||
gap: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MetricMiniCard
|
|
||||||
label="Sleep"
|
|
||||||
value={summary.sleepHours.toString()}
|
|
||||||
helper="hrs"
|
|
||||||
progress={summary.sleepHours / 8}
|
|
||||||
icon={moonOutline}
|
|
||||||
/>
|
/>
|
||||||
<MetricMiniCard
|
<IonSkeletonText
|
||||||
label="Water"
|
animated
|
||||||
value={summary.hydrationCups.toString()}
|
className="habit-skeleton-color-pill"
|
||||||
helper="cups"
|
|
||||||
progress={summary.hydrationCups / 8}
|
|
||||||
icon={restaurantOutline}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<IonSkeletonText
|
||||||
{/* Action Strip */}
|
animated
|
||||||
<div
|
className="habit-skeleton-description"
|
||||||
style={{
|
/>
|
||||||
display: 'flex',
|
<div className="habit-metrics-row">
|
||||||
gap: '8px',
|
<IonSkeletonText
|
||||||
overflowX: 'auto',
|
animated
|
||||||
padding: '0 20px 20px',
|
className="habit-skeleton-metric"
|
||||||
msOverflowStyle: 'none',
|
/>
|
||||||
scrollbarWidth: 'none',
|
<IonSkeletonText
|
||||||
}}
|
animated
|
||||||
>
|
className="habit-skeleton-metric short"
|
||||||
{[
|
/>
|
||||||
{ label: 'Checkup', icon: medkitOutline, path: '/checkups' },
|
</div>
|
||||||
{ label: 'Diet', icon: restaurantOutline, path: '/diet' },
|
</div>
|
||||||
{ label: 'Sleep', icon: moonOutline, path: '/sleep' },
|
</div>
|
||||||
].map((action) => (
|
|
||||||
<div
|
<IonSkeletonText
|
||||||
key={action.label}
|
animated
|
||||||
onClick={() => history.push(action.path)}
|
className="habit-skeleton-action"
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '10px 18px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
minHeight: '44px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={action.icon}
|
|
||||||
style={{ fontSize: '18px', color: '#0369a1' }}
|
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Section Header */}
|
) : error ? (
|
||||||
<div
|
<div className="inline-message error-card home-inline-card">
|
||||||
style={{
|
<h3>Couldn't load your habits</h3>
|
||||||
margin: '4px 20px 12px',
|
<p>{error}</p>
|
||||||
display: 'flex',
|
<IonButton onClick={loadDashboard}>Try Again</IonButton>
|
||||||
justifyContent: 'space-between',
|
</div>
|
||||||
alignItems: 'baseline',
|
) : habits.length === 0 ? (
|
||||||
}}
|
<div className="home-routine-panel">
|
||||||
>
|
<EmptyState
|
||||||
<span
|
title="Start your first routine"
|
||||||
style={{ fontSize: '17px', fontWeight: '700', color: '#1e3a5f' }}
|
message="Create one small daily habit and begin building your streak story today."
|
||||||
>
|
actionLabel="Create First Habit"
|
||||||
Recent Activity
|
onAction={() => setShowCreateModal(true)}
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
onClick={() => history.push('/insights')}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '2px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '13px', fontWeight: '700', color: '#0369a1' }}
|
|
||||||
>
|
|
||||||
See insights
|
|
||||||
</span>
|
|
||||||
<IonIcon
|
|
||||||
icon={chevronForwardOutline}
|
|
||||||
style={{ fontSize: '14px', color: '#0369a1' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="home-routine-panel">
|
||||||
|
<div className="stack-list home-habit-stack">
|
||||||
|
{habits.map((habit) => (
|
||||||
|
<HabitCard
|
||||||
|
key={habit.id}
|
||||||
|
habit={habit}
|
||||||
|
completedToday={habitStats[habit.id]?.doneToday ?? false}
|
||||||
|
currentStreak={habitStats[habit.id]?.streak ?? 0}
|
||||||
|
completionRate={habitStats[habit.id]?.weekRate ?? 0}
|
||||||
|
onToggle={() => toggleHabit(habit)}
|
||||||
|
onDelete={() => deleteHabit(habit.id)}
|
||||||
|
busy={busyHabitId === habit.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="home-complete-section">
|
||||||
|
<p className="home-section-label">Momentum</p>
|
||||||
|
<div className="home-summary-card">
|
||||||
|
<div
|
||||||
|
className="summary-ring"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="summary-ring-inner">
|
||||||
|
{Math.round(completionRatio * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-copy">
|
||||||
|
<h3>Keep your rhythm</h3>
|
||||||
|
<p>
|
||||||
|
{pendingCount === 0
|
||||||
|
? 'Everything is complete today. Enjoy the streak.'
|
||||||
|
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
|
||||||
|
</p>
|
||||||
|
<div className="summary-metrics">
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
|
||||||
|
done
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
|
||||||
|
best streak
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity List */}
|
<HabitFormModal
|
||||||
<RecentActivityList
|
isOpen={showCreateModal}
|
||||||
entries={recentEntries}
|
onDismiss={() => setShowCreateModal(false)}
|
||||||
onCreate={() => history.push('/checkups')}
|
onSubmit={createHabit}
|
||||||
onSeeInsights={() => history.push('/insights')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ height: '20px' }} />
|
|
||||||
</IonContent>
|
</IonContent>
|
||||||
</IonPage>
|
</IonPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonHeader,
|
|
||||||
IonPage,
|
|
||||||
IonTitle,
|
|
||||||
IonToolbar,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonGrid,
|
|
||||||
IonRow,
|
|
||||||
IonCol,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
chevronBackOutline,
|
|
||||||
analyticsOutline,
|
|
||||||
medkitOutline,
|
|
||||||
restaurantOutline,
|
|
||||||
moonOutline,
|
|
||||||
} from 'ionicons/icons';
|
|
||||||
import {
|
|
||||||
getWellbeingSummary,
|
|
||||||
calculateInsights,
|
|
||||||
} from '../services/localHealthStore';
|
|
||||||
import { WellbeingSummary, LocalInsight } from '../types/health';
|
|
||||||
import {
|
|
||||||
InsightSummaryCard,
|
|
||||||
RecommendationList,
|
|
||||||
} from '../components/health/InsightCards';
|
|
||||||
|
|
||||||
const Insights: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const [summary, setSummary] = useState<WellbeingSummary>(
|
|
||||||
getWellbeingSummary()
|
|
||||||
);
|
|
||||||
const [insights, setInsights] = useState<LocalInsight[]>(calculateInsights());
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
setSummary(getWellbeingSummary());
|
|
||||||
setInsights(calculateInsights());
|
|
||||||
};
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
refreshData();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage style={{ background: '#eef7fb' }}>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#eef7fb', '--color': '#1e3a5f' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle style={{ fontWeight: '700' }}>Insights</IonTitle>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent
|
|
||||||
style={{
|
|
||||||
'--background': '#eef7fb',
|
|
||||||
'--padding-start': '20px',
|
|
||||||
'--padding-end': '20px',
|
|
||||||
'--padding-top': '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{insights.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<InsightSummaryCard
|
|
||||||
summary={summary}
|
|
||||||
primaryInsight={insights[0]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '24px 0 12px',
|
|
||||||
fontSize: '17px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Habit Guidance
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecommendationList insights={insights} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '40px 24px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '64px',
|
|
||||||
height: '64px',
|
|
||||||
borderRadius: '20px',
|
|
||||||
background: 'rgba(3,105,161,0.06)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: '20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon
|
|
||||||
icon={analyticsOutline}
|
|
||||||
style={{ fontSize: '32px', color: 'rgba(3,105,161,0.30)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Insights need a few logs first
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
color: 'rgba(30,58,95,0.58)',
|
|
||||||
lineHeight: '20px',
|
|
||||||
marginBottom: '32px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add a checkup, meal, or sleep log to see personalized guidance
|
|
||||||
based on your patterns.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '12px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonButton
|
|
||||||
onClick={() => history.push('/checkups')}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={medkitOutline} slot="start" />
|
|
||||||
Add Checkup
|
|
||||||
</IonButton>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<IonButton
|
|
||||||
onClick={() => history.push('/diet')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
'--background': '#e2f2f8',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#1e3a5f',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={restaurantOutline} slot="start" />
|
|
||||||
Diet
|
|
||||||
</IonButton>
|
|
||||||
<IonButton
|
|
||||||
onClick={() => history.push('/sleep')}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
'--background': '#e2f2f8',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#1e3a5f',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IonIcon icon={moonOutline} slot="start" />
|
|
||||||
Sleep
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ height: '40px' }} />
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Insights;
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
IonContent,
|
|
||||||
IonHeader,
|
|
||||||
IonPage,
|
|
||||||
IonTitle,
|
|
||||||
IonToolbar,
|
|
||||||
IonButtons,
|
|
||||||
IonButton,
|
|
||||||
IonIcon,
|
|
||||||
IonInput,
|
|
||||||
IonTextarea,
|
|
||||||
IonAlert,
|
|
||||||
useIonViewWillEnter,
|
|
||||||
} from '@ionic/react';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { chevronBackOutline, trashOutline, moonOutline } from 'ionicons/icons';
|
|
||||||
import {
|
|
||||||
getSleepEntries,
|
|
||||||
addSleepEntry,
|
|
||||||
deleteSleepEntry,
|
|
||||||
clearSleepEntries,
|
|
||||||
} from '../services/localHealthStore';
|
|
||||||
import { SleepEntry, SleepQuality } from '../types/health';
|
|
||||||
import HealthLogList from '../components/health/HealthLogList';
|
|
||||||
import SleepTrendCard from '../components/health/SleepTrendCard';
|
|
||||||
|
|
||||||
const Sleep: React.FC = () => {
|
|
||||||
const history = useHistory();
|
|
||||||
const [entries, setEntries] = useState<SleepEntry[]>([]);
|
|
||||||
|
|
||||||
const [hours, setHours] = useState<string>('');
|
|
||||||
const [quality, setQuality] = useState<SleepQuality | ''>('');
|
|
||||||
const [interruptions, setInterruptions] = useState<string>('');
|
|
||||||
const [notes, setNotes] = useState<string>('');
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showClearAlert, setShowClearAlert] = useState(false);
|
|
||||||
|
|
||||||
const refreshData = () => {
|
|
||||||
setEntries(getSleepEntries());
|
|
||||||
};
|
|
||||||
|
|
||||||
useIonViewWillEnter(() => {
|
|
||||||
refreshData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const hoursNum = parseFloat(hours);
|
|
||||||
if (!hours || isNaN(hoursNum) || hoursNum <= 0) {
|
|
||||||
setError('Please enter a valid sleep duration');
|
|
||||||
setTimeout(() => setError(null), 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!quality) {
|
|
||||||
setError('Please select sleep quality');
|
|
||||||
setTimeout(() => setError(null), 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addSleepEntry({
|
|
||||||
hours: hoursNum,
|
|
||||||
quality: quality as SleepQuality,
|
|
||||||
interruptions: parseInt(interruptions) || 0,
|
|
||||||
notes,
|
|
||||||
});
|
|
||||||
|
|
||||||
setHours('');
|
|
||||||
setQuality('');
|
|
||||||
setInterruptions('');
|
|
||||||
setNotes('');
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
deleteSleepEntry(id);
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
clearSleepEntries();
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const qualities: SleepQuality[] = ['Restful', 'Okay', 'Restless'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IonPage style={{ background: '#eef7fb' }}>
|
|
||||||
<IonHeader className="ion-no-border">
|
|
||||||
<IonToolbar style={{ '--background': '#eef7fb', '--color': '#1e3a5f' }}>
|
|
||||||
<IonButtons slot="start">
|
|
||||||
<IonButton onClick={() => history.goBack()}>
|
|
||||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
||||||
</IonButton>
|
|
||||||
</IonButtons>
|
|
||||||
<IonTitle style={{ fontWeight: '700' }}>Sleep</IonTitle>
|
|
||||||
<IonButtons slot="end">
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<IonButton onClick={() => setShowClearAlert(true)}>
|
|
||||||
<IonIcon
|
|
||||||
icon={trashOutline}
|
|
||||||
slot="icon-only"
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
</IonButton>
|
|
||||||
)}
|
|
||||||
</IonButtons>
|
|
||||||
</IonToolbar>
|
|
||||||
</IonHeader>
|
|
||||||
|
|
||||||
<IonContent
|
|
||||||
style={{
|
|
||||||
'--background': '#eef7fb',
|
|
||||||
'--padding-start': '20px',
|
|
||||||
'--padding-end': '20px',
|
|
||||||
'--padding-top': '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Form Card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: '#ffffff',
|
|
||||||
borderRadius: '24px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '18px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
Record your sleep
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Duration (hours)
|
|
||||||
</div>
|
|
||||||
<IonInput
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={hours}
|
|
||||||
onIonInput={(e) => {
|
|
||||||
const val = e.detail.value!.replace(/[^0-9.]/g, '');
|
|
||||||
if ((val.match(/\./g) || []).length <= 1) setHours(val);
|
|
||||||
}}
|
|
||||||
placeholder="0.0"
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Interruptions
|
|
||||||
</div>
|
|
||||||
<IonInput
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={interruptions}
|
|
||||||
onIonInput={(e) =>
|
|
||||||
setInterruptions(e.detail.value!.replace(/[^0-9]/g, ''))
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Quality
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
{qualities.map((q) => (
|
|
||||||
<div
|
|
||||||
key={q}
|
|
||||||
onClick={() => setQuality(q)}
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: quality === q ? '#0369a1' : '#e2f2f8',
|
|
||||||
color: quality === q ? '#ffffff' : '#1e3a5f',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
flex: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{q}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'rgba(30,58,95,0.60)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
<IonTextarea
|
|
||||||
value={notes}
|
|
||||||
onIonInput={(e) => setNotes(e.detail.value!)}
|
|
||||||
placeholder="Add more details..."
|
|
||||||
rows={3}
|
|
||||||
style={{
|
|
||||||
'--background': '#f3f8fb',
|
|
||||||
'--padding-start': '16px',
|
|
||||||
'--padding-end': '16px',
|
|
||||||
'--padding-top': '12px',
|
|
||||||
'--padding-bottom': '12px',
|
|
||||||
'--border-radius': '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#1e3a5f',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#dc2626',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IonButton
|
|
||||||
expand="block"
|
|
||||||
onClick={handleSave}
|
|
||||||
style={{
|
|
||||||
'--background': '#0369a1',
|
|
||||||
'--border-radius': '999px',
|
|
||||||
'--color': '#ffffff',
|
|
||||||
fontWeight: '700',
|
|
||||||
height: '48px',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save sleep log
|
|
||||||
</IonButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trend Widget */}
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<SleepTrendCard entries={entries} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History Section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
margin: '0 0 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ fontSize: '17px', fontWeight: '700', color: '#1e3a5f' }}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'rgba(30,58,95,0.40)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length} logs
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HealthLogList
|
|
||||||
type="sleep"
|
|
||||||
entries={entries}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
emptyTitle="No sleep logs yet"
|
|
||||||
emptyActionLabel="Log sleep"
|
|
||||||
onEmptyAction={() => {}} // Form is already on page
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ height: '40px' }} />
|
|
||||||
|
|
||||||
<IonAlert
|
|
||||||
isOpen={showClearAlert}
|
|
||||||
onDidDismiss={() => setShowClearAlert(false)}
|
|
||||||
header="Clear history?"
|
|
||||||
message="This will delete all sleep logs permanently."
|
|
||||||
buttons={[
|
|
||||||
{ text: 'Cancel', role: 'cancel' },
|
|
||||||
{ text: 'Clear All', role: 'destructive', handler: handleClear },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</IonContent>
|
|
||||||
</IonPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Sleep;
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IonButton,
|
||||||
|
IonButtons,
|
||||||
|
IonContent,
|
||||||
|
IonHeader,
|
||||||
|
IonIcon,
|
||||||
|
IonPage,
|
||||||
|
IonSegment,
|
||||||
|
IonSegmentButton,
|
||||||
|
IonTitle,
|
||||||
|
IonToolbar,
|
||||||
|
} from '@ionic/react';
|
||||||
|
import { IonBackButton } from '@ionic/react';
|
||||||
|
import { calendarOutline } from 'ionicons/icons';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
import type { Tables } from '../database.types';
|
||||||
|
import StatsHabitCard from '../components/StatsHabitCard';
|
||||||
|
import { supabase } from '../supabase';
|
||||||
|
import {
|
||||||
|
Style,
|
||||||
|
setStatusBarBackground,
|
||||||
|
setStatusBarStyle,
|
||||||
|
} from '../utils/statusBar';
|
||||||
|
import { useIonViewWillEnter } from '@ionic/react';
|
||||||
|
|
||||||
|
interface HabitWithMeta extends Tables<'habits'> {
|
||||||
|
completions: Tables<'habit_completions'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const calculateCurrentStreak = (dates: string[]) => {
|
||||||
|
const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a));
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date();
|
||||||
|
|
||||||
|
if (!unique.includes(formatDate(cursor))) {
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (unique.includes(formatDate(cursor))) {
|
||||||
|
streak += 1;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stats: React.FC = () => {
|
||||||
|
const [period, setPeriod] = useState<'7' | '30'>('7');
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
|
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
setSessionChecked(true);
|
||||||
|
setUserId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(session.user.id);
|
||||||
|
setSessionChecked(true);
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.select('*, completions:habit_completions(*)')
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
setHabits((data ?? []) as HabitWithMeta[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const days = Number(period);
|
||||||
|
const visibleDates = useMemo(() => {
|
||||||
|
return Array.from({ length: days }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - index);
|
||||||
|
return formatDate(date);
|
||||||
|
});
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
return habits.map((habit) => {
|
||||||
|
const completionDates = habit.completions.map(
|
||||||
|
(entry) => entry.completed_on,
|
||||||
|
);
|
||||||
|
const completedCount = completionDates.filter((date) =>
|
||||||
|
visibleDates.includes(date),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
id: habit.id,
|
||||||
|
name: habit.name,
|
||||||
|
color: habit.color,
|
||||||
|
completedCount,
|
||||||
|
streak: calculateCurrentStreak(completionDates),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [habits, visibleDates]);
|
||||||
|
|
||||||
|
if (sessionChecked && !userId) {
|
||||||
|
return <Redirect to="/auth" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IonPage>
|
||||||
|
<IonHeader className="ion-no-border">
|
||||||
|
<IonToolbar>
|
||||||
|
<IonButtons slot="start">
|
||||||
|
<IonBackButton defaultHref="/home" />
|
||||||
|
</IonButtons>
|
||||||
|
<IonTitle>Stats</IonTitle>
|
||||||
|
</IonToolbar>
|
||||||
|
</IonHeader>
|
||||||
|
<IonContent fullscreen>
|
||||||
|
<div className="dashboard-shell ion-padding">
|
||||||
|
<section className="hero-card compact">
|
||||||
|
<span className="eyebrow">Consistency view</span>
|
||||||
|
<h1>See which habits you're actually keeping.</h1>
|
||||||
|
<p>
|
||||||
|
Flip between the last week and month to spot patterns before a
|
||||||
|
streak disappears.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="stats-filter-row">
|
||||||
|
<IonIcon icon={calendarOutline} />
|
||||||
|
<IonSegment
|
||||||
|
value={period}
|
||||||
|
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
|
||||||
|
>
|
||||||
|
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
|
||||||
|
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
|
||||||
|
</IonSegment>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.length === 0 ? (
|
||||||
|
<div className="inline-message">
|
||||||
|
<h3>No stats yet</h3>
|
||||||
|
<p>
|
||||||
|
Create a habit and start checking it off daily to unlock
|
||||||
|
consistency insights.
|
||||||
|
</p>
|
||||||
|
<IonButton routerLink="/home">Go to dashboard</IonButton>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="stack-list">
|
||||||
|
{summary.map((habit) => (
|
||||||
|
<StatsHabitCard
|
||||||
|
key={habit.id}
|
||||||
|
name={habit.name}
|
||||||
|
color={habit.color}
|
||||||
|
completedCount={habit.completedCount}
|
||||||
|
totalDays={days}
|
||||||
|
streak={habit.streak}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</IonContent>
|
||||||
|
</IonPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import {
|
|
||||||
Department,
|
|
||||||
CohortAnalyticsSummary,
|
|
||||||
AnalyticsResponse,
|
|
||||||
TelemetryLog,
|
|
||||||
AggregatedTrendPoint,
|
|
||||||
} from '../../types/cohortHealth';
|
|
||||||
import { getRawUsers, getRawTelemetry } from './dataStore';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enterprise Aggregation & Privacy Preservation Layer.
|
|
||||||
*
|
|
||||||
* DESIGN PRINCIPLE:
|
|
||||||
* In a true Zero-Trust enterprise healthcare or corporate wellness deployment,
|
|
||||||
* raw device telemetry (containing exact timestamps, high-frequency heart rates,
|
|
||||||
* and device sleep signals) is considered highly sensitive.
|
|
||||||
*
|
|
||||||
* To guarantee absolute privacy:
|
|
||||||
* 1. The client browser NEVER has access to raw database tables or individual-level records.
|
|
||||||
* 2. Mathematical aggregations occur solely on the server-side.
|
|
||||||
* 3. Cohort minimum sizes (k-anonymity) are programmatically enforced at the API boundary.
|
|
||||||
* 4. Identification vectors are scrubbed using randomized keys and cryptographic hashes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// MINIMUM COHORT SIZE (k-anonymity privacy floor)
|
|
||||||
// If the selected cohort contains fewer than 5 individuals, the API refuses execution
|
|
||||||
// to eliminate the possibility of an HR administrator identifying a specific employee's
|
|
||||||
// biometric signals (e.g. recognizing a specific day's stress spike of a single engineer).
|
|
||||||
const PRIVACY_FLOOR_LIMIT = 5;
|
|
||||||
|
|
||||||
export const getCohortAnalytics = (
|
|
||||||
department: Department
|
|
||||||
): AnalyticsResponse => {
|
|
||||||
try {
|
|
||||||
const allUsers = getRawUsers();
|
|
||||||
const allTelemetry = getRawTelemetry();
|
|
||||||
|
|
||||||
// 1. Filter cohort members
|
|
||||||
const cohortUsers = allUsers.filter((u) => u.department === department);
|
|
||||||
const cohortSize = cohortUsers.length;
|
|
||||||
|
|
||||||
// --- PRIVACY GUARD: k-ANONYMITY ENFORCEMENT ---
|
|
||||||
// In production, this prevents "differential attacks" where an attacker isolates
|
|
||||||
// a department with 1 or 2 users to view their personal health metrics.
|
|
||||||
if (cohortSize < PRIVACY_FLOOR_LIMIT) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errorCode: 'INSUFFICIENT_COHORT_SIZE',
|
|
||||||
error: `Privacy Guard Refusal: Cohort '${department}' is too small.`,
|
|
||||||
details: `Under strict privacy compliance (k-anonymity >= 5), we refuse aggregate telemetry queries for departments with fewer than ${PRIVACY_FLOOR_LIMIT} members (current cohort size: ${cohortSize}). This prevents re-identification risks.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cohortSize === 0 || allTelemetry.length === 0) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
errorCode: 'NO_DATA',
|
|
||||||
error: 'No active telemetry found.',
|
|
||||||
details:
|
|
||||||
'The system has no telemetry records on file. Seed the data generator to proceed.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cohortUserIds = new Set(cohortUsers.map((u) => u.id));
|
|
||||||
|
|
||||||
// 2. Filter telemetry logs for this cohort
|
|
||||||
const cohortTelemetry = allTelemetry.filter((log) =>
|
|
||||||
cohortUserIds.has(log.userId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Group telemetry logs by date to compute daily averages
|
|
||||||
const telemetryByDate: Record<string, TelemetryLog[]> = {};
|
|
||||||
cohortTelemetry.forEach((log) => {
|
|
||||||
if (!telemetryByDate[log.date]) {
|
|
||||||
telemetryByDate[log.date] = [];
|
|
||||||
}
|
|
||||||
telemetryByDate[log.date].push(log);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort dates chronologically
|
|
||||||
const sortedDates = Object.keys(telemetryByDate).sort();
|
|
||||||
|
|
||||||
// Map to aggregated points
|
|
||||||
const trends: AggregatedTrendPoint[] = sortedDates.map((date) => {
|
|
||||||
const logs = telemetryByDate[date];
|
|
||||||
const count = logs.length;
|
|
||||||
|
|
||||||
const sumSleep = logs.reduce((acc, l) => acc + l.sleepScore, 0);
|
|
||||||
const sumStress = logs.reduce((acc, l) => acc + l.stressIndex, 0);
|
|
||||||
const sumActive = logs.reduce((acc, l) => acc + l.activeMinutes, 0);
|
|
||||||
|
|
||||||
// Poor sleep: sleepScore < 60 (Clinical cutoff for disrupted restorative sleep)
|
|
||||||
const poorSleepCount = logs.filter((l) => l.sleepScore < 60).length;
|
|
||||||
// High stress: stressIndex >= 7 (Corresponds to low heart-rate variability / hyper-arousal)
|
|
||||||
const highStressCount = logs.filter((l) => l.stressIndex >= 7).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
avgSleepScore: parseFloat((sumSleep / count).toFixed(1)),
|
|
||||||
avgStressIndex: parseFloat((sumStress / count).toFixed(1)),
|
|
||||||
avgActiveMinutes: parseFloat((sumActive / count).toFixed(1)),
|
|
||||||
poorSleepPercentage: parseFloat(
|
|
||||||
((poorSleepCount / count) * 100).toFixed(0)
|
|
||||||
),
|
|
||||||
highStressPercentage: parseFloat(
|
|
||||||
((highStressCount / count) * 100).toFixed(0)
|
|
||||||
),
|
|
||||||
cohortSize: count,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Compute overall statistics for the cohort
|
|
||||||
const overallAvgSleep = parseFloat(
|
|
||||||
(
|
|
||||||
trends.reduce((acc, t) => acc + t.avgSleepScore, 0) / trends.length
|
|
||||||
).toFixed(1)
|
|
||||||
);
|
|
||||||
const overallAvgStress = parseFloat(
|
|
||||||
(
|
|
||||||
trends.reduce((acc, t) => acc + t.avgStressIndex, 0) / trends.length
|
|
||||||
).toFixed(1)
|
|
||||||
);
|
|
||||||
const overallAvgActive = parseFloat(
|
|
||||||
(
|
|
||||||
trends.reduce((acc, t) => acc + t.avgActiveMinutes, 0) / trends.length
|
|
||||||
).toFixed(1)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. Generate dynamic insights and alert classification
|
|
||||||
const insightData = analyzeTrendsAndGenerateInsight(trends, department);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
department,
|
|
||||||
trends,
|
|
||||||
overallAvgSleep,
|
|
||||||
overallAvgStress,
|
|
||||||
overallAvgActive,
|
|
||||||
cohortSize,
|
|
||||||
insight: insightData.message,
|
|
||||||
insightSeverity: insightData.severity,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Internal aggregate error.',
|
|
||||||
details: err.message || 'An unknown aggregation error occurred.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clinical analytics engine that reads aggregated trend lines to trigger enterprise alerts.
|
|
||||||
* Identifies burnout markers, recovery indexes, and sedentary cycles.
|
|
||||||
*/
|
|
||||||
const analyzeTrendsAndGenerateInsight = (
|
|
||||||
trends: AggregatedTrendPoint[],
|
|
||||||
department: Department
|
|
||||||
): {
|
|
||||||
message: string;
|
|
||||||
severity: 'success' | 'warning' | 'danger' | 'medium';
|
|
||||||
} => {
|
|
||||||
if (trends.length < 5) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
'Monitoring active. Insufficient trend history to run statistical drift alerts.',
|
|
||||||
severity: 'medium',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look at last 5 days vs the preceding days to find trends
|
|
||||||
const halfLength = Math.floor(trends.length / 2);
|
|
||||||
const olderTrend = trends.slice(0, halfLength);
|
|
||||||
const newerTrend = trends.slice(halfLength);
|
|
||||||
|
|
||||||
const olderAvgStress =
|
|
||||||
olderTrend.reduce((acc, t) => acc + t.avgStressIndex, 0) /
|
|
||||||
olderTrend.length;
|
|
||||||
const newerAvgStress =
|
|
||||||
newerTrend.reduce((acc, t) => acc + t.avgStressIndex, 0) /
|
|
||||||
newerTrend.length;
|
|
||||||
|
|
||||||
const olderAvgSleep =
|
|
||||||
olderTrend.reduce((acc, t) => acc + t.avgSleepScore, 0) / olderTrend.length;
|
|
||||||
const newerAvgSleep =
|
|
||||||
newerTrend.reduce((acc, t) => acc + t.avgSleepScore, 0) / newerTrend.length;
|
|
||||||
|
|
||||||
const stressDeltaPct =
|
|
||||||
((newerAvgStress - olderAvgStress) / olderAvgStress) * 100;
|
|
||||||
const sleepDeltaPct = ((newerAvgSleep - olderAvgSleep) / olderAvgSleep) * 100;
|
|
||||||
|
|
||||||
// Engineering Burnout Anomaly check
|
|
||||||
if (
|
|
||||||
department === 'Engineering' &&
|
|
||||||
stressDeltaPct > 12 &&
|
|
||||||
sleepDeltaPct < -5
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
message: `⚠️ COHORT ALERT: The Engineering division has experienced a ${Math.abs(stressDeltaPct).toFixed(0)}% increase in elevated stress markers and a ${Math.abs(sleepDeltaPct).toFixed(0)}% drop in restorative sleep over the last 7 days. Action Recommendation: High likelihood of workload burnout. Consider a meeting-light week or deploying wellness interventions.`,
|
|
||||||
severity: 'danger',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sales Stress check
|
|
||||||
if (department === 'Sales') {
|
|
||||||
const weekendTrend = trends.filter((t) => {
|
|
||||||
const day = new Date(t.date).getDay();
|
|
||||||
return day === 0 || day === 6;
|
|
||||||
});
|
|
||||||
const weekdayTrend = trends.filter((t) => {
|
|
||||||
const day = new Date(t.date).getDay();
|
|
||||||
return day !== 0 && day !== 6;
|
|
||||||
});
|
|
||||||
|
|
||||||
const wkStress =
|
|
||||||
weekdayTrend.reduce((acc, t) => acc + t.avgStressIndex, 0) /
|
|
||||||
Math.max(1, weekdayTrend.length);
|
|
||||||
const weStress =
|
|
||||||
weekendTrend.reduce((acc, t) => acc + t.avgStressIndex, 0) /
|
|
||||||
Math.max(1, weekendTrend.length);
|
|
||||||
|
|
||||||
if (wkStress > weStress + 2.0) {
|
|
||||||
return {
|
|
||||||
message: `📊 Dynamic Cohort Insights: Sales telemetry displays high weekday stress peaks (${wkStress.toFixed(1)}/10) with healthy weekend decompression (${weStress.toFixed(1)}/10). Recommendation: Ensure micro-breaks are integrated during intensive mid-week sales sprints.`,
|
|
||||||
severity: 'warning',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operations physical health check
|
|
||||||
if (department === 'Operations') {
|
|
||||||
const avgActive =
|
|
||||||
trends.reduce((acc, t) => acc + t.avgActiveMinutes, 0) / trends.length;
|
|
||||||
if (avgActive > 70) {
|
|
||||||
return {
|
|
||||||
message: `🟢 Healthy Cohort Signal: Operations logs highly consistent physical engagement averaging ${avgActive.toFixed(0)} minutes of active cardiovascular telemetry daily. This correlates with stable stress metrics (${overallAverageStress(trends).toFixed(1)}/10).`,
|
|
||||||
severity: 'success',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default Fallback
|
|
||||||
return {
|
|
||||||
message: `📈 Baseline Trend Analysis: Biomarkers for ${department} are within normal enterprise tolerances. Sleep quality index is stable at ${overallAverageSleep(trends).toFixed(0)}/100, and sympathetic nervous activation indicates low-to-moderate occupational strain.`,
|
|
||||||
severity: 'success',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const overallAverageStress = (trends: AggregatedTrendPoint[]) =>
|
|
||||||
trends.reduce((acc, t) => acc + t.avgStressIndex, 0) / trends.length;
|
|
||||||
|
|
||||||
const overallAverageSleep = (trends: AggregatedTrendPoint[]) =>
|
|
||||||
trends.reduce((acc, t) => acc + t.avgSleepScore, 0) / trends.length;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { User, TelemetryLog } from '../../types/cohortHealth';
|
|
||||||
|
|
||||||
const STORAGE_KEY_USERS = 'cohorthealth_raw_users';
|
|
||||||
const STORAGE_KEY_TELEMETRY = 'cohorthealth_raw_telemetry';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enterprise Data Architecture Mock Store.
|
|
||||||
*
|
|
||||||
* DESIGN BOUNDARY NOTE:
|
|
||||||
* This represents the internal, firewalled raw telemetry database. In a production enterprise setting,
|
|
||||||
* this database sits behind strict network VPC guidelines and is completely inaccessible
|
|
||||||
* from public client applications or APIs.
|
|
||||||
*
|
|
||||||
* ONLY the anonymizer/aggregator microservice has secure read permissions to this store.
|
|
||||||
* The client UI is programmatically blocked from calling any function in this file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const getRawUsers = (): User[] => {
|
|
||||||
const data = localStorage.getItem(STORAGE_KEY_USERS);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveRawUsers = (users: User[]): void => {
|
|
||||||
localStorage.setItem(STORAGE_KEY_USERS, JSON.stringify(users));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRawTelemetry = (): TelemetryLog[] => {
|
|
||||||
const data = localStorage.getItem(STORAGE_KEY_TELEMETRY);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveRawTelemetry = (telemetry: TelemetryLog[]): void => {
|
|
||||||
localStorage.setItem(STORAGE_KEY_TELEMETRY, JSON.stringify(telemetry));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAllData = (): void => {
|
|
||||||
localStorage.removeItem(STORAGE_KEY_USERS);
|
|
||||||
localStorage.removeItem(STORAGE_KEY_TELEMETRY);
|
|
||||||
};
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import { User, TelemetryLog, Department } from '../../types/cohortHealth';
|
|
||||||
import {
|
|
||||||
getRawUsers,
|
|
||||||
saveRawUsers,
|
|
||||||
getRawTelemetry,
|
|
||||||
saveRawTelemetry,
|
|
||||||
} from './dataStore';
|
|
||||||
|
|
||||||
// In production, we would use a secure cryptographic hashing function like HMAC-SHA256
|
|
||||||
// with a salt rotated daily, which completely obfuscates individual identities
|
|
||||||
// before ingestion. This ensures zero PII (Personally Identifiable Information) enters the telemetry table.
|
|
||||||
const generateSecureEmployeeHash = (
|
|
||||||
index: number,
|
|
||||||
department: string
|
|
||||||
): string => {
|
|
||||||
// Simple deterministic mockup of an anonymized secure employee token
|
|
||||||
return `emp_hash_${department.toLowerCase().substring(0, 3)}_${(1000 + index).toString(16)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enterprise Telemetry Simulation Engine.
|
|
||||||
*
|
|
||||||
* Generates high-fidelity wearable telemetry logs for users across 3 corporate departments:
|
|
||||||
* - Engineering (Injected Burnout Anomaly: 70% experience 14-day decline in sleep and spike in stress)
|
|
||||||
* - Sales (High volatility, weekend recovery, elevated stress levels)
|
|
||||||
* - Operations (Stable baseline, high physical activity, healthy sleep indicators)
|
|
||||||
*/
|
|
||||||
export const generateWorkforceData = (
|
|
||||||
userCount: number = 100,
|
|
||||||
forceReset = false
|
|
||||||
): { users: User[]; logs: TelemetryLog[] } => {
|
|
||||||
const existingUsers = getRawUsers();
|
|
||||||
const existingLogs = getRawTelemetry();
|
|
||||||
|
|
||||||
if (existingUsers.length > 0 && existingLogs.length > 0 && !forceReset) {
|
|
||||||
return { users: existingUsers, logs: existingLogs };
|
|
||||||
}
|
|
||||||
|
|
||||||
const departments: Department[] = ['Engineering', 'Sales', 'Operations'];
|
|
||||||
const users: User[] = [];
|
|
||||||
|
|
||||||
// Distribute users across departments
|
|
||||||
// Engineering: 40%, Sales: 30%, Operations: 30%
|
|
||||||
for (let i = 0; i < userCount; i++) {
|
|
||||||
let dept: Department = 'Operations';
|
|
||||||
if (i < userCount * 0.4) {
|
|
||||||
dept = 'Engineering';
|
|
||||||
} else if (i < userCount * 0.7) {
|
|
||||||
dept = 'Sales';
|
|
||||||
}
|
|
||||||
|
|
||||||
users.push({
|
|
||||||
id: generateSecureEmployeeHash(i, dept),
|
|
||||||
department: dept,
|
|
||||||
joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Programmatically append exactly 3 Executive Board users to demonstrate
|
|
||||||
// the Privacy Guard k-anonymity limit (< 5 users) refusing aggregate rendering.
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
users.push({
|
|
||||||
id: generateSecureEmployeeHash(userCount + i, 'Executive Board'),
|
|
||||||
department: 'Executive Board',
|
|
||||||
joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveRawUsers(users);
|
|
||||||
|
|
||||||
// Generate 14 days of historical data for each user
|
|
||||||
const logs: TelemetryLog[] = [];
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
for (let dayOffset = 13; dayOffset >= 0; dayOffset--) {
|
|
||||||
const currentDate = new Date(today);
|
|
||||||
currentDate.setDate(today.getDate() - dayOffset);
|
|
||||||
const dateStr = currentDate.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
users.forEach((user, uIndex) => {
|
|
||||||
logs.push(generateDailyLogForUser(user, uIndex, dateStr, dayOffset));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveRawTelemetry(logs);
|
|
||||||
|
|
||||||
// Track current simulation day offset in localstorage so we can increment it during "Wearable Sync Pulse"
|
|
||||||
localStorage.setItem('cohorthealth_sim_day_offset', '0');
|
|
||||||
|
|
||||||
return { users, logs };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates single-day wearable biomarkers with specific trends and injected anomalies.
|
|
||||||
* @param dayOffset Days in the past (13 = 13 days ago, 0 = today)
|
|
||||||
*/
|
|
||||||
const generateDailyLogForUser = (
|
|
||||||
user: User,
|
|
||||||
userIndex: number,
|
|
||||||
dateStr: string,
|
|
||||||
dayOffset: number
|
|
||||||
): TelemetryLog => {
|
|
||||||
let sleepScore = 75;
|
|
||||||
let stressIndex = 4;
|
|
||||||
let activeMinutes = 45;
|
|
||||||
|
|
||||||
const isWeekend = isDateWeekend(dateStr);
|
|
||||||
|
|
||||||
if (user.department === 'Engineering') {
|
|
||||||
// Burnout Anomaly injection: 70% of Engineering department experiences steady decline
|
|
||||||
const affectedByAnomaly = userIndex % 10 < 7;
|
|
||||||
|
|
||||||
if (affectedByAnomaly) {
|
|
||||||
// Linear decline of sleep score from dayOffset 13 to 0
|
|
||||||
// dayOffset 13 (oldest) -> sleep starts high, stress starts normal
|
|
||||||
// dayOffset 0 (newest) -> sleep drops low, stress spikes high
|
|
||||||
const progression = (13 - dayOffset) / 13; // 0.0 to 1.0 over the 14 days
|
|
||||||
|
|
||||||
sleepScore = Math.round(76 - progression * 24 + (Math.random() * 8 - 4)); // Starts ~76, drops to ~52
|
|
||||||
stressIndex = Math.round(
|
|
||||||
3.5 + progression * 4.5 + (Math.random() * 1.5 - 0.75)
|
|
||||||
); // Starts ~3.5, spikes to ~8.0
|
|
||||||
activeMinutes = Math.round(
|
|
||||||
50 - progression * 25 + (Math.random() * 15 - 7)
|
|
||||||
); // Decline in physical activity
|
|
||||||
} else {
|
|
||||||
// 30% control group - remains relatively healthy
|
|
||||||
sleepScore = Math.round(74 + (Math.random() * 12 - 6));
|
|
||||||
stressIndex = Math.round(4.0 + (Math.random() * 2 - 1));
|
|
||||||
activeMinutes = Math.round(45 + (Math.random() * 20 - 10));
|
|
||||||
}
|
|
||||||
} else if (user.department === 'Sales') {
|
|
||||||
// Sales: High volatility. High weekdays stress, weekend recovery.
|
|
||||||
if (isWeekend) {
|
|
||||||
sleepScore = Math.round(80 + (Math.random() * 8 - 4));
|
|
||||||
stressIndex = Math.round(3.0 + (Math.random() * 1.5 - 0.75));
|
|
||||||
activeMinutes = Math.round(65 + (Math.random() * 30 - 15));
|
|
||||||
} else {
|
|
||||||
sleepScore = Math.round(68 + (Math.random() * 10 - 5));
|
|
||||||
stressIndex = Math.round(6.2 + (Math.random() * 2.2 - 1.1));
|
|
||||||
activeMinutes = Math.round(35 + (Math.random() * 15 - 7));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Operations: High active minutes, stable sleep and stress
|
|
||||||
sleepScore = Math.round(75 + (Math.random() * 8 - 4));
|
|
||||||
stressIndex = Math.round(3.8 + (Math.random() * 1.5 - 0.75));
|
|
||||||
activeMinutes = Math.round(80 + (Math.random() * 30 - 15));
|
|
||||||
|
|
||||||
// Slight weekend boost
|
|
||||||
if (isWeekend) {
|
|
||||||
sleepScore += 4;
|
|
||||||
stressIndex -= 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounds enforcement
|
|
||||||
sleepScore = Math.max(0, Math.min(100, sleepScore));
|
|
||||||
stressIndex = Math.max(1, Math.min(10, stressIndex));
|
|
||||||
activeMinutes = Math.max(0, Math.min(180, activeMinutes));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `log_${user.id}_${dateStr}`,
|
|
||||||
userId: user.id,
|
|
||||||
date: dateStr,
|
|
||||||
sleepScore,
|
|
||||||
stressIndex,
|
|
||||||
activeMinutes,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDateWeekend = (dateStr: string): boolean => {
|
|
||||||
const day = new Date(dateStr).getDay();
|
|
||||||
return day === 0 || day === 6; // Sunday or Saturday
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates a new wearable data synchronization event (typically run daily via worker threads).
|
|
||||||
* Instantly appends another 24 hours of mock telemetry logs to the database for all users,
|
|
||||||
* progressing the anomaly trends.
|
|
||||||
*/
|
|
||||||
export const triggerWearableSyncPulse = (): {
|
|
||||||
success: boolean;
|
|
||||||
dateSynced: string;
|
|
||||||
recordCount: number;
|
|
||||||
} => {
|
|
||||||
const users = getRawUsers();
|
|
||||||
const logs = getRawTelemetry();
|
|
||||||
|
|
||||||
if (users.length === 0 || logs.length === 0) {
|
|
||||||
// Seed first if empty
|
|
||||||
const { users: seededUsers, logs: seededLogs } = generateWorkforceData(
|
|
||||||
100,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
dateSynced: new Date().toISOString().split('T')[0],
|
|
||||||
recordCount: seededLogs.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine what next date is
|
|
||||||
let currentSimOffset = parseInt(
|
|
||||||
localStorage.getItem('cohorthealth_sim_day_offset') || '0',
|
|
||||||
10
|
|
||||||
);
|
|
||||||
currentSimOffset += 1; // Progress 1 day forward
|
|
||||||
localStorage.setItem(
|
|
||||||
'cohorthealth_sim_day_offset',
|
|
||||||
currentSimOffset.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const targetDate = new Date(today);
|
|
||||||
targetDate.setDate(today.getDate() + currentSimOffset);
|
|
||||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
const newLogs: TelemetryLog[] = [];
|
|
||||||
users.forEach((user, uIndex) => {
|
|
||||||
// Use negative dayOffset to simulate ongoing burnout/stable trends in generator
|
|
||||||
// (the linear burnout continues trending slightly negative/tense)
|
|
||||||
const mockOffset = -currentSimOffset;
|
|
||||||
newLogs.push(
|
|
||||||
generateDailyLogForUser(user, uIndex, targetDateStr, mockOffset)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedLogs = [...logs, ...newLogs];
|
|
||||||
saveRawTelemetry(updatedLogs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
dateSynced: targetDateStr,
|
|
||||||
recordCount: newLogs.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the local store completely and seeds fresh 14-day history.
|
|
||||||
*/
|
|
||||||
export const resetDatabase = (): void => {
|
|
||||||
generateWorkforceData(100, true);
|
|
||||||
};
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import {
|
|
||||||
CheckupEntry,
|
|
||||||
DietEntry,
|
|
||||||
SleepEntry,
|
|
||||||
ActivityEntry,
|
|
||||||
WellbeingSummary,
|
|
||||||
LocalInsight,
|
|
||||||
} from '../types/health';
|
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
CHECKUPS: 'healthcare.checkups.v1',
|
|
||||||
DIET: 'healthcare.diet.v1',
|
|
||||||
SLEEP: 'healthcare.sleep.v1',
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- CRUD HELPERS ---
|
|
||||||
|
|
||||||
const getItems = <T,>(key: string): T[] => {
|
|
||||||
const data = localStorage.getItem(key);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveItems = <T,>(key: string, items: T[]): void => {
|
|
||||||
localStorage.setItem(key, JSON.stringify(items));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCheckups = (): CheckupEntry[] =>
|
|
||||||
getItems<CheckupEntry>(KEYS.CHECKUPS);
|
|
||||||
export const addCheckup = (entry: Omit<CheckupEntry, 'id' | 'createdAt'>) => {
|
|
||||||
const items = getCheckups();
|
|
||||||
const newItem: CheckupEntry = {
|
|
||||||
...entry,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveItems(KEYS.CHECKUPS, [newItem, ...items]);
|
|
||||||
};
|
|
||||||
export const deleteCheckup = (id: string) => {
|
|
||||||
const items = getCheckups().filter((i) => i.id !== id);
|
|
||||||
saveItems(KEYS.CHECKUPS, items);
|
|
||||||
};
|
|
||||||
export const clearCheckups = () => saveItems(KEYS.CHECKUPS, []);
|
|
||||||
|
|
||||||
export const getDietEntries = (): DietEntry[] => getItems<DietEntry>(KEYS.DIET);
|
|
||||||
export const addDietEntry = (entry: Omit<DietEntry, 'id' | 'createdAt'>) => {
|
|
||||||
const items = getDietEntries();
|
|
||||||
const newItem: DietEntry = {
|
|
||||||
...entry,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveItems(KEYS.DIET, [newItem, ...items]);
|
|
||||||
};
|
|
||||||
export const deleteDietEntry = (id: string) => {
|
|
||||||
const items = getDietEntries().filter((i) => i.id !== id);
|
|
||||||
saveItems(KEYS.DIET, items);
|
|
||||||
};
|
|
||||||
export const clearDietEntries = () => saveItems(KEYS.DIET, []);
|
|
||||||
|
|
||||||
export const getSleepEntries = (): SleepEntry[] =>
|
|
||||||
getItems<SleepEntry>(KEYS.SLEEP);
|
|
||||||
export const addSleepEntry = (entry: Omit<SleepEntry, 'id' | 'createdAt'>) => {
|
|
||||||
const items = getSleepEntries();
|
|
||||||
const newItem: SleepEntry = {
|
|
||||||
...entry,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
saveItems(KEYS.SLEEP, [newItem, ...items]);
|
|
||||||
};
|
|
||||||
export const deleteSleepEntry = (id: string) => {
|
|
||||||
const items = getSleepEntries().filter((i) => i.id !== id);
|
|
||||||
saveItems(KEYS.SLEEP, items);
|
|
||||||
};
|
|
||||||
export const clearSleepEntries = () => saveItems(KEYS.SLEEP, []);
|
|
||||||
|
|
||||||
// --- DASHBOARD HELPERS ---
|
|
||||||
|
|
||||||
export const getRecentActivity = (): ActivityEntry[] => {
|
|
||||||
const checkups = getCheckups().map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
type: 'checkup' as const,
|
|
||||||
date: i.createdAt,
|
|
||||||
title: `Checkup: ${i.mood}`,
|
|
||||||
subtitle: i.symptoms || 'No symptoms noted',
|
|
||||||
}));
|
|
||||||
const diet = getDietEntries().map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
type: 'diet' as const,
|
|
||||||
date: i.createdAt,
|
|
||||||
title: `${i.mealType} log`,
|
|
||||||
subtitle: `${i.waterCups} cups water, ${i.produceServings} servings produce`,
|
|
||||||
}));
|
|
||||||
const sleep = getSleepEntries().map((i) => ({
|
|
||||||
id: i.id,
|
|
||||||
type: 'sleep' as const,
|
|
||||||
date: i.createdAt,
|
|
||||||
title: `Sleep: ${i.hours}h`,
|
|
||||||
subtitle: `${i.quality} quality`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [...checkups, ...diet, ...sleep]
|
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getWellbeingSummary = (): WellbeingSummary => {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const checkups = getCheckups();
|
|
||||||
const diet = getDietEntries();
|
|
||||||
const sleep = getSleepEntries();
|
|
||||||
|
|
||||||
const todayDiet = diet.filter((i) => i.createdAt.startsWith(today));
|
|
||||||
const water = todayDiet.reduce((sum, i) => sum + i.waterCups, 0);
|
|
||||||
const produce = todayDiet.reduce((sum, i) => sum + i.produceServings, 0);
|
|
||||||
|
|
||||||
const lastSleep = sleep[0];
|
|
||||||
const lastCheckup = checkups[0];
|
|
||||||
|
|
||||||
// Simple score calculation (0-100)
|
|
||||||
let score = 50;
|
|
||||||
if (lastCheckup) {
|
|
||||||
if (lastCheckup.mood === 'Great') score += 15;
|
|
||||||
if (lastCheckup.mood === 'Low') score -= 10;
|
|
||||||
if (lastCheckup.mood === 'Unwell') score -= 20;
|
|
||||||
score += lastCheckup.energy / 10;
|
|
||||||
}
|
|
||||||
if (lastSleep) {
|
|
||||||
if (lastSleep.hours >= 7 && lastSleep.hours <= 9) score += 10;
|
|
||||||
if (lastSleep.quality === 'Restful') score += 5;
|
|
||||||
}
|
|
||||||
score += Math.min(water, 8); // +1 per cup up to 8
|
|
||||||
score = Math.min(Math.max(score, 0), 100);
|
|
||||||
|
|
||||||
let message = 'Keep tracking to see your wellbeing score improve.';
|
|
||||||
if (score > 80) message = "You're doing great! Keep up the healthy habits.";
|
|
||||||
else if (score > 60)
|
|
||||||
message = 'Good progress. Try adding more hydration today.';
|
|
||||||
else if (score > 40)
|
|
||||||
message = 'A bit low today. Make sure to get enough rest.';
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: Math.round(score),
|
|
||||||
scoreMessage: message,
|
|
||||||
scoreProgress: score / 100,
|
|
||||||
sleepHours: lastSleep?.hours || 0,
|
|
||||||
sleepQuality: lastSleep?.quality || 'N/A',
|
|
||||||
hydrationCups: water,
|
|
||||||
produceServings: produce,
|
|
||||||
streakDays: calculateStreak([...checkups, ...diet, ...sleep]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateStreak = (entries: { createdAt: string }[]): number => {
|
|
||||||
if (entries.length === 0) return 0;
|
|
||||||
const dates = [...new Set(entries.map((e) => e.createdAt.split('T')[0]))]
|
|
||||||
.sort()
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
let streak = 0;
|
|
||||||
let current = new Date();
|
|
||||||
|
|
||||||
for (const dateStr of dates) {
|
|
||||||
const entryDate = new Date(dateStr);
|
|
||||||
const diff = Math.floor(
|
|
||||||
(current.getTime() - entryDate.getTime()) / (1000 * 3600 * 24)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (diff === 0 || diff === 1) {
|
|
||||||
streak++;
|
|
||||||
current = entryDate;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return streak;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- INSIGHT RULES ---
|
|
||||||
|
|
||||||
export const calculateInsights = (): LocalInsight[] => {
|
|
||||||
const checkups = getCheckups();
|
|
||||||
const diet = getDietEntries();
|
|
||||||
const sleep = getSleepEntries();
|
|
||||||
const insights: LocalInsight[] = [];
|
|
||||||
|
|
||||||
if (checkups.length === 0 && diet.length === 0 && sleep.length === 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
const lastSleep = sleep[0];
|
|
||||||
if (lastSleep) {
|
|
||||||
if (lastSleep.hours < 6) {
|
|
||||||
insights.push({
|
|
||||||
id: 'sleep-low',
|
|
||||||
type: 'warning',
|
|
||||||
title: 'Low sleep duration',
|
|
||||||
body: 'You slept less than 6 hours. Consistent sleep is key for recovery.',
|
|
||||||
icon: 'moonOutline',
|
|
||||||
});
|
|
||||||
} else if (lastSleep.hours >= 7) {
|
|
||||||
insights.push({
|
|
||||||
id: 'sleep-good',
|
|
||||||
type: 'positive',
|
|
||||||
title: 'Healthy sleep pattern',
|
|
||||||
body: 'Great job getting over 7 hours of rest.',
|
|
||||||
icon: 'checkmarkCircleOutline',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const todayDiet = diet.filter((i) => i.createdAt.startsWith(today));
|
|
||||||
const water = todayDiet.reduce((sum, i) => sum + i.waterCups, 0);
|
|
||||||
|
|
||||||
if (water < 4 && diet.length > 0) {
|
|
||||||
insights.push({
|
|
||||||
id: 'water-low',
|
|
||||||
type: 'info',
|
|
||||||
title: 'Boost hydration',
|
|
||||||
body: 'Try to drink at least 8 cups of water daily for better energy.',
|
|
||||||
icon: 'waterOutline',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowMoods = checkups
|
|
||||||
.filter((c) => c.mood === 'Low' || c.mood === 'Unwell')
|
|
||||||
.slice(0, 3);
|
|
||||||
if (lowMoods.length >= 2) {
|
|
||||||
insights.push({
|
|
||||||
id: 'mood-warning',
|
|
||||||
type: 'warning',
|
|
||||||
title: 'Mood patterns',
|
|
||||||
body: 'You recorded several low mood entries recently. Consider checking in with a friend.',
|
|
||||||
icon: 'heartOutline',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return insights;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from './database.types';
|
||||||
|
|
||||||
|
export const supabase = createClient<Database>(
|
||||||
|
'https://zillycfjfbzofltgyjwy.supabase.co',
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InppbGx5Y2ZqZmJ6b2ZsdGd5and5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkxMjYzODQsImV4cCI6MjA5NDcwMjM4NH0.BB4ccBEQI2krc7SseC73234Nfya-92VGcTELNQdzt50',
|
||||||
|
);
|
||||||
+886
-69
@@ -1,99 +1,916 @@
|
|||||||
/* Ionic CSS Variables — customise to match your app's brand */
|
|
||||||
:root {
|
:root {
|
||||||
/** primary **/
|
--ion-color-primary: #6d28d9;
|
||||||
--ion-color-primary: #0369a1;
|
--ion-color-primary-rgb: 109, 40, 217;
|
||||||
--ion-color-primary-rgb: 3, 105, 161;
|
|
||||||
--ion-color-primary-contrast: #ffffff;
|
--ion-color-primary-contrast: #ffffff;
|
||||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-primary-shade: #035c8e;
|
--ion-color-primary-shade: #5b21b6;
|
||||||
--ion-color-primary-tint: #1c78aa;
|
--ion-color-primary-tint: #7c3aed;
|
||||||
|
|
||||||
/** secondary **/
|
--ion-color-secondary: #0f766e;
|
||||||
--ion-color-secondary: #38bdf8;
|
--ion-color-secondary-rgb: 15, 118, 110;
|
||||||
--ion-color-secondary-rgb: 56, 189, 248;
|
|
||||||
--ion-color-secondary-contrast: #ffffff;
|
--ion-color-secondary-contrast: #ffffff;
|
||||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-secondary-shade: #31a6da;
|
--ion-color-secondary-shade: #115e59;
|
||||||
--ion-color-secondary-tint: #4cc4f9;
|
--ion-color-secondary-tint: #14b8a6;
|
||||||
|
|
||||||
/** tertiary **/
|
--ion-color-tertiary: #ea580c;
|
||||||
--ion-color-tertiary: #1e3a5f;
|
--ion-color-tertiary-rgb: 234, 88, 12;
|
||||||
--ion-color-tertiary-rgb: 30, 58, 95;
|
|
||||||
--ion-color-tertiary-contrast: #ffffff;
|
--ion-color-tertiary-contrast: #ffffff;
|
||||||
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-tertiary-shade: #1a3353;
|
--ion-color-tertiary-shade: #c2410c;
|
||||||
--ion-color-tertiary-tint: #354e6f;
|
--ion-color-tertiary-tint: #f97316;
|
||||||
|
|
||||||
/** success **/
|
--ion-color-success: #16a34a;
|
||||||
--ion-color-success: #2dd36f;
|
--ion-color-success-rgb: 22, 163, 74;
|
||||||
--ion-color-success-rgb: 45, 211, 111;
|
|
||||||
--ion-color-success-contrast: #ffffff;
|
--ion-color-success-contrast: #ffffff;
|
||||||
--ion-color-success-contrast-rgb: 255, 255, 255;
|
--ion-color-success-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-success-shade: #28ba62;
|
--ion-color-success-shade: #15803d;
|
||||||
--ion-color-success-tint: #42d77d;
|
--ion-color-success-tint: #22c55e;
|
||||||
|
|
||||||
/** warning **/
|
--ion-color-warning: #f59e0b;
|
||||||
--ion-color-warning: #ffc409;
|
--ion-color-warning-rgb: 245, 158, 11;
|
||||||
--ion-color-warning-rgb: 255, 196, 9;
|
--ion-color-warning-contrast: #1f2937;
|
||||||
--ion-color-warning-contrast: #000000;
|
--ion-color-warning-contrast-rgb: 31, 41, 55;
|
||||||
--ion-color-warning-contrast-rgb: 0, 0, 0;
|
--ion-color-warning-shade: #d97706;
|
||||||
--ion-color-warning-shade: #e0ad08;
|
--ion-color-warning-tint: #fbbf24;
|
||||||
--ion-color-warning-tint: #ffca22;
|
|
||||||
|
|
||||||
/** danger **/
|
--ion-color-danger: #dc2626;
|
||||||
--ion-color-danger: #eb445a;
|
--ion-color-danger-rgb: 220, 38, 38;
|
||||||
--ion-color-danger-rgb: 235, 68, 90;
|
|
||||||
--ion-color-danger-contrast: #ffffff;
|
--ion-color-danger-contrast: #ffffff;
|
||||||
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
--ion-color-danger-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-danger-shade: #cf3c4f;
|
--ion-color-danger-shade: #b91c1c;
|
||||||
--ion-color-danger-tint: #ed576b;
|
--ion-color-danger-tint: #ef4444;
|
||||||
|
|
||||||
/** dark **/
|
--ion-color-dark: #111827;
|
||||||
--ion-color-dark: #222428;
|
--ion-color-dark-rgb: 17, 24, 39;
|
||||||
--ion-color-dark-rgb: 34, 36, 40;
|
|
||||||
--ion-color-dark-contrast: #ffffff;
|
--ion-color-dark-contrast: #ffffff;
|
||||||
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
--ion-color-dark-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-dark-shade: #1e2023;
|
--ion-color-dark-shade: #030712;
|
||||||
--ion-color-dark-tint: #383a3e;
|
--ion-color-dark-tint: #374151;
|
||||||
|
|
||||||
/** medium **/
|
--ion-color-medium: #6b7280;
|
||||||
--ion-color-medium: #92949c;
|
--ion-color-medium-rgb: 107, 114, 128;
|
||||||
--ion-color-medium-rgb: 146, 148, 156;
|
|
||||||
--ion-color-medium-contrast: #ffffff;
|
--ion-color-medium-contrast: #ffffff;
|
||||||
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
--ion-color-medium-contrast-rgb: 255, 255, 255;
|
||||||
--ion-color-medium-shade: #808289;
|
--ion-color-medium-shade: #4b5563;
|
||||||
--ion-color-medium-tint: #9d9fa6;
|
--ion-color-medium-tint: #9ca3af;
|
||||||
|
|
||||||
/** light **/
|
--ion-color-light: #f3f4f6;
|
||||||
--ion-color-light: #f4f5f8;
|
--ion-color-light-rgb: 243, 244, 246;
|
||||||
--ion-color-light-rgb: 244, 245, 248;
|
--ion-color-light-contrast: #111827;
|
||||||
--ion-color-light-contrast: #000000;
|
--ion-color-light-contrast-rgb: 17, 24, 39;
|
||||||
--ion-color-light-contrast-rgb: 0, 0, 0;
|
--ion-color-light-shade: #e5e7eb;
|
||||||
--ion-color-light-shade: #d7d8da;
|
--ion-color-light-tint: #f9fafb;
|
||||||
--ion-color-light-tint: #f5f6f9;
|
|
||||||
|
|
||||||
/* Custom Background */
|
--ion-background-color: #f6f7fb;
|
||||||
--color-bg: #eef7fb;
|
--ion-text-color: #111827;
|
||||||
--ion-background-color: #eef7fb;
|
--ion-border-color: #e5e7eb;
|
||||||
--ion-background-color-rgb: 238, 247, 251;
|
|
||||||
--ion-text-color: #1e3a5f;
|
|
||||||
--ion-text-color-rgb: 30, 58, 95;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
body {
|
||||||
* App-wide styles
|
background:
|
||||||
*/
|
radial-gradient(
|
||||||
* {
|
circle at top left,
|
||||||
font-family:
|
rgba(168, 85, 247, 0.12),
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
transparent 34%
|
||||||
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
),
|
||||||
}
|
linear-gradient(180deg, #fbf7ff 0%, #f2ecfb 100%);
|
||||||
|
|
||||||
ion-content {
|
|
||||||
--background: var(--color-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-toolbar {
|
ion-toolbar {
|
||||||
--background: var(--color-bg);
|
--background: #ffffff;
|
||||||
--color: #1e3a5f;
|
--color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.habit-card,
|
||||||
|
.stats-card,
|
||||||
|
.empty-state-card,
|
||||||
|
.inline-message,
|
||||||
|
.auth-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #ebeef4;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card.compact {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1,
|
||||||
|
.auth-hero h1,
|
||||||
|
.empty-state-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card p,
|
||||||
|
.auth-hero p,
|
||||||
|
.inline-message p,
|
||||||
|
.empty-state-card p,
|
||||||
|
.stats-card p,
|
||||||
|
.habit-copy p {
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy h3,
|
||||||
|
.stats-card h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row,
|
||||||
|
.stats-card-footer,
|
||||||
|
.stats-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row span,
|
||||||
|
.stats-card-footer span,
|
||||||
|
.stats-card-header p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-metrics-row ion-icon {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card,
|
||||||
|
.inline-message {
|
||||||
|
padding: 28px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(109, 40, 217, 0.1);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 32px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card ion-card-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form,
|
||||||
|
.auth-message-block,
|
||||||
|
.habit-form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input-item {
|
||||||
|
--background: #f8fafc;
|
||||||
|
--border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feedback {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(107, 114, 128, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider span {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-auth-button,
|
||||||
|
.apple-auth-button {
|
||||||
|
--border-radius: 18px;
|
||||||
|
--border-color: rgba(17, 24, 39, 0.08);
|
||||||
|
--background: #ffffff;
|
||||||
|
--background-hover: #ffffff;
|
||||||
|
--background-activated: #f8fafc;
|
||||||
|
--color: var(--ion-color-dark);
|
||||||
|
--box-shadow: none;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-auth-button {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--ion-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-dot {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-color-dot.selected {
|
||||||
|
border-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-streak-badge {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header ion-toolbar {
|
||||||
|
--background: transparent;
|
||||||
|
--border-color: transparent;
|
||||||
|
--padding-start: 12px;
|
||||||
|
--padding-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-content {
|
||||||
|
--background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-shell {
|
||||||
|
padding-top: calc(var(--ion-safe-area-top) + 10px);
|
||||||
|
padding-bottom: 32px;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-icon-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.86);
|
||||||
|
--background-hover: rgba(255, 255, 255, 0.96);
|
||||||
|
--background-activated: rgba(255, 255, 255, 0.96);
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: 0 10px 24px rgba(109, 40, 217, 0.08);
|
||||||
|
--color: #17121f;
|
||||||
|
--padding-start: 0;
|
||||||
|
--padding-end: 0;
|
||||||
|
--padding-top: 0;
|
||||||
|
--padding-bottom: 0;
|
||||||
|
width: 46px;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-inline-start: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-icon-button::part(native) {
|
||||||
|
width: 46px;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habits-hero {
|
||||||
|
padding: 6px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habits-hero-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(34, 23, 54, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3rem, 8vw, 4.2rem);
|
||||||
|
line-height: 0.94;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #110d18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title-wrap span {
|
||||||
|
font-size: clamp(2.2rem, 7vw, 3.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(26, 16, 38, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-add-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.86);
|
||||||
|
--background-hover: rgba(255, 255, 255, 0.96);
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: 0 14px 30px rgba(109, 40, 217, 0.1);
|
||||||
|
--color: var(--ion-color-primary);
|
||||||
|
--padding-start: 0;
|
||||||
|
--padding-end: 0;
|
||||||
|
--padding-top: 0;
|
||||||
|
--padding-bottom: 0;
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-add-button::part(native) {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-track {
|
||||||
|
margin-top: 20px;
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(109, 40, 217, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #5b21b6 0%, #c084fc 100%);
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-progress-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(43, 31, 58, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-routine-panel,
|
||||||
|
.home-summary-card,
|
||||||
|
.home-inline-card,
|
||||||
|
.home-empty-state-card,
|
||||||
|
.habit-card--editorial {
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
box-shadow: 0 18px 40px rgba(106, 62, 149, 0.08);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-routine-panel {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-habit-stack {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial.is-complete {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-check-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(109, 40, 217, 0.08);
|
||||||
|
color: rgba(109, 40, 217, 0.72);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-check-button.is-complete {
|
||||||
|
background: rgba(109, 40, 217, 0.16);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy-topline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-copy-topline .habit-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
min-height: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-tap-area {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-card-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: rgba(52, 41, 66, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-metrics-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card--editorial .habit-metrics-row span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-delete-button {
|
||||||
|
--color: rgba(86, 68, 106, 0.72);
|
||||||
|
--padding-start: 6px;
|
||||||
|
--padding-end: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-skeleton {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card-skeleton ion-skeleton-text {
|
||||||
|
margin: 0;
|
||||||
|
--background: rgba(109, 40, 217, 0.08);
|
||||||
|
--background-rgb: 109, 40, 217;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-check,
|
||||||
|
.habit-skeleton-action {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-title {
|
||||||
|
width: min(42vw, 160px);
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-color-pill {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
min-width: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-description {
|
||||||
|
width: min(58vw, 220px);
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-metric {
|
||||||
|
width: 84px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-skeleton-metric.short {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-complete-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section-label {
|
||||||
|
margin: 0 4px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(66, 50, 84, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card {
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ring {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-ring-inner {
|
||||||
|
width: 66px;
|
||||||
|
height: 66px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1d1430;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-copy h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(52, 41, 66, 0.7);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metrics span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(42, 30, 56, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty-state-card {
|
||||||
|
border-radius: 26px;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-empty-state-button {
|
||||||
|
--border-radius: 999px;
|
||||||
|
--box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-shell {
|
||||||
|
gap: 20px;
|
||||||
|
padding-bottom: calc(24px + var(--ion-safe-area-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 16px 38px rgba(71, 45, 108, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image-wrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
background: #e9e1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(17, 24, 39, 0) 0%,
|
||||||
|
rgba(17, 24, 39, 0.3) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(55, 65, 81, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(109, 40, 217, 0.1);
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip.subtle {
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-highlight-chip ion-icon,
|
||||||
|
.account-highlight-chip svg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-details-card {
|
||||||
|
gap: 18px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item {
|
||||||
|
--background: #f7f5fb;
|
||||||
|
--border-radius: 20px;
|
||||||
|
--padding-start: 16px;
|
||||||
|
--padding-end: 16px;
|
||||||
|
--inner-padding-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-item p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(22, 163, 74, 0.12);
|
||||||
|
color: var(--ion-color-success);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-trust-row p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(55, 65, 81, 0.74);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-primary-button,
|
||||||
|
.account-secondary-button {
|
||||||
|
--border-radius: 18px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-primary-button {
|
||||||
|
--background: var(--ion-color-primary);
|
||||||
|
--box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-secondary-button {
|
||||||
|
--border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.home-summary-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hero-image-wrap {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
export type Department =
|
|
||||||
| 'Engineering'
|
|
||||||
| 'Sales'
|
|
||||||
| 'Operations'
|
|
||||||
| 'Executive Board';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string; // Securely generated hash in production
|
|
||||||
department: Department;
|
|
||||||
joinedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TelemetryLog {
|
|
||||||
id: string;
|
|
||||||
userId: string; // References User.id
|
|
||||||
date: string; // YYYY-MM-DD
|
|
||||||
sleepScore: number; // 0 - 100
|
|
||||||
stressIndex: number; // 1 - 10 (derived from HRV metrics)
|
|
||||||
activeMinutes: number; // 0 - 120+
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AggregatedTrendPoint {
|
|
||||||
date: string;
|
|
||||||
avgSleepScore: number;
|
|
||||||
avgStressIndex: number;
|
|
||||||
avgActiveMinutes: number;
|
|
||||||
poorSleepPercentage: number; // sleepScore < 60
|
|
||||||
highStressPercentage: number; // stressIndex >= 7
|
|
||||||
cohortSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CohortAnalyticsSummary {
|
|
||||||
department: Department;
|
|
||||||
trends: AggregatedTrendPoint[];
|
|
||||||
overallAvgSleep: number;
|
|
||||||
overallAvgStress: number;
|
|
||||||
overallAvgActive: number;
|
|
||||||
cohortSize: number;
|
|
||||||
insight: string;
|
|
||||||
insightSeverity: 'success' | 'warning' | 'danger' | 'medium';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsResponse {
|
|
||||||
success: boolean;
|
|
||||||
data?: CohortAnalyticsSummary;
|
|
||||||
error?: string;
|
|
||||||
errorCode?: 'INSUFFICIENT_COHORT_SIZE' | 'NO_DATA';
|
|
||||||
details?: string;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
export type CheckupMood = 'Great' | 'Okay' | 'Low' | 'Unwell';
|
|
||||||
|
|
||||||
export interface CheckupEntry {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
mood: CheckupMood;
|
|
||||||
energy: number; // 0-100
|
|
||||||
symptoms: string;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MealType = 'Breakfast' | 'Lunch' | 'Dinner' | 'Snack';
|
|
||||||
|
|
||||||
export interface DietEntry {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
mealType: MealType;
|
|
||||||
waterCups: number;
|
|
||||||
produceServings: number;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SleepQuality = 'Restful' | 'Okay' | 'Restless';
|
|
||||||
|
|
||||||
export interface SleepEntry {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
hours: number;
|
|
||||||
quality: SleepQuality;
|
|
||||||
interruptions: number;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActivityType = 'checkup' | 'diet' | 'sleep';
|
|
||||||
|
|
||||||
export interface ActivityEntry {
|
|
||||||
id: string;
|
|
||||||
type: ActivityType;
|
|
||||||
date: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WellbeingSummary {
|
|
||||||
score: number; // 0-100
|
|
||||||
scoreMessage: string;
|
|
||||||
scoreProgress: number; // 0-1
|
|
||||||
sleepHours: number; // last entry or average
|
|
||||||
sleepQuality: SleepQuality | 'N/A';
|
|
||||||
hydrationCups: number; // today's total
|
|
||||||
produceServings: number; // today's total
|
|
||||||
streakDays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalInsight {
|
|
||||||
id: string;
|
|
||||||
type: 'positive' | 'warning' | 'info';
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
export { Style };
|
export { Style };
|
||||||
``
|
|
||||||
const _origin = (() => {
|
const _origin = (() => {
|
||||||
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
"esModuleInterop": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
|
|||||||
+2
-3
@@ -5,12 +5,11 @@ const injectRouterBasename: Plugin = {
|
|||||||
name: 'inject-router-basename',
|
name: 'inject-router-basename',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
transform(code, id) {
|
transform(code, id) {
|
||||||
if (!/\.[jt]sx$/.test(id)) return null;
|
if (!/\.[jt]sx$/.test(id)) return;
|
||||||
const next = code.replace(
|
return code.replace(
|
||||||
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
|
||||||
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
|
||||||
);
|
);
|
||||||
return next === code ? null : next;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user