Files
HealthLog/src/components/dashboard/dashboard-header.tsx
T
Marc-André Bombeck 6ca33882f1 Open the capture sheet from the dashboard's add button on phones
On the phone shell the header's 'Add' opened a dropdown while the plus
in the bottom nav, one thumb below, opened the capture sheet. The header
button now opens the same sheet there; the desktop rail keeps the
dropdown. The onboarding tour anchors on whichever of the two is laid
out.
2026-10-08 18:36:46 +02:00

260 lines
11 KiB
TypeScript

"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, useState } 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,
CapturePicker,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
onQuickEntry,
}: {
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
const [captureOpen, setCaptureOpen] = useState(false);
// 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:<kind>`). 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 (
<PageHeader
title={t("dashboard.title")}
description={
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
<span
data-slot="dashboard-header-greeting"
className="block min-h-5 truncate"
>
{welcomeText}
</span>
}
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 && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* On the phone shell the add button opens the same capture
picker sheet the bottom nav's plus opens: one way to add a
thing, not a dropdown here and a sheet one thumb below. */}
{canAddAnything && (
<Button
size="default"
className="shell-desktop:hidden min-h-11"
onClick={() => setCaptureOpen(true)}
aria-haspopup="dialog"
aria-expanded={captureOpen}
data-slot="dashboard-quick-add-capture"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
{canAddAnything && (
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
)}
{canAddAnything && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="shell-mobile:hidden min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* 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 && (
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
)}
{/* 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 && (
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
{canAddMood && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
)}
{canAddSymptom && (
<DropdownMenuItem onClick={() => onQuickEntry("symptom")}>
<Stethoscope className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddSymptom")}
</DropdownMenuItem>
)}
{canAddWorkout && (
<DropdownMenuItem onClick={() => onQuickEntry("workout")}>
<Footprints className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWorkout")}
</DropdownMenuItem>
)}
{canAddLifeEvent && (
<DropdownMenuItem onClick={() => onQuickEntry("lifeEvent")}>
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddLifeEvent")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
/>
);
}