feat(dashboard): open the day with a verdict

The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 09:26:55 +02:00
1 parent ad33fa46ed
commit ea29e9cf1b
20 files changed
+1451 -94

No files matched your search

@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
*
* - it gates on the layout's `heroVisible` flag (anything but the
* literal `false` renders — the resolver's clamp) AND on snapshot
* mode (the verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph left the header for the hero. The header
* renders no name-bearing text at all any more, so the SSR pass
* stays name-free by construction (the hero itself only mounts
* once the snapshot resolved — post-hydration).
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
const HEADER_PATH = join(
process.cwd(),
"src/components/dashboard/dashboard-header.tsx",
);
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
);
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
});
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
expect(matches).toHaveLength(1);
});
it("the hero sits between the header and the strip", () => {
const headerIdx = src.indexOf("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header dropped the greeting paragraph and its reservation", () => {
expect(header).not.toContain("welcomeText");
expect(header).not.toContain("min-h-5");
});
it("the header keeps the title + customize + add actions", () => {
expect(header).toContain('t("dashboard.title")');
expect(header).toContain("dashboard-customize-shortcut");
expect(header).toContain("dashboard-quick-add");
});
});
@@ -74,15 +74,14 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The greeting hour only changes when the user's timezone changes
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
// local one line above the `useMemo` so the dep array stays stable.
expect(src).toMatch(
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
});
+45 -52
View File
@@ -36,12 +36,13 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { DashboardHero } from "@/components/dashboard/hero/dashboard-hero";
import { DashboardHeroSkeleton } from "@/components/dashboard/hero/dashboard-hero-skeleton";
import {
QuickEntrySheets,
type QuickEntryDialog,
} from "@/components/dashboard/quick-entry-sheets";
import {
getHourForTimeZone,
getRangeColorClass,
getRangeHint,
toHoursSummary,
@@ -684,42 +685,49 @@ export default function DashboardPage() {
upperBound: 55,
},
);
// v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat`
// instantiation (audit-H4). The greeting derived from the hour only
// changes when the user's timezone changes; recomputing it every
// render churned the parts-array and walked the format ICU twice per
// render across a 1 400-line component body.
// Gated on `mounted`: `user` comes from the auth query, which can
// resolve before this boundary hydrates — the greeting 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 });
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
// v1.16.9 — hoisted out of the strip IIFE: the hero band below
// shares the same gate so its skeleton and the tile silhouettes
// swap to content in the same render pass.
const primaryLoading =
!mounted ||
(snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading);
// v1.16.9 — the greeting paragraph moved off the header into the
// hero band (it derives from the snapshot's server-computed
// `greetingHour`, so the old client-side Intl hour walk retired
// with it). The hero gates on the layout's `heroVisible` flag
// (default on; anything but the literal `false` renders) and holds
// a footprint-identical skeleton while the snapshot is in flight,
// so the band swaps in place — no growth, no greeting flash.
const heroVisible = snapshotEnabled && layout.heroVisible !== false;
const heroSnapshot = snapshotQuery.data;
return (
<div className="space-y-6">
<DashboardHeader
welcomeText={welcomeText}
onQuickEntry={setQuickEntryDialog}
/>
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{heroVisible &&
(primaryLoading || !heroSnapshot ? (
<DashboardHeroSkeleton />
) : (
<DashboardHero
snapshot={heroSnapshot}
onQuickEntry={setQuickEntryDialog}
/>
))}
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
@@ -1403,24 +1411,9 @@ export default function DashboardPage() {
// strip tile) and undercounted `bp` (one widget id, two tiles),
// so the silhouettes reshuffled when the data landed.
const configuredTileCount = resolveConfiguredTileCount(layout);
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
// always `false`. Gating the skeleton on the slim query then
// never fires under snapshot mode, so the empty-state branch
// below flashes for the whole snapshot fetch. Key the loading
// flag off whichever query is actually driving the tiles.
// v1.16.4 — `!mounted` pins the SSR pass AND the hydration
// render to the skeleton branch. Server-side the data queries
// are `enabled: false` (no auth) → `isLoading: false`, which
// used to select the empty state; a late-hydrating boundary
// then saw `isAuthenticated` already resolved and rendered the
// skeleton instead — React #418. See `useMounted`.
const primaryLoading =
!mounted ||
(snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading);
// `primaryLoading` is derived once in the component body (above
// the return) — the hero band and this strip share the same
// gate so both surfaces swap in the same render pass.
// v1.16.8 — chart-row reservation while the snapshot is in
// flight. The chart visibility gates above need snapshot data
// (`count > 0`), so the cold chart row only carried the