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

+49 -15
View File
@@ -13,21 +13,21 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* Phone-width density guard (390×844 iPhone class, 360×780 small
* Android class).
*
* Two invariants, per the report against the dashboard hero (a long
* briefing-signal headline squeezed into a one-word-per-line column
* beside a wide delta, the delta spilling past the tile edge at 360 px):
* Two invariants, per the original overflow report (long headline text
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 2. Nothing escapes its tile — for every card / hero band / list
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
* `overflow-hidden` wrapper below the tile is fine; content cut
* off at the tile's own edge is the bug).
*
* The snapshot mock pins the worst case: hero band on, three score
* rings, and a fresh briefing whose signal rows pair wrapping German
* headlines with wide delta strings.
* The mocks pin the worst case: the Today hero (its score face + a
* worth-a-look rail) plus a fresh briefing whose rows pair wrapping
* German headlines with wide delta strings.
*
* Desktop project only — the assertions are viewport-driven via
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
@@ -51,7 +51,7 @@ interface TileEscape {
function collectTileEscapes(): TileEscape[] {
const escapes: TileEscape[] = [];
const tiles = document.querySelectorAll(
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
'[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]',
);
const TOLERANCE = 1;
for (const tile of tiles) {
@@ -113,10 +113,46 @@ test.describe("phone-width density guard", () => {
await page.setViewportSize(viewport);
await mockDashboardSnapshot(page, {
summaries: POPULATED_SUMMARIES,
heroVisible: true,
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
// The Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a
// score + one worth-a-look item) so the hero paints without
// reaching the real route — mirror of the dashboard.spec mock.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
],
},
error: null,
}),
}),
);
if (route === "/insights/mood") {
await mockMoodInsights(page);
}
@@ -124,14 +160,12 @@ test.describe("phone-width density guard", () => {
await page.waitForLoadState("networkidle");
if (route === "/") {
// The guard must not pass vacuously — the hero band and its
// long-headline spotlight rows have to actually render.
// The guard must not pass vacuously — the Today hero and its
// score face have to actually render.
await expect(page.locator('[data-slot="today-hero"]')).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-hero"]'),
page.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
).toHaveCount(3);
}
if (route === "/insights/mood") {
+3 -7
View File
@@ -231,11 +231,9 @@ export interface MockSnapshotOptions {
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
heroVisible?: boolean;
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
/** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */
briefing?: DailyBriefing;
/** Selected hero score rings (renders beside the health-score ring). */
/** Selected score rings carried on the snapshot (server-computed). */
scoreRings?: DashboardScoreRing[];
}
@@ -263,9 +261,7 @@ export function buildMockSnapshot(
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: options.heroVisible
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
: DEFAULT_DASHBOARD_LAYOUT,
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),