From 651d37b494f90266924a01d5e0fd61def7a25429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:46:35 +0200 Subject: [PATCH] Let the dashboard hero show what matters most to its owner The hero card composition was fixed: score panel first, the worth-a-look rail beneath. A reminder-driven person asked for the reverse. The stored dashboard layout gains an optional hero field ('score' default, 'reminders' promotes the rail into the hero slot), validated as a closed enum, preserve-when-absent on write, omitted from the blob at the default so untouched accounts serialize byte-identically. The choice sits in Settings beside the other hero controls; the promoted rail degrades to the calm all-clear line when nothing is due. --- .../dashboard/widgets/__tests__/route.test.ts | 90 ++++++++++ src/app/api/dashboard/widgets/route.ts | 10 ++ src/app/page-client.tsx | 3 + .../daily/__tests__/today-hero.test.tsx | 63 +++++++ src/components/daily/today-hero.tsx | 163 +++++++++++------- .../settings/dashboard-layout-section.tsx | 42 ++++- src/lib/__tests__/dashboard-layout.test.ts | 64 +++++++ src/lib/dashboard-layout.ts | 42 +++++ src/lib/openapi/routes/dashboard.ts | 11 +- 9 files changed, 422 insertions(+), 66 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 095282ea7..03fcb3375 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect(body.data.updatedAt).toBe(advanced.toISOString()); }); }); + +describe("dashboard widgets — hero primary content", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + it("accepts and persists hero: 'reminders'", 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: weightOnly, hero: "reminders" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBe("reminders"); + }); + + it("rejects a hero value outside the closed enum with 422", 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: weightOnly, hero: "banner" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps the stored hero choice when the client omits the field", async () => { + // A layout save from a client that predates the field (the native + // client, or a stale web tab) must not silently reset the choice. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, widgets: weightOnly })); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders"); + }); + + it("honours an explicitly sent 'score' over the stored 'reminders'", async () => { + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + hero: "reminders", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, hero: "score" }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { hero?: string } }; + }; + // "score" is the default and serializes as an omitted field. + expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined(); + + const body = (await res.json()) as { data: { hero?: string } }; + expect(body.data.hero).toBeUndefined(); + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 4d8514d04..7cd00455e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -31,6 +31,7 @@ import { MAX_SELECTED_SCORE_RINGS, HERO_RING_IDS, MAX_HERO_RING_ORDER, + HERO_PRIMARY_CONTENTS, layoutNeedsPreserveRead, mergePreservedLayoutFields, type DashboardLayout, @@ -158,6 +159,15 @@ const layoutSchema = z.object({ .array(z.enum(PRIORITY_ITEM_KINDS)) .max(PRIORITY_ITEM_KINDS.length) .optional(), + // Hero primary content — what the dashboard hero card leads with: + // "score" (default) or "reminders" (the worth-a-look rail promoted into + // the hero slot). This widens the stored `dashboardWidgetsJson` shape + // ADDITIVELY: the field is optional on the wire and omitted from storage + // at the default, so the native client (which mirrors this blob) can + // keep round-tripping layouts untouched until it learns the field. + // Same preserve-when-absent contract as `comparisonBaseline`; the + // resolver drops unknown stored values back to "score" on read. + hero: z.enum(HERO_PRIMARY_CONTENTS).optional(), // v1.32.16 (issue #581) — the optimistic-concurrency base token used to // live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper, // which strips it BEFORE this parse: the token is transport, not a layout diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 5287c60f8..408330c7e 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -967,6 +967,9 @@ export default function DashboardPageClient({ ) : !mounted || digestQuery.isLoading ? ( diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 5be773a7e..cfb1a57e2 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -321,4 +321,67 @@ describe("", () => { expect(html).toContain('style="width:168px;height:168px"'); expect(html).not.toContain('data-layout="compact-all-clear"'); }); + + // The hero primary-content preference (`hero` on the dashboard layout + // blob): "reminders" promotes the worth-a-look rail into the hero slot; + // the score composition stays the default. + it("promotes the rail into the hero slot when the preference is reminders", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-layout="reminders"'); + expect(html).toContain('data-slot="today-hero-rail"'); + expect(html).toContain('data-kind="dose_window"'); + expect(html).toContain('data-kind="sync_issue"'); + // The score composition yields the slot entirely. + expect(html).not.toContain('data-slot="today-hero-score"'); + expect(html).not.toContain('data-slot="today-hero-lead"'); + expect(html).not.toContain('data-slot="today-hero-signal"'); + }); + + it("keeps the score composition when the preference is the default score", () => { + const html = render(); + expect(html).toContain('data-slot="today-hero-score"'); + expect(html).toContain('data-slot="today-hero-rail"'); + expect(html).not.toContain('data-layout="reminders"'); + }); + + it("shows the calm all-clear line when reminders mode has nothing to surface", () => { + const html = render( + , + ); + expect(html).toContain('data-layout="reminders"'); + expect(html).toContain('data-slot="today-hero-all-clear"'); + expect(html).not.toContain('data-slot="today-hero-rail"'); + expect(html).not.toContain('data-slot="today-hero-score"'); + }); + + it("keeps the honest sleep-pending note in reminders mode", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + }); + + it("still degrades to nothing on a genuinely empty account in reminders mode", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 7a88dc02f..e7632e055 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -33,6 +33,14 @@ * sync.reconnect → /settings/integrations, checkup.view → /checkups), so * `PriorityCard` wires each tap as a `` to its existing destination * by construction; S2 invents no new backend action. + * + * Configurable primary content: the `hero` field on the dashboard layout + * blob (server-persisted, Settings → Dashboard) chooses what this card + * leads with. `"score"` keeps the composition above verbatim; + * `"reminders"` promotes the worth-a-look rail into the hero slot — the + * rail (or the calm all-clear line) becomes the card's content and the + * score composition yields entirely. The empty-account degrade and the + * freshness note are shared by both modes. */ import Link from "next/link"; import { Moon } from "lucide-react"; @@ -47,6 +55,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { DailyDigest } from "@/lib/daily/digest"; +import type { HeroPrimaryContent } from "@/lib/dashboard-layout"; import { COACH_CHECKIN_KEEP_INTENT, COACH_CHECKIN_LETGO_INTENT, @@ -88,9 +97,12 @@ function withoutRepeatedScore( export function TodayHero({ digest, renderFilteredAllClear = false, + primaryContent = "score", }: { digest: DailyDigest; renderFilteredAllClear?: boolean; + /** Server-persisted hero choice from the dashboard layout blob. */ + primaryContent?: HeroPrimaryContent; }) { const { t } = useTranslations(); // The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element @@ -182,20 +194,88 @@ export function TodayHero({ return null; } + // Shared fragments — identical markup in every composition, so the + // "score" and "reminders" modes cannot drift apart on either surface. + const sleepPendingNote = digest.sleepPending ? ( +
+

+

+
+ ) : null; + + /* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. */ + const rail = hasItems ? ( +
+

+ {t("daily.today.worthALook")} +

+
+ {digest.worthALook.map((item, i) => ( + dismissItem.mutate(itemKey) : undefined + } + actionsPending={ + item.kind === "coach_checkin" && + (keep.isPending || letGo.isPending) + } + /> + ))} +
+
+ ) : null; + + const heroShellClassName = cn( + // The tile strip's surface plus the ONE sanctioned Today atmosphere: + // `.today-hero-wash` leans a faint `--primary` mix over the theme + // `--card` toward the ring corner (the `.wellness-tile` color-mix + // pattern, softer than the insights hero) so the promoted day's read + // carries a quiet identity without a banner gradient or glow. + "bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border", + "p-4 md:p-6", + ); + + // Reminders-first composition — the rail IS the hero. The score + // composition yields the slot entirely (the ring keeps its home on + // /insights); an empty rail degrades to the same calm all-clear line + // the score mode uses, never an empty card. + if (primaryContent === "reminders") { + return ( +
+
+ {rail ?? ( +

+ {t("daily.today.allClear")} +

+ )} + {sleepPendingNote} +
+
+ ); + } + return (
{/* v1.29.1 — tightened after the v1.29.0 ring cluster was removed: the hero read as half-empty (short lead → gap → rail). Compact section @@ -252,17 +332,7 @@ export function TodayHero({ {/* In the compact fallback the quiet freshness tier belongs next to the smaller ring. That uses the otherwise-empty leading space and avoids adding a second row below the dial. */} - {compactAllClear && digest.sleepPending ? ( -
-

-

-
- ) : null} + {compactAllClear ? sleepPendingNote : null} {/* Health score ring — `flat` (no sweep/bloom), server-computed @@ -309,53 +379,20 @@ export function TodayHero({ opacity modifier). Freshness note (plan §2.4) — provisional day, last night's sleep not yet folded in. Muted, non-blocking, refreshes in place when the morning job lands. */} - {!compactAllClear && digest.sleepPending ? ( -
-

-

-
- ) : null} + {!compactAllClear ? sleepPendingNote : null} {/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the digest is all-clear, a first-class muted line stands in for the rail (calm inversion of an alarm), never an empty card. */} - {hasItems ? ( -
-

- {t("daily.today.worthALook")} -

-
- {digest.worthALook.map((item, i) => ( - dismissItem.mutate(itemKey) - : undefined - } - actionsPending={ - item.kind === "coach_checkin" && - (keep.isPending || letGo.isPending) - } - /> - ))} -
-
- ) : !compactAllClear ? ( -

- {t("daily.today.allClear")} -

- ) : null} + {rail ?? + (!compactAllClear ? ( +

+ {t("daily.today.allClear")} +

+ ) : null)}
); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 7b475bb01..0ee6013a2 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -32,6 +32,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -207,6 +208,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const dragHintId = useId(); const heroDescriptionId = useId(); const heroNotificationNoteId = useId(); + const heroContentSelectId = useId(); // v1.34 — the "Today highlights" fieldset below is gated on `layout`, // which can be truthy on the very first client render whenever a // returning visitor's browser already has a warm, offline-persisted @@ -393,6 +395,19 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** + * Hero primary content — the server-persisted `hero` field on the layout + * blob. The value set is closed ("score" | "reminders"); anything else a + * DOM event could carry collapses to the default rather than a cast. + */ + function setHeroContent(value: string) { + if (!layout) return; + setDraft({ + ...layout, + hero: value === "reminders" ? "reminders" : "score", + }); + } + /** * v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()` * so the swap-and-renumber logic lives in exactly one place. The @@ -459,6 +474,30 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{t("dashboard.heroItemsDescription")}

+ {/* Hero primary content — a single labelled select beside the + highlight toggles. "score" keeps the health-score read; + "reminders" promotes the highlight rail into the hero slot. */} +
+ + setHeroContent(event.target.value)} + disabled={saveMutation.isPending} + data-slot="hero-content-select" + className="sm:max-w-xs" + > + + + +
{PRIORITY_ITEM_KINDS.map((kind) => { const inputId = `hero-item-${kind}`; @@ -733,7 +772,8 @@ export function DashboardLayoutSection({ id }: { id: string }) { JSON.stringify(layout.widgets) === JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) && JSON.stringify(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS) === - JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) + JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) && + (layout.hero ?? "score") === DEFAULT_DASHBOARD_LAYOUT.hero ? t("dashboard.layoutUsingDefaults") : t("dashboard.layoutCustomized")}

diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index b52d0cec9..79576b185 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -1060,6 +1060,7 @@ describe("layout field merge disposition", () => { selectedScoreRings: ["MED_COMPLIANCE"], heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"], enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + hero: "reminders", }; it("assigns a disposition to every top-level layout field", () => { @@ -1083,6 +1084,7 @@ describe("layout field merge disposition", () => { "chartOverlayPrefs", "comparisonBaseline", "enabledHeroItemKinds", + "hero", "heroRingOrder", "selectedScoreRings", "widgets", @@ -1162,3 +1164,65 @@ describe("layout field merge disposition", () => { expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets); }); }); + +/** + * Configurable hero primary content. The `hero` field chooses what the + * dashboard hero card leads with: the health-score read ("score", the + * long-standing default) or the Today-highlight rail promoted into the + * hero slot ("reminders"). Closed two-value enum, preserve-when-absent + * like the other layout preferences, unknown values dropped on read. + */ +describe("resolveDashboardLayout() — hero primary content", () => { + const baseWidgets: DashboardLayout["widgets"] = [ + { id: "weight", visible: true, tileVisible: true, order: 0 }, + ]; + + it("defaults to 'score' for legacy layouts (no field saved)", () => { + const layout = resolveDashboardLayout({ version: 1, widgets: baseWidgets }); + expect(layout.hero).toBe("score"); + }); + + it("defaults to 'score' on the default layout", () => { + expect(DEFAULT_DASHBOARD_LAYOUT.hero).toBe("score"); + expect(resolveDashboardLayout(null).hero).toBe("score"); + }); + + it("respects an explicit 'reminders' choice", () => { + const layout = resolveDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "reminders", + }); + expect(layout.hero).toBe("reminders"); + }); + + it("drops unknown hero values back to 'score'", () => { + for (const garbage of ["banner", 7, null, {}, ["reminders"]]) { + const layout = resolveDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: garbage, + }); + expect(layout.hero).toBe("score"); + } + }); + + it("omits the default 'score' from serialization", () => { + const serialized = serializeDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "score", + }); + expect("hero" in serialized).toBe(false); + }); + + it("persists 'reminders' through serialize → resolve round-trip", () => { + const serialized = serializeDashboardLayout({ + version: 1, + widgets: baseWidgets, + hero: "reminders", + }); + expect(serialized.hero).toBe("reminders"); + expect(resolveDashboardLayout(serialized).hero).toBe("reminders"); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0b97c2902..9bd51187f 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -554,6 +554,29 @@ function coerceSelectedScoreRings(value: unknown): ScoreRingId[] { return out; } +/** + * Configurable hero primary content — what the dashboard hero card leads + * with. `"score"` is the long-standing composition (health-score ring + + * the day's read); `"reminders"` promotes the worth-a-look rail into the + * hero slot. Closed two-value set, deliberately no further options. + * + * The preference piggy-backs on the layout blob like `comparisonBaseline` + * (a UI affordance, no Prisma migration). The resolver drops unknown + * values back to the default `"score"`; the serializer omits the default + * so an untouched account's stored blob is unchanged. + */ +export const HERO_PRIMARY_CONTENTS = ["score", "reminders"] as const; +export type HeroPrimaryContent = (typeof HERO_PRIMARY_CONTENTS)[number]; + +export const DEFAULT_HERO_PRIMARY_CONTENT: HeroPrimaryContent = "score"; + +function coerceHeroPrimaryContent(value: unknown): HeroPrimaryContent { + return typeof value === "string" && + (HERO_PRIMARY_CONTENTS as readonly string[]).includes(value) + ? (value as HeroPrimaryContent) + : DEFAULT_HERO_PRIMARY_CONTENT; +} + /** * Resolve the Today hero's item-kind visibility against the current closed * catalogue. Legacy or malformed values enable every current kind. Arrays are @@ -594,6 +617,14 @@ export interface DashboardLayout { * every current kind; an explicit empty array keeps the rail off. */ enabledHeroItemKinds?: PriorityItemKind[]; + /** + * Primary hero-card content: `"score"` (default — health-score ring plus + * the day's read) or `"reminders"` (the worth-a-look rail promoted into + * the hero slot). Optional and additive so clients that predate the + * field round-trip the blob untouched; unknown values resolve to + * `"score"`. + */ + hero?: HeroPrimaryContent; } /** @@ -627,6 +658,7 @@ export const LAYOUT_FIELD_MERGE_DISPOSITION = { selectedScoreRings: "preserve", heroRingOrder: "preserve", enabledHeroItemKinds: "preserve", + hero: "preserve", } as const satisfies Record; /** @@ -693,6 +725,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { selectedScoreRings: [...DEFAULT_SELECTED_SCORE_RINGS], heroRingOrder: [...DEFAULT_HERO_RING_ORDER], enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + hero: DEFAULT_HERO_PRIMARY_CONTENT, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 0 }, { id: "bp", visible: true, tileVisible: true, order: 1 }, @@ -863,6 +896,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout { enabledHeroItemKinds: coerceEnabledHeroItemKinds( candidate.enabledHeroItemKinds, ), + // Hero primary content: unknown / missing values resolve to the + // default "score" so a stale client cannot swap the hero to a + // composition the renderer doesn't know how to draw. + hero: coerceHeroPrimaryContent(candidate.hero), }; } @@ -875,6 +912,7 @@ export function serializeDashboardLayout( const enabledHeroItemKinds = coerceEnabledHeroItemKinds( layout.enabledHeroItemKinds, ); + const hero = coerceHeroPrimaryContent(layout.hero); return { version: DASHBOARD_LAYOUT_VERSION, widgets: layout.widgets @@ -911,5 +949,9 @@ export function serializeDashboardLayout( ...(enabledHeroItemKinds.length === PRIORITY_ITEM_KINDS.length ? {} : { enabledHeroItemKinds }), + // The default "score" is omitted from storage (same forward-compatible + // contract as `enabledHeroItemKinds`), so an untouched account's blob + // is byte-identical to the pre-field shape. + ...(hero === DEFAULT_HERO_PRIMARY_CONTENT ? {} : { hero }), }; } diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 1ea829b2b..34950a54d 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -22,6 +22,7 @@ import { CHART_OVERLAY_KEYS, SCORE_RING_IDS, HERO_RING_IDS, + HERO_PRIMARY_CONTENTS, } from "@/lib/dashboard-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { @@ -69,11 +70,17 @@ const dashboardLayoutSchema = z .describe( "Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds.", ), + hero: z + .enum(HERO_PRIMARY_CONTENTS) + .optional() + .describe( + 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.', + ), }) .meta({ id: "DashboardLayoutBody", description: - "Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, and `enabledHeroItemKinds` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.", + "Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, `enabledHeroItemKinds`, and `hero` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.", }); const dashboardLayoutPutBody = dashboardLayoutSchema @@ -153,7 +160,7 @@ export const dashboardWidgetPaths: NonNullable = { tags: ["Dashboard"], summary: "Reset the calling user's dashboard widget layout", description: - "Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, and hero ring order. Idempotent.", + "Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, hero ring order, and hero primary content. Idempotent.", responses: { "200": { description: