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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:46:35 +02:00
1 parent a3561b4600
commit 651d37b494
9 files changed
+422 -66

No files matched your search

@@ -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();
});
});
+10
View File
@@ -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
+3
View File
@@ -967,6 +967,9 @@ export default function DashboardPageClient({
<TodayHero
digest={digestQuery.data}
renderFilteredAllClear={renderFilteredHeroAllClear}
// Server-persisted hero choice — rides the same resolved layout
// as the widget visibility below, so SSR and hydration agree.
primaryContent={layout.hero ?? "score"}
/>
) : !mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
@@ -321,4 +321,67 @@ describe("<TodayHero>", () => {
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(
<TodayHero digest={digest()} primaryContent="reminders" />,
);
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(<TodayHero digest={digest()} primaryContent="score" />);
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(
<TodayHero
digest={digest({ worthALook: [] })}
primaryContent="reminders"
/>,
);
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(
<TodayHero
digest={digest({ phase: "provisional", sleepPending: true })}
primaryContent="reminders"
/>,
);
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(
<TodayHero
digest={digest({
score: null,
topSignal: null,
briefingLead: null,
worthALook: [],
})}
primaryContent="reminders"
/>,
);
expect(html).toBe("");
});
});
+100 -63
View File
@@ -33,6 +33,14 @@
* sync.reconnect → /settings/integrations, checkup.view → /checkups), so
* `PriorityCard` wires each tap as a `<Link>` 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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null;
/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. */
const rail = hasItems ? (
<div className="space-y-2" data-slot="today-hero-rail">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("daily.today.worthALook")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={canManage ? handleAction : undefined}
onDismiss={
canManage ? (itemKey) => dismissItem.mutate(itemKey) : undefined
}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
}
/>
))}
</div>
</div>
) : 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 (
<section
data-slot="today-hero"
data-phase={digest.phase}
data-layout="reminders"
className={heroShellClassName}
>
<div className="flex flex-col gap-3 md:gap-4">
{rail ?? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
)}
{sleepPendingNote}
</div>
</section>
);
}
return (
<section
data-slot="today-hero"
data-phase={digest.phase}
data-layout={compactAllClear ? "compact-all-clear" : "narrative"}
className={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",
)}
className={heroShellClassName}
>
{/* 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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
{compactAllClear ? sleepPendingNote : null}
</div>
{/* 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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : 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 ? (
<div className="space-y-2" data-slot="today-hero-rail">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("daily.today.worthALook")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={canManage ? handleAction : undefined}
onDismiss={
canManage
? (itemKey) => dismissItem.mutate(itemKey)
: undefined
}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
}
/>
))}
</div>
</div>
) : !compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : null}
{rail ??
(!compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : null)}
</div>
</section>
);
@@ -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 }) {
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
{t("dashboard.heroItemsDescription")}
</p>
{/* 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. */}
<div className="space-y-1.5">
<Label
htmlFor={heroContentSelectId}
className="text-sm font-medium"
>
{t("dashboard.heroContentTitle")}
</Label>
<NativeSelect
id={heroContentSelectId}
value={layout.hero ?? "score"}
onChange={(event) => setHeroContent(event.target.value)}
disabled={saveMutation.isPending}
data-slot="hero-content-select"
className="sm:max-w-xs"
>
<option value="score">{t("dashboard.heroContentScore")}</option>
<option value="reminders">
{t("dashboard.heroContentReminders")}
</option>
</NativeSelect>
</div>
<div className="divide-border divide-y">
{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")}
</p>
@@ -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");
});
});
+42
View File
@@ -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<keyof DashboardLayout, "replace" | "preserve">;
/**
@@ -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 }),
};
}
+9 -2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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: