mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user