"use client"; import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { Activity, Droplet, Footprints, Heart, Moon, Percent, Plus, Smile, Target, TrendingUp, Waves, } from "lucide-react"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { resolveDashboardLayout, type DashboardLayout, } from "@/lib/dashboard-layout"; import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends"; import dynamic from "next/dynamic"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MoodForm } from "@/components/mood/mood-form"; import { TrendCard } from "@/components/charts/trend-card"; import { TrendHint } from "@/components/charts/trend-hint"; import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; const HealthChart = dynamic( () => import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), { ssr: false }, ); const MoodChart = dynamic( () => import("@/components/charts/mood-chart").then((mod) => ({ default: mod.MoodChart, })), { ssr: false }, ); const MedicationComplianceChart = dynamic( () => import("@/components/charts/medication-compliance-chart").then((mod) => ({ default: mod.MedicationComplianceChart, })), { ssr: false }, ); import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { buildTrafficLightBands, buildTrafficRange, buildWeightBandsFromHeight, buildWeightRangeFromHeight, getBodyFatTargetRange, type TrafficRange, } from "@/lib/analytics/value-bands"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; interface AnalyticsData { summaries: Record; bpInTargetPct: number | null; glucoseByContext?: Record; } interface RangeDisplayConfig { range: TrafficRange | null; } function getHourForTimeZone(timeZone?: string): number { const now = new Date(); if (!timeZone) return now.getHours(); try { const parts = new Intl.DateTimeFormat("en-US", { hour: "2-digit", hour12: false, timeZone, }).formatToParts(now); const hourPart = parts.find((part) => part.type === "hour")?.value; const parsed = hourPart ? Number(hourPart) : Number.NaN; return Number.isNaN(parsed) ? now.getHours() : parsed; } catch { return now.getHours(); } } function getRangeColorClass( value: number | null | undefined, config: RangeDisplayConfig, ): string | undefined { const range = config.range; if (value == null || !range) return undefined; const inGreen = value >= range.greenMin && value <= range.greenMax; const inOrange = !inGreen && value >= range.orangeMin && value <= range.orangeMax; if (inGreen) return "text-green-400"; if (inOrange) return "text-orange-400"; return "text-red-400"; } function getRangeHint( unit: string, config: RangeDisplayConfig, t: (key: string) => string, formatNumber: (value: number, fractionDigits?: number) => string, ): React.ReactNode | undefined { const range = config.range; if (!range) return undefined; const format = (value: number) => formatNumber(value, 1); return ( <>

{t("charts.colorGreen")} {" "} {format(range.greenMin)}-{format(range.greenMax)} {unit}

{t("charts.colorOrange")} {" "} {format(range.orangeMin)}-{format(range.greenMin)} {t("common.or")}{" "} {format(range.greenMax)}-{format(range.orangeMax)} {unit}

{t("charts.colorRed")}{" "} {"< "} {format(range.orangeMin)} {t("common.or")} {"> "} {format(range.orangeMax)} {unit}

); } export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); const [quickEntryDialog, setQuickEntryDialog] = useState< "measurement" | "mood" | null >(null); const { data } = useQuery({ queryKey: queryKeys.analytics(), queryFn: async () => { const res = await fetch("/api/analytics"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as AnalyticsData; }, enabled: isAuthenticated, }); const { data: layoutData } = useQuery({ queryKey: ["user", "dashboardWidgets"], queryFn: async () => { const res = await fetch("/api/dashboard/widgets"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as DashboardLayout; }, enabled: isAuthenticated, }); const { data: moodData } = useQuery({ queryKey: queryKeys.moodAnalytics(), queryFn: async () => { const res = await fetch("/api/mood/analytics"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as { entries: Array<{ date: string; score: number; samples: number }>; summary: DataSummary; }; }, enabled: isAuthenticated, }); const w = data?.summaries?.WEIGHT; const sys = data?.summaries?.BLOOD_PRESSURE_SYS; const dia = data?.summaries?.BLOOD_PRESSURE_DIA; const p = data?.summaries?.PULSE; const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; const stepsSummary = data?.summaries?.ACTIVITY_STEPS; const moodSummary = moodData?.summary; // Resolve full dashboard layout — controls visibility + order of every widget const layout = resolveDashboardLayout(layoutData); /** Whether the widget's *chart* (lower row) shows. */ const isChartVisible = (id: string) => layout.widgets.find((widget) => widget.id === id)?.visible ?? false; /** * v1.4.15 Fix 5 — whether the widget's *tile* in the strip shows. * Independent of the chart visibility so the user can hide chart but * keep the tile (or vice versa) from Settings → Dashboard. Falls back * to chart visibility for layouts saved before v1.4.15. */ const isTileVisible = (id: string) => { const widget = layout.widgets.find((w) => w.id === id); if (!widget) return false; return typeof widget.tileVisible === "boolean" ? widget.tileVisible : widget.visible; }; const widgetOrder = (id: string) => layout.widgets.find((widget) => widget.id === id)?.order ?? 999; // Data-floor gates (widget shows iff visible AND has data) const hasWeight = (w?.count ?? 0) > 0; const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0; const hasPulse = (p?.count ?? 0) > 0; const hasBodyFat = (bf?.count ?? 0) > 0; const hasMood = (moodSummary?.count ?? 0) > 0; const hasSleep = (sleepSummary?.count ?? 0) > 0; const hasSteps = (stepsSummary?.count ?? 0) > 0; const hasBpInTarget = data?.bpInTargetPct != null; // Tile (strip) gates — controlled by the new `tileVisible` flag. const showWeightTile = isTileVisible("weight") && hasWeight; const showBpTiles = isTileVisible("bp") && hasBp; const showPulseTile = isTileVisible("pulse") && hasPulse; const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat; const showMoodTile = isTileVisible("mood") && hasMood; const showSleepTile = isTileVisible("sleep") && hasSleep; const showStepsTile = isTileVisible("steps") && hasSteps; const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget; // Chart (lower row) gates — controlled by the legacy `visible` flag. const showWeightChart = isChartVisible("weight") && hasWeight; const showBpCharts = isChartVisible("bp") && hasBp; const showPulseChart = isChartVisible("pulse") && hasPulse; const showBodyFatChart = isChartVisible("bodyFat") && hasBodyFat; const showMoodChart = isChartVisible("mood") && hasMood; const showSleepChart = isChartVisible("sleep") && hasSleep; const showStepsChart = isChartVisible("steps") && hasSteps; const showMedicationsCard = isChartVisible("medications"); // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself // self-handles the empty state (CTA → /achievements), so we only need // the layout-toggle gate here. No data-floor check (the empty card is // intentional — Marc wants the user to discover the feature). const showAchievementsCard = isChartVisible("achievements"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the // single source of truth for it. const glucoseWidgetVisible = isTileVisible("glucose"); const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const glucoseByContext = data?.glucoseByContext ?? {}; const glucoseContexts = [ "FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME", ] as const; const glucoseSummariesPresent = glucoseContexts.filter( (ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0, ); const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0; const glucoseLabelKey: Record = { FASTING: "targets.glucoseFasting", POSTPRANDIAL: "targets.glucosePostprandial", RANDOM: "targets.glucoseRandom", BEDTIME: "targets.glucoseBedtime", }; const bpTargets = user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); const pulseTarget = getPersonalizedPulseTarget( pulseAge, (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, ); const bodyFatRange = getBodyFatTargetRange(user?.gender); const weightRange = user?.heightCm ? buildWeightRangeFromHeight(user.heightCm) : null; const weightBands = user?.heightCm ? buildWeightBandsFromHeight(user.heightCm, { lowerBound: 30, upperBound: 250, }) : undefined; const bpTargetZones = bpTargets ? [ { min: bpTargets.sysLow, max: bpTargets.sysHigh, color: "#ff79c6", opacity: 0.21, label: t("charts.systolic"), textColor: "#ff79c6", lineOpacity: 0.24, }, { min: bpTargets.diaLow, max: bpTargets.diaHigh, color: "#8be9fd", opacity: 0.21, label: t("charts.diastolic"), textColor: "#8be9fd", lineOpacity: 0.24, }, ] : undefined; const bpSysRange = bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null; const bpDiaRange = bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null; const pulseDisplayRange = { greenMin: pulseTarget.greenMin, greenMax: pulseTarget.greenMax, orangeMin: pulseTarget.orangeMin, orangeMax: pulseTarget.orangeMax, }; const pulseBands = [ { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, { min: pulseTarget.orangeMin, max: pulseTarget.greenMin, color: "#ffb86c", opacity: 0.18, }, { min: pulseTarget.greenMin, max: pulseTarget.greenMax, color: "#50fa7b", opacity: 0.2, }, { min: pulseTarget.greenMax, max: pulseTarget.orangeMax, color: "#ffb86c", opacity: 0.18, }, { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, ].filter((band) => band.max > band.min); const bodyFatBands = buildTrafficLightBands( bodyFatRange.min, bodyFatRange.max, { lowerBound: 2, upperBound: 55, }, ); const hour = user?.timezone ? getHourForTimeZone(user.timezone) : null; const timeGreeting = hour == null ? t("dashboard.greeting.day") : hour >= 5 && hour < 12 ? t("dashboard.greeting.morning") : hour >= 12 && hour < 18 ? t("dashboard.greeting.day") : t("dashboard.greeting.evening"); const welcomeText = user?.username && user.username.trim().length > 0 ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name: user.username, }) : t("dashboard.welcomeBack", { greeting: timeGreeting }); return (

{t("dashboard.title")}

{welcomeText}

{/* WCAG 2.5.5 — touch targets must be ≥44×44 CSS px on mobile. `size="sm"` (h-8 = 32px) was below threshold; the explicit min-h-11 ensures we hit 44px on the Pixel 5 viewport while keeping the desktop visual unchanged. */} {/* Menu items must each carry a self-contained verb-phrase ("Log measurement", "Log mood") — the trigger above already says "Add", and the icon is `aria-hidden`, so the visible text is the only thing distinguishing the rows. v1.4.15 phase-A3 fix #1 hardened this with a unit guard at `src/app/__tests__/quick-add-labels.test.ts` — both labels must differ from each other AND from `common.add`. */} setQuickEntryDialog("measurement")} > setQuickEntryDialog("mood")}>
{/* v1.4: Getting-started checklist for brand-new users. * Self-gates visibility on (onboardingCompletedAt == null * || measurementCount < 5) and disappears once dismissed * or fully complete. See B2 in the v1.4 discovery summary. */} {/* v1.4.15 Phase B5 — spotlight tour for first-time users. * Self-gates on `user.onboardingTourCompleted` (DB flag) plus * a session-storage dismiss guard. We pass `ready=true` only * after analytics has resolved — the tour anchors to the tile * strip and we don't want the cutout snapping to a 0×0 * placeholder before tiles render. The launcher mounts a no-op * `null` when the user has already seen the tour, so this * line is free for established users. */} {/* Quick Entry Dialogs */} !open && setQuickEntryDialog(null)} > {t("measurements.addMeasurement")} setQuickEntryDialog(null)} onCancel={() => setQuickEntryDialog(null)} /> !open && setQuickEntryDialog(null)} > {t("mood.addEntry")} setQuickEntryDialog(null)} onCancel={() => setQuickEntryDialog(null)} /> {(() => { type TrendEntry = { id: string; order: number; node: React.ReactNode }; const trendCards: TrendEntry[] = []; if (showWeightTile) { trendCards.push({ id: "weight", order: widgetOrder("weight"), node: ( ), }); } if (showBpTiles) { // BP is conceptually one signal but visually two tiles (sys + dia) // so the user sees both numbers side by side at the same size as // every other tile in the strip. v1.4.3 first attempted a // combined tile with `secondary` values — Marc preferred two // distinct tiles with consistent symmetric widths. trendCards.push({ id: "bp-sys", order: widgetOrder("bp"), node: ( ), }); trendCards.push({ id: "bp-dia", // Sub-order keeps dia immediately after sys; the +0.001 leaves // headroom for any future BP-related tile slotted between them. order: widgetOrder("bp") + 0.001, node: ( ), }); } if (showPulseTile) { trendCards.push({ id: "pulse", order: widgetOrder("pulse"), node: ( ), }); } if (showBodyFatTile) { trendCards.push({ id: "bodyFat", order: widgetOrder("bodyFat"), node: ( ), }); } if (showMoodTile) { trendCards.push({ id: "mood", order: widgetOrder("mood"), node: ( ), }); } if (showSleepTile) { trendCards.push({ id: "sleep", order: widgetOrder("sleep"), node: ( ), }); } if (showStepsTile) { trendCards.push({ id: "steps", order: widgetOrder("steps"), node: ( ), }); } if (showBpInTargetTile) { trendCards.push({ id: "bpInTarget", order: widgetOrder("bpInTarget"), node: ( ), }); } if (showGlucoseCards) { const glucoseOrder = widgetOrder("glucose"); glucoseSummariesPresent.forEach((ctx, idx) => { const s = glucoseByContext[ctx]; trendCards.push({ id: `glucose-${ctx}`, // sub-order so all glucose cards stay in a block and order stable order: glucoseOrder + idx / 1000, node: ( ), }); }); } trendCards.sort((a, b) => a.order - b.order); type ChartEntry = { id: string; order: number; node: React.ReactNode; /** * Total raw readings for this metric. <5 surfaces a contextual * "First trend after 5 readings" hint underneath the chart; * undefined disables the hint (e.g. medications card). */ count?: number; }; const charts: ChartEntry[] = []; if (showWeightChart) { charts.push({ id: "weight-chart", order: widgetOrder("weight"), count: w?.count ?? 0, node: ( ), }); if (user?.heightCm) { charts.push({ id: "bmi-chart", order: widgetOrder("weight") + 0.5, node: ( ), }); } } if (showBpCharts) { charts.push({ id: "bp-chart", order: widgetOrder("bp"), count: Math.max(sys?.count ?? 0, dia?.count ?? 0), node: ( ), }); } if (showPulseChart) { charts.push({ id: "pulse-chart", order: widgetOrder("pulse"), count: p?.count ?? 0, node: ( ), }); } if (showBodyFatChart) { charts.push({ id: "bodyFat-chart", order: widgetOrder("bodyFat"), count: bf?.count ?? 0, node: ( ), }); } if (showMoodChart) { charts.push({ id: "mood-chart", order: widgetOrder("mood"), count: moodSummary?.count ?? 0, node: , }); } if (showSleepChart) { charts.push({ id: "sleep-chart", order: widgetOrder("sleep"), count: sleepSummary?.count ?? 0, node: ( ), }); } if (showStepsChart) { charts.push({ id: "steps-chart", order: widgetOrder("steps"), count: stepsSummary?.count ?? 0, node: ( ), }); } if (showMedicationsCard) { // v1.4.15 Fix 2: the toggle existed since v1.1 but the dashboard // slot only rendered a static placeholder (icon + title), so // flipping the layout switch on did nothing visible. Wire the // real chart that consumes // `/api/medications/intake?scope=compliance&days=N`. charts.push({ id: "medications", order: widgetOrder("medications"), node: , }); } if (showAchievementsCard) { // v1.4.15 phase-B4 — slotted at the user's chosen position via // the layout `order`. Default order from // `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) // which matches Marc's brief "below the chart row". charts.push({ id: "achievements", order: widgetOrder("achievements"), node: , }); } charts.sort((a, b) => a.order - b.order); // v1.4.15 phase-C5: dashboard fully-empty state. When no tile // and no chart has data the dashboard would otherwise paint a // 0-px tile strip with the welcome banner above it — visually // looked like a half-broken page. Render an EmptyState that // re-uses the existing quick-entry dialog so the user has a // single click into "Log measurement" without leaving the page. // The GettingStartedChecklist above renders its own self-gated // surface for very-new accounts; this empty state covers the // case where the checklist has been dismissed but no data was // logged afterwards. if (trendCards.length === 0 && charts.length === 0) { return ( } title={t("dashboard.emptyTitle")} description={t("dashboard.emptyDescription")} action={ } /> ); } return ( <> {/* v1.4: dashboard tiles are *always* a single row. * Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row * tile strip breaks the visual hierarchy and reads like an * Excel grid. Total width caps at the parent container — * exactly the chart-width below. When the active tile count * exceeds what fits the viewport, the strip horizontal-scrolls * instead of wrapping; the user trims the set in * Settings → Dashboard (`/settings/dashboard`). * Each tile keeps a `min-w-[10rem]` so a single tile still * looks substantial on a wide screen, and `snap-x snap-mandatory` * makes the scroll feel deliberate rather than arbitrary on * touch. */}
{trendCards.map((entry) => (
{entry.node}
))}
{charts.map((entry) => (
{entry.node} {entry.count != null ? : null}
))} ); })()}
); }