diff --git a/frontend/src/components/MobileBottomNav.tsx b/frontend/src/components/MobileBottomNav.tsx new file mode 100644 index 0000000..a1a8311 --- /dev/null +++ b/frontend/src/components/MobileBottomNav.tsx @@ -0,0 +1,186 @@ +import { + mdiAccountCircleOutline, + mdiChartTimelineVariant, + mdiHeartPulse, + mdiLeafCircle, + mdiMenu, + mdiTargetVariant, + mdiViewDashboardOutline, + mdiWaterOutline, +} from '@mdi/js' +import Link from 'next/link' +import { useRouter } from 'next/router' +import React, { useEffect, useState } from 'react' +import { hasCompletedApexOnboarding } from '../helpers/apexOnboarding' +import { hasPermission } from '../helpers/userPermissions' +import { useAppSelector } from '../stores/hooks' +import BaseIcon from './BaseIcon' + +type Props = { + onMenuClick: () => void + isMenuOpen: boolean +} + +type MobileNavItem = { + key: string + href?: string + label: string + icon: string + matchers: string[] + onClick?: () => void +} + +function isRouteActive(pathname: string, matchers: string[]) { + return matchers.some( + (matcher) => pathname === matcher || pathname.startsWith(`${matcher}/`), + ) +} + +function getItemClass(active: boolean, isPulse: boolean) { + if (isPulse) { + return active + ? 'bg-[#4A6741] text-white shadow-md dark:bg-emerald-700 dark:text-white' + : 'bg-[#EDF4EA] text-[#4A6741] dark:bg-dark-800 dark:text-emerald-300' + } + + return active + ? 'bg-[#EDF4EA] text-[#4A6741] dark:bg-dark-800 dark:text-emerald-300' + : 'text-[#7E725E] dark:text-slate-400' +} + +export default function MobileBottomNav({ onMenuClick, isMenuOpen }: Props) { + const router = useRouter() + const { currentUser } = useAppSelector((state) => state.auth) + const [onboardingComplete, setOnboardingComplete] = useState(false) + + useEffect(() => { + setOnboardingComplete(hasCompletedApexOnboarding(currentUser?.id)) + }, [currentUser?.id]) + + const focusItem: MobileNavItem = + onboardingComplete && hasPermission(currentUser, 'READ_GOALS') + ? { + key: 'goals', + href: '/goals/goals-list', + label: 'Goals', + icon: mdiTargetVariant, + matchers: ['/goals'], + } + : { + key: 'setup', + href: '/onboarding', + label: 'Setup', + icon: mdiAccountCircleOutline, + matchers: ['/onboarding'], + } + + let trackingItem: MobileNavItem = { + key: 'today', + href: '/daily-pulse', + label: 'Today', + icon: mdiChartTimelineVariant, + matchers: ['/daily-pulse'], + } + + if (hasPermission(currentUser, 'READ_HEALTH_METRICS')) { + trackingItem = { + key: 'vitals', + href: '/health_metrics/health_metrics-list', + label: 'Vitals', + icon: mdiChartTimelineVariant, + matchers: ['/health_metrics'], + } + } else if (hasPermission(currentUser, 'READ_CONDITIONS')) { + trackingItem = { + key: 'care', + href: '/conditions/conditions-list', + label: 'Care', + icon: mdiLeafCircle, + matchers: ['/conditions'], + } + } else if (hasPermission(currentUser, 'READ_HYDRATION_LOGS')) { + trackingItem = { + key: 'hydrate', + href: '/hydration_logs/hydration_logs-list', + label: 'Hydrate', + icon: mdiWaterOutline, + matchers: ['/hydration_logs'], + } + } + + const navItems: MobileNavItem[] = [ + { + key: 'home', + href: '/dashboard', + label: 'Home', + icon: mdiViewDashboardOutline, + matchers: ['/dashboard'], + }, + { + key: 'pulse', + href: '/daily-pulse', + label: 'Pulse', + icon: mdiHeartPulse, + matchers: ['/daily-pulse'], + }, + focusItem, + trackingItem, + { + key: 'menu', + label: 'Menu', + icon: mdiMenu, + matchers: [], + onClick: onMenuClick, + }, + ] + + return ( +
+
+ {navItems.map((item) => { + const isActive = + item.key === 'menu' + ? isMenuOpen + : isRouteActive(router.pathname, item.matchers) + const isPulse = item.key === 'pulse' + const sharedClassName = [ + 'flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-1 py-2 text-[11px] font-semibold transition-all duration-150', + getItemClass(isActive, isPulse), + ].join(' ') + const content = ( + <> + + {item.label} + + ) + + if (item.href) { + return ( + + {content} + + ) + } + + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 55559d2..dd9c442 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/helpers/apexOnboarding.ts b/frontend/src/helpers/apexOnboarding.ts new file mode 100644 index 0000000..85da7f8 --- /dev/null +++ b/frontend/src/helpers/apexOnboarding.ts @@ -0,0 +1,88 @@ +export type ApexOnboardingRecordIds = { + physicalId?: string; + lifestyleId?: string; + goalId?: string; + conditionId?: string; +}; + +export type ApexOnboardingDraft> = { + version: number; + lastSavedAt?: string; + completedAt?: string; + form?: Partial; + recordIds?: ApexOnboardingRecordIds; +}; + +const APEX_ONBOARDING_VERSION = 1; + +export function getApexOnboardingStorageKey(userId?: string | null) { + return userId + ? `apex-brain:onboarding:v${APEX_ONBOARDING_VERSION}:${userId}` + : ''; +} + +export function readApexOnboardingDraft< + T = Record, +>(userId?: string | null): ApexOnboardingDraft | null { + if (typeof window === 'undefined' || !userId) { + return null; + } + + const storageKey = getApexOnboardingStorageKey(userId); + + try { + const rawValue = window.localStorage.getItem(storageKey); + + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + + if (!parsedValue || typeof parsedValue !== 'object') { + return null; + } + + return parsedValue as ApexOnboardingDraft; + } catch (error) { + console.error('Failed to read APEX BRAIN onboarding draft:', error); + return null; + } +} + +export function saveApexOnboardingDraft( + userId: string | null | undefined, + draft: Omit, 'version'>, +) { + if (typeof window === 'undefined' || !userId) { + return; + } + + const storageKey = getApexOnboardingStorageKey(userId); + const nextDraft: ApexOnboardingDraft = { + version: APEX_ONBOARDING_VERSION, + ...draft, + lastSavedAt: draft.lastSavedAt ?? new Date().toISOString(), + }; + + try { + window.localStorage.setItem(storageKey, JSON.stringify(nextDraft)); + } catch (error) { + console.error('Failed to save APEX BRAIN onboarding draft:', error); + } +} + +export function markApexOnboardingCompleted( + userId: string | null | undefined, + draft: Omit, 'version'>, +) { + saveApexOnboardingDraft(userId, { + ...draft, + completedAt: draft.completedAt ?? new Date().toISOString(), + }); +} + +export function hasCompletedApexOnboarding(userId?: string | null) { + const draft = readApexOnboardingDraft(userId); + return Boolean(draft?.completedAt); +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..0cc495b 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,6 +1,6 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' -import jwt from 'jsonwebtoken'; +import type { ReactNode } from 'react' +import { useEffect, useState } from 'react' +import jwt from 'jsonwebtoken' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' import menuNavBar from '../menuNavBar' @@ -9,66 +9,88 @@ import NavBar from '../components/NavBar' import NavBarItemPlain from '../components/NavBarItemPlain' import AsideMenu from '../components/AsideMenu' import FooterBar from '../components/FooterBar' +import MobileBottomNav from '../components/MobileBottomNav' import { useAppDispatch, useAppSelector } from '../stores/hooks' -import Search from '../components/Search'; +import Search from '../components/Search' import { useRouter } from 'next/router' -import {findMe, logoutUser} from "../stores/authSlice"; - -import {hasPermission} from "../helpers/userPermissions"; - +import { findMe, logoutUser } from '../stores/authSlice' +import { humanize } from '../helpers/humanize' +import { hasPermission } from '../helpers/userPermissions' type Props = { children: ReactNode - permission?: string - } -export default function LayoutAuthenticated({ - children, - - permission - -}: Props) { +function getMobilePageTitle(pathname: string) { + const knownRoutes = [ + { prefix: '/dashboard', title: 'Home' }, + { prefix: '/daily-pulse', title: 'Daily Pulse' }, + { prefix: '/onboarding', title: 'Onboarding' }, + { prefix: '/goals', title: 'Goals' }, + { prefix: '/health_metrics', title: 'Vitals' }, + { prefix: '/conditions', title: 'Care' }, + { prefix: '/hydration_logs', title: 'Hydration' }, + { prefix: '/mood_logs', title: 'Mood' }, + { prefix: '/sleep_logs', title: 'Sleep' }, + { prefix: '/profile', title: 'Profile' }, + ] + + const matchedRoute = knownRoutes.find((route) => pathname.startsWith(route.prefix)) + + if (matchedRoute) { + return matchedRoute.title + } + + const [firstSegment] = pathname.split('/').filter(Boolean) + + if (!firstSegment) { + return 'Home' + } + + return humanize(firstSegment.replace(/-/g, ' ').replace(/_/g, ' ')) +} + +export default function LayoutAuthenticated({ children, permission }: Props) { const dispatch = useAppDispatch() const router = useRouter() const { token, currentUser } = useAppSelector((state) => state.auth) - const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor) + const darkMode = useAppSelector((state) => state.style.darkMode) + const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) + const [isAsideLgActive, setIsAsideLgActive] = useState(false) + let localToken if (typeof window !== 'undefined') { - // Perform localStorage action localToken = localStorage.getItem('token') } const isTokenValid = () => { - const token = localStorage.getItem('token'); - if (!token) return; - const date = new Date().getTime() / 1000; - const data = jwt.decode(token); - if (!data) return; - return date < data.exp; - }; + const tokenValue = localStorage.getItem('token') + + if (!tokenValue) return + + const date = new Date().getTime() / 1000 + const data = jwt.decode(tokenValue) + + if (!data) return + + return date < data.exp + } useEffect(() => { - dispatch(findMe()); + dispatch(findMe()) if (!isTokenValid()) { - dispatch(logoutUser()); - router.push('/login'); + dispatch(logoutUser()) + router.push('/login') } - }, [token, localToken]); + }, [token, localToken]) - useEffect(() => { - if (!permission || !currentUser) return; + if (!permission || !currentUser) return - if (!hasPermission(currentUser, permission)) router.push('/error'); - }, [currentUser, permission]); - - - const darkMode = useAppSelector((state) => state.style.darkMode) - - const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) - const [isAsideLgActive, setIsAsideLgActive] = useState(false) + if (!hasPermission(currentUser, permission)) router.push('/error') + }, [currentUser, permission]) useEffect(() => { const handleRouteChangeStart = () => { @@ -78,22 +100,20 @@ export default function LayoutAuthenticated({ router.events.on('routeChangeStart', handleRouteChangeStart) - // If the component is unmounted, unsubscribe - // from the event with the `off` method: return () => { router.events.off('routeChangeStart', handleRouteChangeStart) } }, [router.events, dispatch]) - const layoutAsidePadding = 'xl:pl-60' + const mobilePageTitle = getMobilePageTitle(router.pathname) return (
+
+
+
+ APEX BRAIN +
+
+ {mobilePageTitle} +
+
+
setIsAsideLgActive(true)} > - +
@@ -122,7 +152,13 @@ export default function LayoutAuthenticated({ onAsideLgClose={() => setIsAsideLgActive(false)} /> {children} - Hand-crafted & Made with ❤️ +
+ Hand-crafted & Made with ❤️ +
+ setIsAsideMobileExpanded((currentValue) => !currentValue)} + />
) diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index ea74456..b561930 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -5,7 +5,17 @@ const menuAside: MenuAsideItem[] = [ { href: '/dashboard', icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', + label: 'Home', + }, + { + href: '/daily-pulse', + label: 'Daily Pulse', + icon: 'mdiHeartPulse' in icon ? icon['mdiHeartPulse' as keyof typeof icon] : icon.mdiChartTimelineVariant, + }, + { + href: '/onboarding', + label: 'Onboarding', + icon: icon.mdiAccount, }, { diff --git a/frontend/src/pages/daily-pulse.tsx b/frontend/src/pages/daily-pulse.tsx new file mode 100644 index 0000000..75964de --- /dev/null +++ b/frontend/src/pages/daily-pulse.tsx @@ -0,0 +1,1399 @@ +import { + mdiArrowTopRight, + mdiCalendarHeart, + mdiCheckCircleOutline, + mdiEmoticonHappyOutline, + mdiHeartPulse, + mdiScaleBathroom, + mdiTargetVariant, + mdiTrophyOutline, + mdiWaterOutline, + mdiWeatherNight, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import type { FormEvent, ReactElement } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import BaseIcon from '../components/BaseIcon'; +import FormField from '../components/FormField'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { humanize } from '../helpers/humanize'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type FeedbackState = { + color: 'success' | 'danger'; + message: string; +} | null; + +type MoodRow = { + mood_score: number | null; + energy_level: number | null; + anxiety_level: number | null; + emotion_tags_csv: string | null; + logged_at: string | null; +}; + +type SleepRow = { + bedtime: string | null; + final_wake_time: string | null; + total_sleep_minutes: number | null; + sleep_quality: number | null; + morning_energy: number | null; +}; + +type WeightRow = { + measured_at: string | null; + value_primary: number | string | null; + unit: string | null; + notes: string | null; +}; + +type GoalRow = { + id: string; + goal_type: string; + follow_up_answer: string | null; + target_date: string | null; +}; + +type ActivityEntry = { + id: string; + kind: 'hydration' | 'mood' | 'sleep' | 'weight'; + title: string; + detail: string | null; + happened_at: string | null; +}; + +type SummaryState = { + hydrationTotalMl: number; + hydrationLogsToday: number; + moodToday: MoodRow | null; + sleepLatest: SleepRow | null; + weightLatest: WeightRow | null; +}; + +type PulseFormState = { + hydrationLoggedAt: string; + volumeMl: string; + beverageType: string; + containerLabel: string; + countsTowardHydration: boolean; + moodLoggedAt: string; + moodScore: string; + moodTags: string; + energyLevel: string; + anxietyLevel: string; + journalEntry: string; + sleepBedtime: string; + sleepWakeTime: string; + sleepQuality: string; + morningEnergy: string; + sleepDisturbances: string; + weightMeasuredAt: string; + weightValue: string; + weightUnit: 'kg' | 'lb'; + weightNotes: string; +}; + +const WATER_GOAL_ML = 2000; +const DAILY_PULSE_DEVICE = 'APEX BRAIN Daily Pulse'; + +const activityRouteByKind: Record = { + hydration: '/hydration_logs/hydration_logs-view', + mood: '/mood_logs/mood_logs-view', + sleep: '/sleep_logs/sleep_logs-view', + weight: '/health_metrics/health_metrics-view', +}; + +const quickLinks = [ + { + href: '/hydration_logs/hydration_logs-list', + title: 'Hydration history', + description: 'Review every bottle, cup, and hydration pattern.', + icon: mdiWaterOutline, + }, + { + href: '/mood_logs/mood_logs-list', + title: 'Mood journal', + description: 'See emotional trends and coaching notes over time.', + icon: mdiEmoticonHappyOutline, + }, + { + href: '/sleep_logs/sleep_logs-list', + title: 'Sleep trends', + description: 'Open your bedtime, wake time, and recovery history.', + icon: mdiWeatherNight, + }, + { + href: '/goals/goals-list', + title: 'Goal vault', + description: 'Keep active goals aligned with the habits that matter.', + icon: mdiTargetVariant, + }, +]; + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function toLocalDateTimeValue(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function initialPulseForm(): PulseFormState { + const now = new Date(); + + return { + hydrationLoggedAt: toLocalDateTimeValue(now), + volumeMl: '', + beverageType: 'water', + containerLabel: '', + countsTowardHydration: true, + moodLoggedAt: toLocalDateTimeValue(now), + moodScore: '', + moodTags: '', + energyLevel: '', + anxietyLevel: '', + journalEntry: '', + sleepBedtime: '', + sleepWakeTime: '', + sleepQuality: '', + morningEnergy: '', + sleepDisturbances: '', + weightMeasuredAt: toLocalDateTimeValue(now), + weightValue: '', + weightUnit: 'kg', + weightNotes: '', + }; +} + +function escapeSqlLiteral(value: string) { + return value.replace(/'/g, "''"); +} + +async function runSql(sql: string): Promise { + const response = await axios.post('/sql', { sql }); + return Array.isArray(response.data?.rows) ? response.data.rows : []; +} + +function formatDateTime(value?: string | null) { + if (!value) { + return 'Not recorded yet'; + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(value)); +} + +function formatDate(value?: string | null) { + if (!value) { + return 'No target date'; + } + + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)); +} + +function formatMinutes(total?: number | null) { + if (!total || total <= 0) { + return 'Not logged yet'; + } + + const hours = Math.floor(total / 60); + const minutes = total % 60; + + if (!hours) { + return `${minutes} min`; + } + + return `${hours}h ${minutes}m`; +} + +function isToday(value?: string | null) { + if (!value) { + return false; + } + + const today = new Date(); + const candidate = new Date(value); + + return today.toDateString() === candidate.toDateString(); +} + +function hasRecentSleep(value?: string | null) { + if (!value) { + return false; + } + + const happenedAt = new Date(value).getTime(); + const now = Date.now(); + const hoursDiff = Math.abs(now - happenedAt) / (1000 * 60 * 60); + + return hoursDiff <= 36; +} + +function toIsoString(value: string) { + return new Date(value).toISOString(); +} + +function DailyPulsePage() { + const { currentUser } = useAppSelector((state) => state.auth); + const [form, setForm] = useState(initialPulseForm); + const [feedback, setFeedback] = useState(null); + const [summary, setSummary] = useState({ + hydrationTotalMl: 0, + hydrationLogsToday: 0, + moodToday: null, + sleepLatest: null, + weightLatest: null, + }); + const [goals, setGoals] = useState([]); + const [recentActivity, setRecentActivity] = useState([]); + const [loading, setLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + const permissions = useMemo( + () => ({ + hydrationRead: hasPermission(currentUser, 'READ_HYDRATION_LOGS'), + hydrationCreate: hasPermission(currentUser, 'CREATE_HYDRATION_LOGS'), + moodRead: hasPermission(currentUser, 'READ_MOOD_LOGS'), + moodCreate: hasPermission(currentUser, 'CREATE_MOOD_LOGS'), + sleepRead: hasPermission(currentUser, 'READ_SLEEP_LOGS'), + sleepCreate: hasPermission(currentUser, 'CREATE_SLEEP_LOGS'), + weightRead: hasPermission(currentUser, 'READ_HEALTH_METRICS'), + weightCreate: hasPermission(currentUser, 'CREATE_HEALTH_METRICS'), + goalsRead: hasPermission(currentUser, 'READ_GOALS'), + }), + [currentUser], + ); + + const loadPulse = useCallback(async (userId: string) => { + const safeUserId = escapeSqlLiteral(userId); + const activityQueries: string[] = []; + + if (permissions.hydrationRead) { + activityQueries.push(` + SELECT + id, + 'hydration'::text AS kind, + COALESCE(NULLIF(container_label, ''), INITCAP(REPLACE(beverage_type, '_', ' ')), 'Hydration') AS title, + CONCAT(COALESCE(volume_ml::text, '0'), ' ml') AS detail, + logged_at AS happened_at + FROM hydration_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + `); + } + + if (permissions.moodRead) { + activityQueries.push(` + SELECT + id, + 'mood'::text AS kind, + COALESCE(NULLIF(emotion_tags_csv, ''), 'Mood check-in') AS title, + CONCAT('Mood ', COALESCE(mood_score::text, '-'), '/10') AS detail, + logged_at AS happened_at + FROM mood_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + `); + } + + if (permissions.sleepRead) { + activityQueries.push(` + SELECT + id, + 'sleep'::text AS kind, + 'Sleep log'::text AS title, + CONCAT(COALESCE(total_sleep_minutes::text, '0'), ' min sleep') AS detail, + COALESCE(final_wake_time, bedtime) AS happened_at + FROM sleep_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + `); + } + + if (permissions.weightRead) { + activityQueries.push(` + SELECT + id, + 'weight'::text AS kind, + 'Weight check'::text AS title, + CONCAT(COALESCE(value_primary::text, '0'), ' ', COALESCE(unit, '')) AS detail, + measured_at AS happened_at + FROM health_metrics + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + AND metric_type = 'body_composition' + AND device_used = '${escapeSqlLiteral(DAILY_PULSE_DEVICE)}' + `); + } + + setLoading(true); + + try { + const [hydrationRows, moodRows, sleepRows, weightRows, goalRows, activityRows] = await Promise.all([ + permissions.hydrationRead + ? runSql<{ hydration_total_ml: number; hydration_logs_today: number }>(` + SELECT + COALESCE(SUM(CASE WHEN counts_toward_hydration THEN volume_ml ELSE 0 END), 0)::int AS hydration_total_ml, + COUNT(*)::int AS hydration_logs_today + FROM hydration_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + AND logged_at::date = CURRENT_DATE + `) + : Promise.resolve([]), + permissions.moodRead + ? runSql(` + SELECT mood_score, energy_level, anxiety_level, emotion_tags_csv, logged_at + FROM mood_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + AND logged_at::date = CURRENT_DATE + ORDER BY logged_at DESC + LIMIT 1 + `) + : Promise.resolve([]), + permissions.sleepRead + ? runSql(` + SELECT bedtime, final_wake_time, total_sleep_minutes, sleep_quality, morning_energy + FROM sleep_logs + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + ORDER BY COALESCE(final_wake_time, bedtime) DESC NULLS LAST, "createdAt" DESC + LIMIT 1 + `) + : Promise.resolve([]), + permissions.weightRead + ? runSql(` + SELECT measured_at, value_primary, unit, notes + FROM health_metrics + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + AND metric_type = 'body_composition' + AND device_used = '${escapeSqlLiteral(DAILY_PULSE_DEVICE)}' + ORDER BY measured_at DESC NULLS LAST, "createdAt" DESC + LIMIT 1 + `) + : Promise.resolve([]), + permissions.goalsRead + ? runSql(` + SELECT id, goal_type, follow_up_answer, target_date + FROM goals + WHERE "userId" = '${safeUserId}' + AND "deletedAt" IS NULL + AND is_active = true + ORDER BY "createdAt" DESC + LIMIT 3 + `) + : Promise.resolve([]), + activityQueries.length + ? runSql(` + SELECT * + FROM ( + ${activityQueries.join('\n UNION ALL\n')} + ) AS activity + WHERE happened_at IS NOT NULL + ORDER BY happened_at DESC + LIMIT 8 + `) + : Promise.resolve([]), + ]); + + const hydrationTotals = hydrationRows[0]; + + setSummary({ + hydrationTotalMl: Number(hydrationTotals?.hydration_total_ml ?? 0), + hydrationLogsToday: Number(hydrationTotals?.hydration_logs_today ?? 0), + moodToday: moodRows[0] ?? null, + sleepLatest: sleepRows[0] ?? null, + weightLatest: weightRows[0] ?? null, + }); + setGoals(goalRows); + setRecentActivity(activityRows); + } catch (error) { + console.error('Failed to load APEX BRAIN Daily Pulse:', error); + setFeedback({ + color: 'danger', + message: 'We could not load your Daily Pulse data right now. Please refresh and try again.', + }); + } finally { + setLoading(false); + } + }, [permissions.goalsRead, permissions.hydrationRead, permissions.moodRead, permissions.sleepRead, permissions.weightRead]); + + useEffect(() => { + if (!currentUser?.id) { + return; + } + + loadPulse(currentUser.id).catch((error) => { + console.error('Unhandled Daily Pulse load error:', error); + }); + }, [currentUser?.id, loadPulse]); + + const greeting = useMemo(() => { + const hour = new Date().getHours(); + + if (hour < 12) { + return 'Good morning'; + } + + if (hour < 18) { + return 'Good afternoon'; + } + + return 'Good evening'; + }, []); + + const summaryCards = useMemo(() => { + const hydrationProgress = Math.min(summary.hydrationTotalMl / WATER_GOAL_ML, 1); + const sleepTimestamp = summary.sleepLatest?.final_wake_time || summary.sleepLatest?.bedtime; + const weightToday = isToday(summary.weightLatest?.measured_at); + const sleepLogged = hasRecentSleep(sleepTimestamp); + const moodLogged = Boolean(summary.moodToday?.logged_at); + + return [ + { + label: 'Hydration', + icon: mdiWaterOutline, + value: `${summary.hydrationTotalMl.toLocaleString()} ml`, + detail: `${Math.min(100, Math.round(hydrationProgress * 100))}% of your 2L target`, + isComplete: summary.hydrationTotalMl > 0, + }, + { + label: 'Mood', + icon: mdiEmoticonHappyOutline, + value: summary.moodToday?.mood_score ? `${summary.moodToday.mood_score}/10 mood` : 'Not logged yet', + detail: + summary.moodToday?.energy_level || summary.moodToday?.anxiety_level + ? `Energy ${summary.moodToday?.energy_level ?? '-'} • Anxiety ${summary.moodToday?.anxiety_level ?? '-'}` + : 'Capture how you felt today', + isComplete: moodLogged, + }, + { + label: 'Sleep', + icon: mdiWeatherNight, + value: formatMinutes(summary.sleepLatest?.total_sleep_minutes), + detail: summary.sleepLatest?.sleep_quality + ? `Quality ${summary.sleepLatest.sleep_quality}/10 • Logged ${formatDateTime(sleepTimestamp)}` + : 'Add your last sleep block', + isComplete: sleepLogged, + }, + { + label: 'Weight', + icon: mdiScaleBathroom, + value: summary.weightLatest?.value_primary + ? `${summary.weightLatest.value_primary} ${summary.weightLatest.unit ?? ''}`.trim() + : 'Not logged yet', + detail: summary.weightLatest?.measured_at + ? `Measured ${formatDateTime(summary.weightLatest.measured_at)}` + : 'Optional body composition check', + isComplete: weightToday, + }, + ]; + }, [summary.hydrationTotalMl, summary.moodToday, summary.sleepLatest, summary.weightLatest]); + + const completedCount = summaryCards.filter((card) => card.isComplete).length; + const completionPercent = Math.round((completedCount / summaryCards.length) * 100); + const hydrationScore = Math.round(Math.min(summary.hydrationTotalMl / WATER_GOAL_ML, 1) * 25); + const pulseScore = hydrationScore + (summaryCards[1].isComplete ? 25 : 0) + (summaryCards[2].isComplete ? 25 : 0) + (summaryCards[3].isComplete ? 25 : 0); + const hasAnyCreatePermission = + permissions.hydrationCreate || permissions.moodCreate || permissions.sleepCreate || permissions.weightCreate; + const accessibleQuickLinks = quickLinks.filter((link) => { + if (link.href.startsWith('/hydration_logs')) { + return permissions.hydrationRead; + } + + if (link.href.startsWith('/mood_logs')) { + return permissions.moodRead; + } + + if (link.href.startsWith('/sleep_logs')) { + return permissions.sleepRead; + } + + if (link.href.startsWith('/goals')) { + return permissions.goalsRead; + } + + return false; + }); + + const handleChange = (field: K, value: PulseFormState[K]) => { + setForm((previous) => ({ + ...previous, + [field]: value, + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!currentUser?.id) { + setFeedback({ + color: 'danger', + message: 'Your session is not ready yet. Please wait a moment and try again.', + }); + return; + } + + const errors: string[] = []; + const hasHydrationEntry = permissions.hydrationCreate && form.volumeMl.trim() !== ''; + const hasMoodEntry = + permissions.moodCreate && + [form.moodScore, form.energyLevel, form.anxietyLevel, form.moodTags, form.journalEntry].some((value) => value.trim() !== ''); + const hasSleepEntry = + permissions.sleepCreate && + [form.sleepBedtime, form.sleepWakeTime, form.sleepQuality, form.morningEnergy, form.sleepDisturbances].some((value) => value.trim() !== ''); + const hasWeightEntry = permissions.weightCreate && form.weightValue.trim() !== ''; + + if (!hasHydrationEntry && !hasMoodEntry && !hasSleepEntry && !hasWeightEntry) { + errors.push('Add at least one check-in before saving your Daily Pulse.'); + } + + if (hasHydrationEntry) { + const volume = Number(form.volumeMl); + + if (!Number.isFinite(volume) || volume <= 0) { + errors.push('Hydration volume must be greater than zero.'); + } + } + + if (hasMoodEntry) { + const moodScore = Number(form.moodScore); + const energyLevel = form.energyLevel ? Number(form.energyLevel) : null; + const anxietyLevel = form.anxietyLevel ? Number(form.anxietyLevel) : null; + + if (!Number.isFinite(moodScore) || moodScore < 1 || moodScore > 10) { + errors.push('Mood score must be between 1 and 10.'); + } + + if (energyLevel && (energyLevel < 1 || energyLevel > 10)) { + errors.push('Energy level must be between 1 and 10.'); + } + + if (anxietyLevel && (anxietyLevel < 1 || anxietyLevel > 10)) { + errors.push('Anxiety level must be between 1 and 10.'); + } + } + + if (hasSleepEntry) { + if (!form.sleepBedtime || !form.sleepWakeTime) { + errors.push('Sleep entries need both a bedtime and a wake time.'); + } else { + const totalMinutes = Math.round((new Date(form.sleepWakeTime).getTime() - new Date(form.sleepBedtime).getTime()) / 60000); + + if (totalMinutes <= 0 || totalMinutes > 24 * 60) { + errors.push('Sleep duration must be a realistic positive value.'); + } + } + + if (form.sleepQuality && (Number(form.sleepQuality) < 1 || Number(form.sleepQuality) > 10)) { + errors.push('Sleep quality must be between 1 and 10.'); + } + + if (form.morningEnergy && (Number(form.morningEnergy) < 1 || Number(form.morningEnergy) > 10)) { + errors.push('Morning energy must be between 1 and 10.'); + } + } + + if (hasWeightEntry) { + const weight = Number(form.weightValue); + + if (!Number.isFinite(weight) || weight <= 0) { + errors.push('Weight must be greater than zero.'); + } + } + + if (errors.length) { + setFeedback({ + color: 'danger', + message: errors.join(' '), + }); + return; + } + + setIsSubmitting(true); + setFeedback(null); + + const savedSections: string[] = []; + const failedSections: string[] = []; + + try { + if (hasHydrationEntry) { + try { + await axios.post('/hydration_logs', { + data: { + user: currentUser.id, + logged_at: toIsoString(form.hydrationLoggedAt), + beverage_type: form.beverageType, + volume_ml: Number(form.volumeMl), + counts_toward_hydration: form.countsTowardHydration, + container_label: form.containerLabel || null, + }, + }); + savedSections.push('hydration'); + } catch (error) { + console.error('Failed to save hydration entry:', error); + failedSections.push('hydration'); + } + } + + if (hasMoodEntry) { + try { + await axios.post('/mood_logs', { + data: { + user: currentUser.id, + logged_at: toIsoString(form.moodLoggedAt), + mood_score: Number(form.moodScore), + emotion_tags_csv: form.moodTags || null, + energy_level: form.energyLevel ? Number(form.energyLevel) : null, + anxiety_level: form.anxietyLevel ? Number(form.anxietyLevel) : null, + journal_entry: form.journalEntry || null, + }, + }); + savedSections.push('mood'); + } catch (error) { + console.error('Failed to save mood entry:', error); + failedSections.push('mood'); + } + } + + if (hasSleepEntry) { + const totalMinutes = Math.round((new Date(form.sleepWakeTime).getTime() - new Date(form.sleepBedtime).getTime()) / 60000); + + try { + await axios.post('/sleep_logs', { + data: { + user: currentUser.id, + bedtime: toIsoString(form.sleepBedtime), + final_wake_time: toIsoString(form.sleepWakeTime), + total_sleep_minutes: totalMinutes, + sleep_quality: form.sleepQuality ? Number(form.sleepQuality) : null, + morning_energy: form.morningEnergy ? Number(form.morningEnergy) : null, + disturbances: form.sleepDisturbances || null, + }, + }); + savedSections.push('sleep'); + } catch (error) { + console.error('Failed to save sleep entry:', error); + failedSections.push('sleep'); + } + } + + if (hasWeightEntry) { + try { + await axios.post('/health_metrics', { + data: { + user: currentUser.id, + measured_at: toIsoString(form.weightMeasuredAt), + metric_type: 'body_composition', + value_primary: Number(form.weightValue), + unit: form.weightUnit, + device_used: DAILY_PULSE_DEVICE, + notes: form.weightNotes || null, + }, + }); + savedSections.push('weight'); + } catch (error) { + console.error('Failed to save weight entry:', error); + failedSections.push('weight'); + } + } + + if (!savedSections.length) { + setFeedback({ + color: 'danger', + message: 'Nothing was saved. Please review your inputs and try again.', + }); + return; + } + + if (failedSections.length) { + setFeedback({ + color: 'danger', + message: `Saved ${savedSections.join(', ')}, but ${failedSections.join(', ')} still need attention. Please retry those sections.`, + }); + } else { + setFeedback({ + color: 'success', + message: `Saved your ${savedSections.join(', ')} check-in${savedSections.length > 1 ? 's' : ''}. Your Daily Pulse is up to date.`, + }); + setForm(initialPulseForm()); + } + + await loadPulse(currentUser.id); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + {getPageTitle('Daily Pulse')} + + + +
+ + Overview + + + Goals + +
+
+ +
+
+
+
+ + APEX BRAIN signature workflow +
+

+ {greeting}, {currentUser?.firstName || 'there'}. Here's your calm, clear view of today. +

+

+ Save water, mood, sleep, and weight in one elegant ritual, then jump straight into your detailed history and active goals. +

+ +
+
+
+ Today's pulse score +
+
+ {pulseScore} + + /100 + +
+
+
+
+ Live hydration +
+
+ {summary.hydrationTotalMl.toLocaleString()} ml +
+

+ {summary.hydrationLogsToday} entries captured today +

+
+
+
+ Focus cadence +
+
+ {completedCount} / {summaryCards.length} anchors +
+

+ Hydration, mood, sleep, and weight all in one view +

+
+
+
+ +
+
+
+
+
+
+
+ Daily ring +
+
+ {completionPercent}% +
+

+ {completedCount === summaryCards.length + ? 'Everything core is captured for today.' + : 'Keep logging to complete your premium health snapshot.'} +

+
+
+
+ + {new Intl.DateTimeFormat(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + }).format(new Date())} +
+
+
+
+
+ + {feedback && ( + + {feedback.message} + + )} + +
+ {summaryCards.map((card) => ( +
+
+
+ +
+ + {card.isComplete ? 'captured' : 'pending'} + +
+

+ {card.label} +

+

+ {loading ? 'Loading...' : card.value} +

+

+ {loading ? 'Refreshing your summary...' : card.detail} +

+
+ ))} +
+ +
+
+
+
+
+

+ Daily capture ritual +

+

+ Add any combination of water, mood, sleep, and weight in one save. Every item lands in its existing history screen automatically. +

+
+
+ First MVP workflow +
+
+ +
+ {!hasAnyCreatePermission && ( +
+ Your current role can view this Daily Pulse workspace, but it does not have create access for the quick-capture records yet. You can still explore your existing history and goals from the cards on the right. +
+ )} + + {permissions.hydrationCreate && ( +
+
+
+ +
+
+

+ Hydration log +

+

+ Water first, with room for coffee, tea, and other beverages. +

+
+
+
+ + handleChange('hydrationLoggedAt', event.target.value)} + /> + + + handleChange('volumeMl', event.target.value)} + /> + + + + + + handleChange('containerLabel', event.target.value)} + /> + +
+ +
+ )} + + {permissions.moodCreate && ( +
+
+
+ +
+
+

+ Mood check-in +

+

+ Capture feelings, energy, and anything that shaped your day. +

+
+
+
+ + handleChange('moodLoggedAt', event.target.value)} + /> + + + handleChange('moodScore', event.target.value)} + /> + + + handleChange('energyLevel', event.target.value)} + /> + + + handleChange('anxietyLevel', event.target.value)} + /> + +
+
+ + handleChange('moodTags', event.target.value)} + /> + + +