mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user