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