fix(dashboard,medications): review findings before the cut

A fresh crisis-level blood-pressure reading outranks an overdue dose in
the daily verdict, and once it ages past a day the dose leads again.
The greeting returns to the page header whenever the daily band is
hidden, and the settings toggle says it rides along. The nightly
convergence pass honours the early grace, so a take recorded shortly
before a window that starts at the dose time heals like the write path
credits it. The early-take context line states a fact — last dose
yesterday, last dose n days ago — instead of claiming an early intake
after a late one, the quick-add default pick runs in the profile
timezone, a dose line without a medication name reads cleanly, the
early-grace constant has one home, and the Telegram confirm writes
intake and snooze atomically again.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 11:22:21 +02:00
1 parent aaecd141ee
commit b4d2d51d46
29 files changed
+601 -210

No files matched your search

@@ -0,0 +1,57 @@
/**
* `<DashboardHeader>` greeting contract.
*
* The greeting line lives in the hero band when that renders; the hero
* is optional (snapshot flag off, or hidden via the dashboard-layout
* toggle), so the page feeds `showGreeting` from its hero gate and the
* header restores the pre-hero greeting paragraph for exactly those
* mounts. Pinned here:
*
* 1. `showGreeting` renders the greeting line (with the `min-h-5`
* line-box reservation that keeps the header height stable through
* the post-hydration name personalisation);
* 2. without the prop (the hero owns the greeting) the line is absent
* — no duplicate greeting above the hero band;
* 3. the SSR pass renders the name-less fallback (hydration-safe).
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
}),
}));
import { DashboardHeader } from "../dashboard-header";
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DashboardHeader> — greeting", () => {
it("renders the greeting line when the hero is hidden (showGreeting)", () => {
const html = renderSSR(
<DashboardHeader onQuickEntry={() => undefined} showGreeting />,
);
expect(html).toContain('data-slot="dashboard-header-greeting"');
// SSR pass: name-less fallback (personalises post-hydration only).
expect(html).toContain("willkommen zurück.");
// Reserved line box keeps the header height stable through the
// post-hydration personalisation swap.
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
expect(html).toContain("min-h-5");
});
it("renders NO greeting line when the hero band owns it (default)", () => {
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
expect(html).not.toContain('data-slot="dashboard-header-greeting"');
expect(html).not.toContain("willkommen zurück.");
// The title itself stays.
expect(html).toContain("Dashboard");
});
});
@@ -41,6 +41,13 @@ vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// v1.16.9 — the component reads the profile timezone for the auto-pick.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { username: "tester", timezone: "Europe/Berlin" },
}),
}));
import {
MedicationIntakeQuickAdd,
pickDefaultMedicationId,
@@ -175,6 +182,36 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
expect(result).toBe("a");
});
it("reasons in the supplied profile timezone, not Berlin (v1.16.9)", () => {
// 08:00 UTC = 10:00 Berlin (inside the 08:00–11:00 window) but only
// 04:00 in New York (hours before it). The same instant must flip
// the pick with the timezone — pinning that the tz threads through
// both the wall-clock conversion and the window reduction.
const now = new Date("2026-05-12T08:00:00Z");
const meds = [
makeMed("a", { name: "Aaa" }),
makeMed("b", {
name: "Bbb",
schedules: [
{
id: "s1",
windowStart: "08:00",
windowEnd: "11:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
];
expect(pickDefaultMedicationId(meds, now, undefined, "Europe/Berlin")).toBe(
"b",
);
expect(
pickDefaultMedicationId(meds, now, undefined, "America/New_York"),
).toBe("a");
});
it("falls back to the alphabetical-first active medication when nothing is due", () => {
// 03:00 Berlin time: no schedules overlap. The helper should
// alphabetically sort and pick the leading id, "ramipril".
+69 -6
View File
@@ -3,12 +3,16 @@
/**
* Dashboard page header: title, the customize shortcut, and the
* quick-add dropdown that opens the quick-entry sheets. The greeting
* line moved into the hero band (`<DashboardHero>`), which derives it
* from the snapshot's server-computed `greetingHour`.
* line lives in the hero band (`<DashboardHero>`) when that renders —
* but the hero is optional (snapshot flag off, or hidden via the
* dashboard-layout toggle), and the greeting must never disappear with
* it. `showGreeting` (fed from the page's hero gate) restores the
* pre-hero greeting paragraph under the title for exactly those mounts.
*
* 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, Pill, Plus, Settings2, Waves } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -19,19 +23,78 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import { cn } from "@/lib/utils";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
onQuickEntry,
showGreeting = false,
}: {
onQuickEntry: (dialog: NonNullable<QuickEntryDialog>) => void;
/** Render the greeting line here because the hero band does not. */
showGreeting?: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
// 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.
const userTimezone = mounted ? user?.timezone : undefined;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(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 (
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
/* With the greeting the title block is two lines on narrow
viewports: centre the buttons against it on mobile, top-align on
sm+ (the pre-hero posture). Without the greeting the single-line
centre alignment stands. */
<div
className={cn(
"flex items-center justify-between gap-4",
showGreeting && "sm:items-start",
)}
>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
{showGreeting ? (
/* `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. */
<p
data-slot="dashboard-header-greeting"
className="text-muted-foreground mt-1 min-h-5 text-sm"
>
{welcomeText}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
@@ -147,6 +147,44 @@ describe("<DashboardHero> — verdict variants", () => {
expect(html).toMatch(/<button[^>]*data-slot="dashboard-hero-cta"/);
});
it("doseOverdue: a null medication name renders the name-less sentence (no hole)", () => {
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
nextDueAt: isoHoursFromNow(-1),
nextDueOverdue: true,
nextDueMedicationName: null,
}),
}),
);
expect(html).toContain('data-verdict-variant="doseOverdue"');
expect(html).toContain("Eine Dosis ist überfällig.");
// The named template with an empty interpolation leaves a double
// space — it must never render.
expect(html).not.toContain("Eine Dosis ist überfällig.");
});
it("doseUpcoming: a null medication name renders the name-less sentence (no hole)", () => {
const nextDueAt = isoHoursFromNow(1.5); // 13:30 Berlin
const html = render(
baseSnapshot({
medsToday: medsToday({
scheduledToday: 2,
takenToday: 1,
nextDueAt,
nextDueMedicationName: null,
}),
}),
);
const expectedTime = makeFormatters("de", "Europe/Berlin", "AUTO").time(
nextDueAt,
);
expect(html).toContain('data-verdict-variant="doseUpcoming"');
expect(html).toContain(`Um ${expectedTime} steht eine Dosis an.`);
expect(html).not.toContain(`steht an`);
});
it("bpCritical: fixed-floor sentence with the reading + link to the BP insight", () => {
const html = render(
baseSnapshot({
@@ -20,7 +20,7 @@
* Defensive contract (cached snapshot): `medsToday.nextDueAt` in the
* past with `nextDueOverdue: false` means the slot's anchor passed
* after the snapshot was built. The verdict resolver already falls
* through (rung 1 keys on the flag, rung 3 requires a future anchor),
* through (rung 2 keys on the flag, rung 3 requires a future anchor),
* and the dose row below only prints "next at" for a FUTURE anchor on
* the user's local today — the stale state renders as the plain day
* summary, never as overdue.
@@ -134,11 +134,23 @@ export function DashboardHero({
: t("dashboard.welcomeBack", { greeting: timeGreeting });
// ── Verdict sentence + CTA ──────────────────────────────────────────
// The dose variants interpolate a medication name; a snapshot can carry
// none (the resolver emits `""`), and the named sentence would render
// with a hole ("Eine Dosis ist überfällig."). Swap to the name-less
// sibling key instead of patching a generic word into the gap.
const verdictName =
typeof verdict.values.name === "string" ? verdict.values.name.trim() : "";
const messageKey = (variant: Exclude<DashboardVerdictVariant, "briefing">) =>
variant === "doseOverdue" && verdictName.length === 0
? "dashboard.hero.verdict.doseOverdueNoName"
: variant === "doseUpcoming" && verdictName.length === 0
? "dashboard.hero.verdict.doseUpcomingNoName"
: VERDICT_MESSAGE_KEY[variant];
const sentence =
verdict.variant === "briefing"
? // Model headline VERBATIM as a plain text child — no key.
String(verdict.values.headline ?? "")
: t(VERDICT_MESSAGE_KEY[verdict.variant], {
: t(messageKey(verdict.variant), {
...verdict.values,
// The resolver emits a fixed 24 h wall-clock string; re-format
// the underlying instant through the user's hour-cycle
@@ -25,9 +25,10 @@ import {
} from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toBerlinDate,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
/**
@@ -129,12 +130,18 @@ export function pickDefaultMedicationId(
lateMinutes: 120,
missedMinutes: 240,
},
/**
* v1.16.9 — the profile timezone the "due now" heuristic reasons in
* (the same source the cards use). Berlin stays the last-resort
* fallback for logged-out mounts and legacy fixtures.
*/
tz: string = "Europe/Berlin",
): string | null {
const actives = options.filter((m) => m.active);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const nowBerlin = toBerlinDate(now);
const nowLocal = toZonedDate(now, tz);
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
@@ -142,12 +149,13 @@ export function pickDefaultMedicationId(
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin,
nowBerlin: nowLocal,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
tz,
nextDue:
m.nextDueAt === undefined
? undefined
@@ -155,7 +163,7 @@ export function pickDefaultMedicationId(
? { at: new Date(nextDueMs), overdue: m.nextDueOverdue === true }
: null,
});
return status.status !== null && !status.takenEarly;
return status.status !== null && status.takenEarlyDaysAgo === null;
});
if (due) return due.id;
@@ -173,6 +181,11 @@ export function MedicationIntakeQuickAdd({
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({
queryKey: queryKeys.medications(),
@@ -229,8 +242,8 @@ export function MedicationIntakeQuickAdd({
const formId = useId();
const defaultMedicationId = useMemo(
() => pickDefaultMedicationId(medications),
[medications],
() => pickDefaultMedicationId(medications, new Date(), undefined, userTz),
[medications, userTz],
);
const medicationId = medicationOverride ?? defaultMedicationId ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
@@ -71,8 +71,8 @@ export interface MedicationCardBodyProps {
status: Exclude<MedicationWindowStatus, null>;
windowStart: string;
windowEnd: string;
/** v1.16.9 — day-scale early-take context (see the pill). */
takenEarly?: boolean;
/** v1.16.9 — day-scale last-dose context in whole days (see the pill). */
takenEarlyDaysAgo?: number | null;
} | null;
/**
@@ -180,15 +180,15 @@ export function MedicationCardBody({
overdue tier arrives with the compliance query, and a line
popping in late used to shift every row below it (CLS). */}
<div className="min-h-5">
{windowStatus?.takenEarly ? (
// v1.16.9 — taken-early context outranks the overdue line: a
{windowStatus?.takenEarlyDaysAgo != null ? (
// v1.16.9 — last-dose context outranks the overdue line: a
// day-scale dose already taken earlier in its period must never
// escalate into a take prompt (double-dose risk).
<MedicationStatusPill
status={windowStatus.status}
windowStart={windowStatus.windowStart}
windowEnd={windowStatus.windowEnd}
takenEarly
takenEarlyDaysAgo={windowStatus.takenEarlyDaysAgo}
/>
) : overdueLabel ? (
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
@@ -10,13 +10,15 @@ interface MedicationStatusPillProps {
windowStart: string;
windowEnd: string;
/**
* v1.16.9 — day-scale early-take context. When true the dose was
* v1.16.9 — day-scale last-dose context. Non-null when a dose was
* already taken earlier in the cadence period (a weekly shot days
* before its slot day), so the pill renders a calm "taken early" note
* instead of the take-now / overdue prompt — prompting a full take on
* the slot day would be a double-dose prompt.
* before its slot day, or the previous slot served late); the pill
* then renders a calm factual "last dose {n} days ago" note instead
* of the take-now / overdue prompt — prompting a full take on the
* slot day would be a double-dose prompt. The value is the whole
* local days since that take.
*/
takenEarly?: boolean;
takenEarlyDaysAgo?: number | null;
}
/**
@@ -31,16 +33,18 @@ export function MedicationStatusPill({
status,
windowStart,
windowEnd,
takenEarly = false,
takenEarlyDaysAgo = null,
}: MedicationStatusPillProps) {
const { t, locale } = useTranslations();
if (takenEarly) {
if (takenEarlyDaysAgo != null) {
return (
<p className="text-sm">
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("medications.takenEarly")}
{takenEarlyDaysAgo === 1
? t("medications.lastDoseYesterday")
: t("medications.lastDoseDaysAgo", { count: takenEarlyDaysAgo })}
</span>
<span className="text-muted-foreground hidden sm:inline">
{" "}
@@ -446,7 +446,7 @@ export function Glp1MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarly: currentWindowStatus.takenEarly,
takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo,
}
: null
}
@@ -238,7 +238,10 @@ export function MedicationCard({
medication.nextDueAt === undefined
? undefined
: nextAt !== undefined
? { at: new Date(nextAt), overdue: medication.nextDueOverdue === true }
? {
at: new Date(nextAt),
overdue: medication.nextDueOverdue === true,
}
: null,
});
@@ -389,14 +392,16 @@ export function MedicationCard({
// legacy schedule window (which may be stale / degenerate).
windowStart: currentWindowStatus.window!.start,
windowEnd: currentWindowStatus.window!.end,
takenEarly: currentWindowStatus.takenEarly,
takenEarlyDaysAgo: currentWindowStatus.takenEarlyDaysAgo,
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
lastLine={
medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null
medication.lastTakenAt
? formatLastTakenAt(medication.lastTakenAt)
: null
}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
@@ -293,7 +293,7 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
const de = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(de).toContain("Tagesüberblick");
expect(de).toContain(
"Zeigt Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
"Zeigt Begrüßung, Score, Tagesfokus und Dosen-Status ganz oben auf dem Dashboard.",
);
const en = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(en).toContain("Daily overview");