refactor(dashboard): retire the legacy opt-in hero

The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 21:34:06 +02:00
1 parent d6ad76b1e1
commit 1d592bef78
29 files changed
+183 -2719

No files matched your search

+30 -49
View File
@@ -5,19 +5,16 @@ import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
* The legacy opt-in `DashboardHero` (gated on a layout `heroVisible`
* flag) was retired: the digest-driven `TodayHero` is the single,
* unambiguous hero, mounted above the tile strip. Pinned here:
*
* - it gates on the layout's `heroVisible` flag (only the literal
* `true` renders — the hero is opt-in) 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 lives in the hero when that renders; when
* the hero does NOT render (snapshot flag off, or hidden via the
* layout toggle) the header takes it back behind `showGreeting`,
* fed from the page's hero gate — the greeting never disappears.
* - the page mounts `TodayHero` and no longer references the legacy
* hero, its skeleton, or the `heroVisible` gate;
* - the greeting paragraph lives in the header on every mount (the
* header owns it unconditionally now that the legacy hero — which
* once carried it — is gone), while the page itself derives no
* welcome text.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
const HEADER_PATH = join(
@@ -28,60 +25,44 @@ const HEADER_PATH = join(
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*true;/,
);
});
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 &&");
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(heroIdx).toBeGreaterThan(-1);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
expect(src).not.toContain("heroVisible");
});
it("the page derives no welcome text (the header owns the greeting)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
describe("dashboard hero — header greeting", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
// The greeting renders here ONLY when the hero band does not — the
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{\s*showGreeting\s*\?/);
it("renders the greeting unconditionally (no showGreeting gate)", () => {
// The greeting line renders on every mount — the legacy hero that
// once owned it is gone, so there is no `showGreeting` prop to gate
// it. The `min-h-5` line-box reservation keeps the header stable
// through the post-hydration name swap.
expect(header).not.toContain("showGreeting");
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
it("the page feeds showGreeting from the inverted hero gate", () => {
it("the page mounts the header without a greeting prop", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
expect(src).toContain(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
});
it("the header keeps the title + customize + add actions", () => {
@@ -77,10 +77,10 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// 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.
// moved off the page with the greeting itself: the greeting now
// lives in the header (`dashboard-header.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");
});
@@ -407,132 +407,6 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to false for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+4 -16
View File
@@ -121,14 +121,8 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
// v1.27.7 — hero score rings (max 3, closed id set). Optional with the
// same preserve-when-absent contract as `heroVisible`; the resolver
// same preserve-when-absent contract as `chartOverlayPrefs`; the resolver
// dedupes on read/serialize, `.max()` bounds the wire length.
selectedScoreRings: z
.array(z.enum(SCORE_RING_IDS))
@@ -284,19 +278,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save. `heroVisible` and
// `selectedScoreRings` ride the same preserve-when-absent contract —
// an older client's layout save must not reset either choice. One
// wiped here on a subsequent layout save. `selectedScoreRings` and
// `heroRingOrder` ride the same preserve-when-absent contract — an
// older client's layout save must not reset either choice. One
// stored-layout read covers all fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
let mergedScoreRings: ScoreRingId[] | undefined =
parsed.data.selectedScoreRings;
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined ||
mergedScoreRings === undefined ||
mergedHeroRingOrder === undefined
) {
@@ -310,9 +302,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible === true;
}
if (mergedScoreRings === undefined) {
mergedScoreRings = existingLayout.selectedScoreRings;
}
@@ -323,7 +312,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
selectedScoreRings: mergedScoreRings,
heroRingOrder: mergedHeroRingOrder,
} as DashboardLayout);
+2 -2
View File
@@ -4,7 +4,7 @@
* GET returns the resolved presentation (view + manual order, defaults
* merged in if the user hasn't customised yet). PUT updates it with
* preserve-when-absent semantics — a view-only PUT keeps the stored
* order and vice versa, matching the `heroVisible` contract on
* order and vice versa, matching the `chartOverlayPrefs` contract on
* `/api/dashboard/widgets`. DELETE resets to default.
*
* Mirrors the shape and semantics of `/api/insights/layout`; the blob
@@ -42,7 +42,7 @@ const layoutSchema = z.object({
version: z.literal(1),
// Both fields are optional so a client can PUT exactly the field it
// changed; the handler merges the absent one from the stored blob
// (preserve-when-absent, like `heroVisible` on the dashboard layout).
// (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout).
view: z.enum(MEDICATION_LIST_VIEWS).optional(),
// Medication ids are opaque here — display-only ordering, unknown /
// deleted ids are ignored at apply time, so the schema bounds size
+1 -29
View File
@@ -43,8 +43,6 @@ 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 { TodayHero } from "@/components/daily/today-hero";
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
import {
@@ -766,25 +764,9 @@ export default function DashboardPageClient() {
!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
// (opt-in; only the literal `true` renders) and holds
// a footprint-identical skeleton while the snapshot is in flight,
// so the band swaps in place — no growth, no greeting flash. When
// the hero does NOT render (toggle off, or snapshot flag off) the
// header takes the greeting back — it must never disappear with the
// band.
const heroVisible = snapshotEnabled && layout.heroVisible === true;
const heroSnapshot = snapshotQuery.data;
return (
<div className="space-y-6">
<DashboardHeader
onQuickEntry={setQuickEntryDialog}
showGreeting={!heroVisible}
/>
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
decision 1). Renders the S1 daily digest: the day's read, the
@@ -804,16 +786,6 @@ export default function DashboardPageClient() {
<TodayHero digest={digestQuery.data} />
) : null)}
{heroVisible &&
(primaryLoading || !heroSnapshot ? (
<DashboardHeroSkeleton />
) : (
<DashboardHero
snapshot={heroSnapshot}
onQuickEntry={setQuickEntryDialog}
/>
))}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
* (`AuthShell`) so its overlay survives the cross-page
* `router.push`es the module tour makes. The dashboard no longer