", () => {
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 ? (
+
+
+
+ {t("daily.today.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 ? (
-
-
-
- {t("daily.today.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 ? (
-
-
-
- {t("daily.today.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. */}
+
+
+ {t("dashboard.heroContentTitle")}
+
+ setHeroContent(event.target.value)}
+ disabled={saveMutation.isPending}
+ data-slot="hero-content-select"
+ className="sm:max-w-xs"
+ >
+ {t("dashboard.heroContentScore")}
+
+ {t("dashboard.heroContentReminders")}
+
+
+
{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: