"use client"; /** * Dashboard page header: title, the greeting line, the customize * shortcut, and the quick-add dropdown that opens the quick-entry * sheets. The greeting sits under the title on every dashboard mount — * the promoted Today hero renders separately above the tile strip and * carries its own read, so the header greeting is unconditional. * * Extracted from the dashboard page; the page owns the open-state the * dropdown items set via `onQuickEntry`. */ import { useMemo } from "react"; import Link from "next/link"; import { Activity, Flag, Footprints, Pill, Plus, Stethoscope, Waves, Wrench, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { PageHeader } from "@/components/ui/page-header"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; import { CAPTURE_KIND_ORDER, visibleCaptureKinds, } from "@/components/layout/capture-picker"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ onQuickEntry, }: { onQuickEntry: (dialog: NonNullable) => void; }) { const { t } = useTranslations(); const { user } = useAuth(); const mounted = useMounted(); // The quick-add offers four kinds, and each one is asked about its own // section: a reading under `measurements`, a dose under `medications`, a // mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole // menu on carries the grant's LEVEL and no scope, so it answered true for a // grant that opens neither of the two sections the menu never asked about. // The customize shortcut points at a settings page sharing does not cover at // all. const capabilities = useRecordCapabilities(); const { inSharedRecord } = capabilities; // The same answer the bottom bar's capture picker renders from: per kind, // the record's section (a delegated grant) AND the kind's module // (`capture:`). Asking the picker's own function is what keeps the two // menus from disagreeing — this one used to check the section alone, so a // switched-off mood module still offered "Log mood" here. const offered = visibleCaptureKinds( capabilities, CAPTURE_KIND_ORDER, user?.modules, ); const canAddMeasurement = offered.includes("measurement"); const canAddIntake = offered.includes("medication"); const canAddMood = offered.includes("mood"); const canAddSymptom = offered.includes("symptom"); const canAddWorkout = offered.includes("workout"); const canAddLifeEvent = offered.includes("lifeEvent"); const canAddAnything = offered.length > 0; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary // hydrates — the text must match the name-less SSR output during // hydration (React #418) and may only personalise from the first // client re-render. // // The hour comes from the shared `hourInTz`, the same helper the // insights hero greeting uses. Its predecessor fell back to the // DEVICE clock whenever the runtime could not resolve the profile // zone, so a zone this browser's ICU does not carry (a recently added // one, an older engine) split the two greetings by hours while every // other date on the page stayed on the profile zone. const userTimezone = mounted ? user?.timezone : undefined; const hour = useMemo( () => (userTimezone ? hourInTz(new Date(), userTimezone) : null), [userTimezone], ); 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 = mounted && user?.username && user.username.trim().length > 0 ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name: user.username, }) : t("dashboard.welcomeBack", { greeting: timeGreeting }); return ( {welcomeText} } actions={ <> {/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to the left of the add button as a monochrome ghost icon, with a `min-h-11 min-w-11` mobile floor so it meets the 44 px touch-target contract the add button also honours, shrinking back to the 40 px icon footprint on sm+. */} {!inSharedRecord && ( )} {canAddAnything && ( {/* v1.4.33 maintainer-item-7 — restore proportional sizing across viewports. The v1.4.27 fix pinned a `size="sm" min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target contract on mobile, but `size="sm"` is h-8 (32 px) and the `min-h-11` override stretched the cap vertically while keeping the small horizontal padding — the button read as klobig on Pixel 5. Switch to `size="default"` (h-10 = 40 px) on mobile with a responsive `min-h-11 sm:min-h-9` so the button is 44 px tall under finger pressure and shrinks back to the desktop-friendly 36 px on `sm:` upwards. The icon + label keep the same visual contract. */} {/* 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`. */} {canAddMeasurement && ( onQuickEntry("measurement")}> )} {canAddMood && ( onQuickEntry("mood")}> )} {/* v1.4.37 W7b — third quick-add row: medication intake. Same Sheet-on-mobile / Dialog-on-desktop primitive as the other two; the menu label is a self-contained verb-phrase so it doesn't collide with the trigger or the sibling rows (cf. quick-add-labels.test.ts). */} {canAddIntake && ( onQuickEntry("medicationIntake")} > )} {canAddSymptom && ( onQuickEntry("symptom")}> )} {canAddWorkout && ( onQuickEntry("workout")}> )} {canAddLifeEvent && ( onQuickEntry("lifeEvent")}> )} )} } /> ); }