Files
HealthLog/src/components/dashboard/dashboard-header.tsx
T
Marc-André Bombeck 32258bc5ab feat: add the timeline page, its readiness inventory and life-event entry
The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.

All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.

The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.

Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.

Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".

Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.

Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
2026-10-08 14:27:02 +02:00

238 lines
10 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 } 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<QuickEntryDialog>) => 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:<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>
)}
{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="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>
)}
{canAddMood && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</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>
)}
{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>
)}
</>
}
/>
);
}