feat(dashboard): the daily-overview band becomes opt-in

The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 19:03:32 +02:00
1 parent 7eeab2ae98
commit 8a5a26b49a
8 files changed
+51 -50

No files matched your search

@@ -7,9 +7,9 @@ import { join } from "node:path";
*
* The hero band mounts between the page header and the tile strip:
*
* - it gates on the layout's `heroVisible` flag (anything but the
* literal `false` renders — the resolver's clamp) AND on snapshot
* mode (the verdict needs the snapshot payload);
* - it gates on the layout's `heroVisible` flag (only the literal
* `true` renders — the hero is opt-in) AND on snapshot mode (the
* verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
@@ -30,7 +30,7 @@ describe("dashboard hero — page wiring", () => {
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*!==\s*false;/,
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/,
);
});
@@ -477,7 +477,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to true for a first save with no stored layout", async () => {
it("defaults heroVisible to false for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
@@ -495,7 +495,7 @@ describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(true);
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("422s on a non-boolean heroVisible", async () => {
+1 -1
View File
@@ -283,7 +283,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible !== false;
mergedHeroVisible = existingLayout.heroVisible === true;
}
}
const normalized = serializeDashboardLayout({
+2 -2
View File
@@ -709,13 +709,13 @@ export default function DashboardPage() {
// hero band (it derives from the snapshot's server-computed
// `greetingHour`, so the old client-side Intl hour walk retired
// with it). The hero gates on the layout's `heroVisible` flag
// (default on; anything but the literal `false` renders) and holds
// (opt-in; only the literal `true` renders) and holds
// a footprint-identical skeleton while the snapshot is in flight,
// so the band swaps in place — no growth, no greeting flash. When
// the hero does NOT render (toggle off, or snapshot flag off) the
// header takes the greeting back — it must never disappear with the
// band.
const heroVisible = snapshotEnabled && layout.heroVisible !== false;
const heroVisible = snapshotEnabled && layout.heroVisible === true;
const heroSnapshot = snapshotQuery.data;
return (
@@ -299,17 +299,7 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
expect(en).toContain("Daily overview");
});
it("reflects heroVisible: default layout → checked, false → unchecked", () => {
const checkedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
const checked = checkedHtml.match(
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
);
expect(checked).not.toBeNull();
expect(checked![0]).toContain('data-state="checked"');
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: false };
it("reflects heroVisible: default layout → unchecked, true → checked", () => {
const uncheckedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
@@ -318,6 +308,16 @@ describe("<DashboardLayoutSection> — hero visibility switch", () => {
);
expect(unchecked).not.toBeNull();
expect(unchecked![0]).toContain('data-state="unchecked"');
queryState.layout = { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true };
const checkedHtml = render(
<DashboardLayoutSection id="dashboard-layout" />,
);
const checked = checkedHtml.match(
/<button[^>]*data-slot="hero-visible-switch"[^>]*>/,
);
expect(checked).not.toBeNull();
expect(checked![0]).toContain('data-state="checked"');
});
it("persists through the existing PUT draft flow (source pins)", () => {
@@ -408,7 +408,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
<Switch
id={heroToggleId}
checked={layout.heroVisible !== false}
checked={layout.heroVisible === true}
onCheckedChange={(v) => setHeroVisible(v)}
disabled={saveMutation.isPending}
data-slot="hero-visible-switch"
+20 -19
View File
@@ -380,59 +380,60 @@ describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
/**
* Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs
* on the layout blob like the B8 comparison baseline. Resolver contract:
* anything that is not the literal `false` clamps back to `true`
* (default-on for legacy blobs; a stale client cannot poison the field
* with a non-boolean). Serializer persists the resolved boolean
* explicitly so a re-read never has to guess the default.
* anything that is not the literal `true` clamps back to `false`
* (the hero is opt-in; legacy blobs keep it off and a stale client
* cannot poison the field with a non-boolean). Serializer persists the
* resolved boolean explicitly so a re-read never has to guess the
* default.
*/
describe("resolveDashboardLayout() — heroVisible", () => {
it("defaults to true for legacy layouts (no field saved)", () => {
it("defaults to false for legacy layouts (no field saved)", () => {
const legacy = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(resolveDashboardLayout(legacy).heroVisible).toBe(true);
expect(resolveDashboardLayout(legacy).heroVisible).toBe(false);
});
it("respects an explicit heroVisible: false", () => {
it("respects an explicit heroVisible: true", () => {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: false,
heroVisible: true,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(false);
expect(resolveDashboardLayout(saved).heroVisible).toBe(true);
});
it("clamps non-boolean values back to true", () => {
for (const poisoned of ["false", 0, null, 1, {}]) {
it("clamps non-boolean values back to false", () => {
for (const poisoned of ["true", 0, null, 1, {}]) {
const saved = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
heroVisible: poisoned,
};
expect(resolveDashboardLayout(saved).heroVisible).toBe(true);
expect(resolveDashboardLayout(saved).heroVisible).toBe(false);
}
});
it("defaults heroVisible to true in DEFAULT_DASHBOARD_LAYOUT", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(true);
it("defaults heroVisible to false in DEFAULT_DASHBOARD_LAYOUT", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(false);
});
it("preserves heroVisible: false through serialize → resolve round-trip", () => {
it("preserves heroVisible: true through serialize → resolve round-trip", () => {
const layout: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
heroVisible: false,
heroVisible: true,
};
const resolved = resolveDashboardLayout(serializeDashboardLayout(layout));
expect(resolved.heroVisible).toBe(false);
expect(resolved.heroVisible).toBe(true);
});
it("serializer derives true when the field is missing on input", () => {
it("serializer derives false when the field is missing on input", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
expect(serializeDashboardLayout(layout).heroVisible).toBe(true);
expect(serializeDashboardLayout(layout).heroVisible).toBe(false);
});
});
+10 -10
View File
@@ -324,9 +324,9 @@ export interface DashboardLayout {
/**
* Dashboard hero (daily verdict) visibility. Piggy-backs on the
* layout blob like the B8 comparison baseline — a UI affordance, not
* an analytical attribute, so no Prisma migration. The resolver
* clamps anything that is not the literal `false` back to `true`
* (legacy blobs without the field render the hero by default; a
* an analytical attribute, so no Prisma migration. Opt-in: the
* resolver clamps anything that is not the literal `true` back to
* `false` (legacy blobs without the field keep the hero off; a
* stale client cannot poison it with a non-boolean), and the
* serializer persists the resolved boolean explicitly.
*/
@@ -342,9 +342,9 @@ const DASHBOARD_LAYOUT_VERSION = 1;
*/
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
// Hero (daily verdict) is on by default; users opt out via
// Hero (daily verdict) is off by default; users opt in via
// Settings → Dashboard.
heroVisible: true,
heroVisible: false,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: true, tileVisible: true, order: 1 },
@@ -462,10 +462,10 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// client cannot poison the dashboard with values the renderer
// doesn't know how to draw.
chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs),
// Hero visibility — anything that is not the literal `false`
// clamps to `true` (default-on for legacy blobs and malformed
// values alike).
heroVisible: candidate.heroVisible !== false,
// Hero visibility — anything that is not the literal `true`
// clamps to `false` (default-off for legacy blobs and malformed
// values alike; the hero is opt-in).
heroVisible: candidate.heroVisible === true,
};
}
@@ -495,6 +495,6 @@ export function serializeDashboardLayout(
chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs),
// Persist hero visibility explicitly (same clamp as the resolver)
// so a re-read never has to guess the default.
heroVisible: layout.heroVisible !== false,
heroVisible: layout.heroVisible === true,
};
}