Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes c279e86750 build: 55712324-398e-4334-a254-fc30c84b2e47 2026-05-31 14:22:23 +00:00
56 changed files with 8853 additions and 7949 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "io.ionic.starter"
applicationId "com.habitmode.llemba.com"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">healthcare</string>
<string name="title_activity_main">healthcare</string>
<string name="app_name">app</string>
<string name="title_activity_main">app</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
+3 -3
View File
@@ -1,13 +1,13 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.healthcare.app',
appName: 'healthcare',
appId: 'com.habitmode.llemba.com',
appName: 'HabitMode',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
skipNativeAuth: false,
providers: [],
providers: ["google.com", "apple.com"],
},
},
};
+22
View File
@@ -55,6 +55,13 @@ workflows:
if [ -n "$CM_DEBUG_KEYSTORE" ]; then
echo "$CM_DEBUG_KEYSTORE" | base64 --decode > /tmp/debug-keystore.p12
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
script: |
cd android
@@ -68,6 +75,21 @@ workflows:
else
./gradlew assembleDebug --no-daemon
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:
- android/app/build/outputs/apk/debug/app-debug.apk
+2 -2
View File
@@ -308,7 +308,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.healthcare.app;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.healthcare.app;
PRODUCT_BUNDLE_IDENTIFIER = com.habitmode.llemba.com;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+10 -4
View File
@@ -1,6 +1,12 @@
<?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">
<plist version="1.0">
<dict>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>aps-environment</key>
<string>production</string>
</dict>
</plist>
</plist>
+22 -23
View File
@@ -1,5 +1,7 @@
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,43 +9,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
FirebaseApp.configure()
return true
}
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 applicationWillResignActive(_ 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 applicationDidEnterBackground(_ 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 applicationWillEnterForeground(_ 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 applicationDidBecomeActive(_ application: UIApplication) {}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func applicationWillTerminate(_ application: UIApplication) {}
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)
}
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)
}
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)
}
}
+2 -4
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>healthcare</string>
<string>HabitMode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -50,11 +50,9 @@
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.healthcare.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.healthcare.app</string>
<string>com.googleusercontent.apps.1097913965629-pf48952i0uv9shgvie3dq11s18sd0t2p</string>
</array>
</dict>
</array>
+3404
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -20,11 +20,10 @@
"@capacitor/keyboard": "8.0.3",
"@capacitor/push-notifications": "^8.1.1",
"@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-router": "^8.5.0",
"@supabase/supabase-js": "^2.108.1",
"date-fns": "^4.4.0",
"@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
"katex": "^0.16.0",
+11 -29
View File
@@ -1,22 +1,12 @@
import React from 'react';
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 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';
/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.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/float-elements.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/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';
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>
<IonReactRouter>
<IonRouterOutlet>
<Route exact path="/auth" component={Auth} />
<Route exact path="/home" component={Home} />
<Route exact path="/checkups" component={Checkups} />
<Route exact path="/diet" component={Diet} />
<Route exact path="/sleep" component={Sleep} />
<Route exact path="/insights" component={Insights} />
<Route exact path="/stats" component={Stats} />
<Route exact path="/habit/:id" component={HabitDetail} />
<Route exact path="/account" component={Account} />
<Route exact path="/" render={() => <Redirect to="/home" />} />
</IonRouterOutlet>
</IonReactRouter>
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File
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

+77
View File
@@ -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;
+32
View File
@@ -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;
+108
View File
@@ -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;
+153
View File
@@ -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;
+69
View File
@@ -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;
+80
View File
@@ -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;
+47
View File
@@ -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>
);
};
-132
View File
@@ -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>
);
};
-435
View File
@@ -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>
);
};
-217
View File
@@ -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;
-195
View File
@@ -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>
);
};
-111
View File
@@ -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;
-119
View File
@@ -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',
}}
>
Todays 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;
+273
View File
@@ -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
+196
View File
@@ -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;
+398
View File
@@ -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;
-339
View File
@@ -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;
-376
View File
@@ -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 todays 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;
+623
View File
@@ -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&apos;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;
+465 -214
View File
@@ -1,239 +1,490 @@
import React, { useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonPage,
IonHeader,
IonIcon,
IonText,
useIonViewWillEnter,
IonPage,
IonRefresher,
IonRefresherContent,
IonSkeletonText,
IonToolbar,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import {
heartCircleOutline,
medkitOutline,
restaurantOutline,
moonOutline,
chevronForwardOutline,
addOutline,
analyticsOutline,
checkmarkCircle,
personCircleOutline,
sparklesOutline,
} from 'ionicons/icons';
import WellbeingScoreCard from '../components/health/WellbeingScoreCard';
import MetricMiniCard from '../components/health/MetricMiniCard';
import RecentActivityList from '../components/health/RecentActivityList';
import {
getWellbeingSummary,
getRecentActivity,
} from '../services/localHealthStore';
import { WellbeingSummary, ActivityEntry } from '../types/health';
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
import { Redirect, useHistory } from 'react-router-dom';
import type { Tables } from '../database.types';
import { supabase } from '../supabase';
import EmptyState from '../components/EmptyState';
import HabitCard from '../components/HabitCard';
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 history = useHistory();
const [summary, setSummary] = useState<WellbeingSummary>(
getWellbeingSummary()
);
const [recentEntries, setRecentEntries] =
useState<ActivityEntry[]>(getRecentActivity());
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
const [loading, setLoading] = useState(true);
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 = () => {
setSummary(getWellbeingSummary());
setRecentEntries(getRecentActivity());
const showError = (message: string) => {
setError(message);
window.setTimeout(() => setError(''), 4000);
};
useIonViewWillEnter(() => {
refreshData();
});
const registerPushNotifications = useCallback(
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 (
<IonPage style={{ background: '#eef7fb' }}>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(56,189,248,0.16) 0%, #eef7fb 58%, #f8fbfd 100%)',
}}
>
{/* Top Content Row */}
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 12px',
display: 'flex',
alignItems: 'center',
gap: '12px',
<IonPage>
<IonHeader className="ion-no-border home-header">
<IonToolbar>
<IonButtons slot="end">
<IonButton
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();
}}
>
<div
style={{
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
style={{
background: '#ffffff',
borderRadius: '999px',
padding: '8px 12px',
fontSize: '12px',
fontWeight: '700',
color: '#0369a1',
}}
>
Local
</div>
</div>
<IonRefresherContent />
</IonRefresher>
{/* Score Widget */}
<WellbeingScoreCard
score={summary.score}
message={summary.scoreMessage}
progress={summary.scoreProgress}
/>
{/* Metric Row */}
<div
style={{
margin: '0 20px 12px',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
}}
>
<MetricMiniCard
label="Sleep"
value={summary.sleepHours.toString()}
helper="hrs"
progress={summary.sleepHours / 8}
icon={moonOutline}
/>
<MetricMiniCard
label="Water"
value={summary.hydrationCups.toString()}
helper="cups"
progress={summary.hydrationCups / 8}
icon={restaurantOutline}
/>
</div>
{/* Action Strip */}
<div
style={{
display: 'flex',
gap: '8px',
overflowX: 'auto',
padding: '0 20px 20px',
msOverflowStyle: 'none',
scrollbarWidth: 'none',
}}
>
{[
{ label: 'Checkup', icon: medkitOutline, path: '/checkups' },
{ label: 'Diet', icon: restaurantOutline, path: '/diet' },
{ label: 'Sleep', icon: moonOutline, path: '/sleep' },
].map((action) => (
<div
key={action.label}
onClick={() => history.push(action.path)}
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',
}}
<div className="dashboard-shell home-shell ion-padding">
<section className="habits-hero">
<div className="habits-hero-top">
<div>
<p className="hero-kicker">Habits ({habits.length})</p>
<div className="hero-title-wrap">
<h1>Daily</h1>
<span>Weekly</span>
</div>
</div>
<IonButton
className="hero-add-button"
fill="clear"
onClick={() => setShowCreateModal(true)}
>
{action.label}
</span>
<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 className="hero-progress-meta">
<span>{totalCompletedToday} completed today</span>
<span>{Math.round(completionRatio * 100)}% completed</span>
</div>
</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"
/>
<div className="habit-card-main">
<div className="habit-copy">
<div className="habit-copy-topline">
<IonSkeletonText
animated
className="habit-skeleton-title"
/>
<IonSkeletonText
animated
className="habit-skeleton-color-pill"
/>
</div>
<IonSkeletonText
animated
className="habit-skeleton-description"
/>
<div className="habit-metrics-row">
<IonSkeletonText
animated
className="habit-skeleton-metric"
/>
<IonSkeletonText
animated
className="habit-skeleton-metric short"
/>
</div>
</div>
</div>
<IonSkeletonText
animated
className="habit-skeleton-action"
/>
</div>
))}
</div>
</div>
) : error ? (
<div className="inline-message error-card home-inline-card">
<h3>Couldn&apos;t load your habits</h3>
<p>{error}</p>
<IonButton onClick={loadDashboard}>Try Again</IonButton>
</div>
) : habits.length === 0 ? (
<div className="home-routine-panel">
<EmptyState
title="Start your first routine"
message="Create one small daily habit and begin building your streak story today."
actionLabel="Create First Habit"
onAction={() => setShowCreateModal(true)}
/>
</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>
{/* Section Header */}
<div
style={{
margin: '4px 20px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
}}
>
<span
style={{ fontSize: '17px', fontWeight: '700', color: '#1e3a5f' }}
>
Recent Activity
</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>
{/* Recent Activity List */}
<RecentActivityList
entries={recentEntries}
onCreate={() => history.push('/checkups')}
onSeeInsights={() => history.push('/insights')}
<HabitFormModal
isOpen={showCreateModal}
onDismiss={() => setShowCreateModal(false)}
onSubmit={createHabit}
/>
<div style={{ height: '20px' }} />
</IonContent>
</IonPage>
);
-204
View File
@@ -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;
-351
View File
@@ -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;
+174
View File
@@ -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&apos;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;
-255
View File
@@ -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;
-39
View File
@@ -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);
};
-247
View File
@@ -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);
};
-239
View File
@@ -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;
};
+7
View File
@@ -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
View File
@@ -1,99 +1,916 @@
/* Ionic CSS Variables — customise to match your app's brand */
:root {
/** primary **/
--ion-color-primary: #0369a1;
--ion-color-primary-rgb: 3, 105, 161;
--ion-color-primary: #6d28d9;
--ion-color-primary-rgb: 109, 40, 217;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #035c8e;
--ion-color-primary-tint: #1c78aa;
--ion-color-primary-shade: #5b21b6;
--ion-color-primary-tint: #7c3aed;
/** secondary **/
--ion-color-secondary: #38bdf8;
--ion-color-secondary-rgb: 56, 189, 248;
--ion-color-secondary: #0f766e;
--ion-color-secondary-rgb: 15, 118, 110;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #31a6da;
--ion-color-secondary-tint: #4cc4f9;
--ion-color-secondary-shade: #115e59;
--ion-color-secondary-tint: #14b8a6;
/** tertiary **/
--ion-color-tertiary: #1e3a5f;
--ion-color-tertiary-rgb: 30, 58, 95;
--ion-color-tertiary: #ea580c;
--ion-color-tertiary-rgb: 234, 88, 12;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #1a3353;
--ion-color-tertiary-tint: #354e6f;
--ion-color-tertiary-shade: #c2410c;
--ion-color-tertiary-tint: #f97316;
/** success **/
--ion-color-success: #2dd36f;
--ion-color-success-rgb: 45, 211, 111;
--ion-color-success: #16a34a;
--ion-color-success-rgb: 22, 163, 74;
--ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #28ba62;
--ion-color-success-tint: #42d77d;
--ion-color-success-shade: #15803d;
--ion-color-success-tint: #22c55e;
/** warning **/
--ion-color-warning: #ffc409;
--ion-color-warning-rgb: 255, 196, 9;
--ion-color-warning-contrast: #000000;
--ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-warning-shade: #e0ad08;
--ion-color-warning-tint: #ffca22;
--ion-color-warning: #f59e0b;
--ion-color-warning-rgb: 245, 158, 11;
--ion-color-warning-contrast: #1f2937;
--ion-color-warning-contrast-rgb: 31, 41, 55;
--ion-color-warning-shade: #d97706;
--ion-color-warning-tint: #fbbf24;
/** danger **/
--ion-color-danger: #eb445a;
--ion-color-danger-rgb: 235, 68, 90;
--ion-color-danger: #dc2626;
--ion-color-danger-rgb: 220, 38, 38;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #cf3c4f;
--ion-color-danger-tint: #ed576b;
--ion-color-danger-shade: #b91c1c;
--ion-color-danger-tint: #ef4444;
/** dark **/
--ion-color-dark: #222428;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark: #111827;
--ion-color-dark-rgb: 17, 24, 39;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
--ion-color-dark-shade: #030712;
--ion-color-dark-tint: #374151;
/** medium **/
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
--ion-color-medium: #6b7280;
--ion-color-medium-rgb: 107, 114, 128;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #808289;
--ion-color-medium-tint: #9d9fa6;
--ion-color-medium-shade: #4b5563;
--ion-color-medium-tint: #9ca3af;
/** light **/
--ion-color-light: #f4f5f8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
--ion-color-light: #f3f4f6;
--ion-color-light-rgb: 243, 244, 246;
--ion-color-light-contrast: #111827;
--ion-color-light-contrast-rgb: 17, 24, 39;
--ion-color-light-shade: #e5e7eb;
--ion-color-light-tint: #f9fafb;
/* Custom Background */
--color-bg: #eef7fb;
--ion-background-color: #eef7fb;
--ion-background-color-rgb: 238, 247, 251;
--ion-text-color: #1e3a5f;
--ion-text-color-rgb: 30, 58, 95;
--ion-background-color: #f6f7fb;
--ion-text-color: #111827;
--ion-border-color: #e5e7eb;
}
/*
* App-wide styles
*/
* {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
ion-content {
--background: var(--color-bg);
body {
background:
radial-gradient(
circle at top left,
rgba(168, 85, 247, 0.12),
transparent 34%
),
linear-gradient(180deg, #fbf7ff 0%, #f2ecfb 100%);
}
ion-toolbar {
--background: var(--color-bg);
--color: #1e3a5f;
--background: #ffffff;
--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;
}
}
-49
View File
@@ -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;
}
-61
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { StatusBar, Style } from '@capacitor/status-bar';
export { Style };
``
const _origin = (() => {
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
return window.location.origin;
+1 -1
View File
@@ -7,7 +7,7 @@
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
+2 -3
View File
@@ -5,12 +5,11 @@ const injectRouterBasename: Plugin = {
name: 'inject-router-basename',
enforce: 'pre',
transform(code, id) {
if (!/\.[jt]sx$/.test(id)) return null;
const next = code.replace(
if (!/\.[jt]sx$/.test(id)) return;
return code.replace(
/<IonReactRouter(?![^>]*basename)([^>]*)>/g,
'<IonReactRouter$1 basename={import.meta.env.BASE_URL}>',
);
return next === code ? null : next;
},
};
+1779 -3757
View File
File diff suppressed because it is too large Load Diff