refactor(dashboard): retire the legacy opt-in hero

The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 21:34:06 +02:00
1 parent d6ad76b1e1
commit 1d592bef78
29 files changed
+183 -2719

No files matched your search

@@ -407,132 +407,6 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to false for a first save with no stored layout", 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: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+4 -16
View File
@@ -121,14 +121,8 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
// v1.27.7 — hero score rings (max 3, closed id set). Optional with the
// same preserve-when-absent contract as `heroVisible`; the resolver
// same preserve-when-absent contract as `chartOverlayPrefs`; the resolver
// dedupes on read/serialize, `.max()` bounds the wire length.
selectedScoreRings: z
.array(z.enum(SCORE_RING_IDS))
@@ -284,19 +278,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save. `heroVisible` and
// `selectedScoreRings` ride the same preserve-when-absent contract —
// an older client's layout save must not reset either choice. One
// wiped here on a subsequent layout save. `selectedScoreRings` and
// `heroRingOrder` ride the same preserve-when-absent contract — an
// older client's layout save must not reset either choice. One
// stored-layout read covers all fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
let mergedScoreRings: ScoreRingId[] | undefined =
parsed.data.selectedScoreRings;
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined ||
mergedScoreRings === undefined ||
mergedHeroRingOrder === undefined
) {
@@ -310,9 +302,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible === true;
}
if (mergedScoreRings === undefined) {
mergedScoreRings = existingLayout.selectedScoreRings;
}
@@ -323,7 +312,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
selectedScoreRings: mergedScoreRings,
heroRingOrder: mergedHeroRingOrder,
} as DashboardLayout);
+2 -2
View File
@@ -4,7 +4,7 @@
* GET returns the resolved presentation (view + manual order, defaults
* merged in if the user hasn't customised yet). PUT updates it with
* preserve-when-absent semantics — a view-only PUT keeps the stored
* order and vice versa, matching the `heroVisible` contract on
* order and vice versa, matching the `chartOverlayPrefs` contract on
* `/api/dashboard/widgets`. DELETE resets to default.
*
* Mirrors the shape and semantics of `/api/insights/layout`; the blob
@@ -42,7 +42,7 @@ const layoutSchema = z.object({
version: z.literal(1),
// Both fields are optional so a client can PUT exactly the field it
// changed; the handler merges the absent one from the stored blob
// (preserve-when-absent, like `heroVisible` on the dashboard layout).
// (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout).
view: z.enum(MEDICATION_LIST_VIEWS).optional(),
// Medication ids are opaque here — display-only ordering, unknown /
// deleted ids are ignored at apply time, so the schema bounds size