From 668f568289cc300021365419d5e6e43488d30fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:52:39 +0200 Subject: [PATCH] feat(dashboard): let the top card be switched off The layout settings gain a switch for the dashboard's top card (the Today card above the tiles). Stored server-side on the layout blob as `todayCardVisible`: missing means shown, only an explicit false hides it, the default is omitted from storage, a save that omits the field keeps the stored choice, and a layout reset shows the card again. Hidden, the card leaves no skeleton or error card behind. The flag rides the account-settings backup with the rest of the layout. The field is deliberately not `heroVisible`, the retired opt-in hero's flag that older stored layouts may still carry. An end-to-end journey on its own account switches the card off and on and walks the score histories' range tabs. --- docs/api/openapi.yaml | 15 + e2e/score-history.spec.ts | 361 ++++++++++++++++++ e2e/setup/global-setup.ts | 48 +++ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- playwright.config.ts | 5 + .../__tests__/dashboard-hero-wiring.test.ts | 11 + .../dashboard/widgets/__tests__/route.test.ts | 96 +++++ src/app/api/dashboard/widgets/route.ts | 6 + src/app/page-client.tsx | 5 +- .../dashboard-layout-section.test.tsx | 41 ++ .../settings/dashboard-layout-section.tsx | 25 +- src/lib/__tests__/dashboard-layout.test.ts | 54 +++ src/lib/dashboard-layout.ts | 15 + .../__tests__/account-settings-backup.test.ts | 13 + src/lib/openapi/routes/dashboard.ts | 8 +- 21 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 e2e/score-history.spec.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fc8300146..09264477d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -32820,6 +32820,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -76275,6 +76280,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." @@ -76532,6 +76542,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts new file mode 100644 index 000000000..a22b9382f --- /dev/null +++ b/e2e/score-history.spec.ts @@ -0,0 +1,361 @@ +import type { Page, Route } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import type { DailyDigest } from "@/lib/daily/digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; + +/** + * v1.42 — the score histories and the dashboard's top card switch. + * + * - The health score's page charts its daily course with the range tabs + * every chart offers; a tab asks the server for its window, the choice + * survives a reload, and a point opens its day. + * - Readiness, recovery, the sleep score and strain carry the same four + * tabs in the same component. + * - The top card goes away when the layout settings switch it off, and + * comes back when they switch it on. + * + * The score history and the derived scores are route mocks in the + * contract's shape (the server's answers are pinned by the integration + * suite); the range preference and the layout are this account's own, saved + * for real. Stable data attributes only. + */ + +test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH }); +// Every test writes this one account's layout blob, a Serializable +// read-modify-write; run in parallel they would abort each other's write. +test.describe.configure({ mode: "serial" }); + +const RANGES = ["7", "30", "90", "0"]; + +function dayKey(offset: number): string { + const now = new Date(); + const local = new Date( + now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }), + ); + local.setDate(local.getDate() + offset); + const y = local.getFullYear(); + const m = String(local.getMonth() + 1).padStart(2, "0"); + const d = String(local.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** Answer the score history with one point per day of the window, max 40. */ +async function mockScoreHistory(page: Page): Promise { + const asked: number[] = []; + await page.route(/\/api\/insights\/score-history\?/, (route: Route) => { + const url = new URL(route.request().url()); + const days = Number(url.searchParams.get("days")); + asked.push(days); + const count = Math.min(days, 40); + const points = Array.from({ length: count }, (_, i) => ({ + day: dayKey(i - count + 1), + value: 60 + ((i * 7) % 25), + seamBreak: false, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + score: url.searchParams.get("score"), + days, + points, + band: { lo: 64, hi: 76, n: 30 }, + }, + error: null, + }), + }); + }); + return asked; +} + +/** An `ok` derived score, so the score page draws its history under it. */ +async function mockDerived(page: Page) { + await page.route(/\/api\/insights\/derived\?/, (route: Route) => { + const metric = new URL(route.request().url()).searchParams.get("metric"); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + metric, + status: "ok", + value: { + score: 72, + band: "green", + components: [], + subScores: [], + night: dayKey(0), + asleepMinutes: 420, + inBedMinutes: 450, + windowNights: 20, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }, + confidence: "high", + provenance: { + inputs: [], + source: "rollup", + windowDays: 30, + computedAt: new Date().toISOString(), + }, + reason: null, + assessment: null, + }, + error: null, + }), + }); + }); +} + +async function resetRange(page: Page, chartKey: string) { + const res = await page.request.put("/api/dashboard/chart-overlay-prefs", { + data: { + chartKey, + prefs: { + showTrendIndicator: false, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + rangePoints: 30, + }, + }, + }); + expect(res.status()).toBe(200); +} + +async function expectSharedTabs(page: Page, chart: string, pressed: string) { + const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`); + await expect(tabs).toHaveCount(4); + expect( + await tabs.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-range")), + ), + ).toEqual(RANGES); + await expect( + page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`), + ).toHaveAttribute("data-range", pressed); +} + +test.describe("score histories", () => { + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page, { index: "full" }); + }); + + test("the health score's range tabs switch the window and are remembered", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await resetRange(page, "scoreHealth"); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/health-score"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible(); + await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible(); + expect(asked).toContain(30); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="7"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "7"); + await expect.poll(() => asked.at(-1)).toBe(7); + // The table lists exactly the window's days. + await chart.locator('[data-slot="chart-data-table-toggle"]').click(); + await expect( + chart.locator('[data-slot="chart-data-table-row"]'), + ).toHaveCount(7); + + // Remembered: a reload opens on seven days again. + await page.reload(); + await expect(chart).toHaveAttribute("data-range", "7"); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="0"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "0"); + await expect.poll(() => asked.at(-1)).toBe(3650); + + // A point opens its day beside the page. + const plot = chart.locator('[data-slot="chart-plot"]'); + await expect(plot).toHaveAttribute("data-day-links", "true"); + const box = (await plot.boundingBox())!; + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2); + await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute( + "data-shell", + "docked", + ); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + + await resetRange(page, "scoreHealth"); + }); + + test("the score panel leads to the health score page and back", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await mockScoreHistory(page); + await page.goto("/insights"); + const link = page.locator('[data-slot="health-score-history-link"]'); + await expect(link).toHaveAttribute("href", "/insights/health-score"); + await link.click(); + await expect(page).toHaveURL(/\/insights\/health-score$/); + await expect( + page.locator('[data-slot="health-score-back"]'), + ).toHaveAttribute("href", "/insights"); + }); + + test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({ + page, + }) => { + await resetRange(page, "scoreReadiness"); + await mockDerived(page); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/scores/readiness"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "READINESS"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await chart + .locator('[data-slot="chart-range-tab"][data-range="90"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "90"); + await expect.poll(() => asked.at(-1)).toBe(90); + await resetRange(page, "scoreReadiness"); + + await page.goto("/insights/scores/sleep"); + await expect( + page.locator('[data-slot="score-trend-chart"]'), + ).toHaveAttribute("data-score", "SLEEP_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + }); + + test("recovery and strain carry the same tabs on their stored-score chart", async ({ + page, + }) => { + await resetRange(page, "scoreRecovery"); + await mockDerived(page); + + await page.goto("/insights/scores/recovery"); + const chart = '[data-slot="score-history-chart"]'; + await expect(page.locator(chart)).toHaveAttribute( + "data-type", + "RECOVERY_SCORE", + ); + await expectSharedTabs(page, chart, "30"); + // A score history remembers its range, and mounts no overlay dropdown. + await expect( + page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`), + ).toHaveCount(0); + await page + .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`) + .click(); + await expectSharedTabs(page, chart, "7"); + await page.reload(); + await expectSharedTabs(page, chart, "7"); + await resetRange(page, "scoreRecovery"); + + await page.goto("/insights/scores/strain"); + await expectSharedTabs(page, chart, "30"); + }); +}); + +const DIGEST: DailyDigest = { + generatedAt: new Date().toISOString(), + ai: DIGEST_AI_AVAILABLE, + phase: "final", + sleepPending: false, + score: { + value: 82, + band: "green", + delta: null, + deltaReason: "first_eligibility_window", + steadyWeeks: 2, + }, + topSignal: null, + signalLine: null, + briefingLead: null, + lead: null, + today: [], + restMode: null, + line: "Your health score today is 82.", + worthALook: [], + justIn: null, + reactionLine: null, +}; + +test.describe("the dashboard's top card", () => { + test.afterEach(async ({ page }) => { + // Leave the account's layout at its defaults for the next run. + await page.request.delete("/api/dashboard/widgets"); + }); + + test("switches off and on from the layout settings", async ({ page }) => { + await page.request.delete("/api/dashboard/widgets"); + await page.route(/\/api\/daily\/digest(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: DIGEST, error: null }), + }), + ); + + const hero = page.locator('[data-slot="today-hero"]'); + await page.goto("/"); + await expect(hero.first()).toBeVisible(); + + async function flip(expected: "checked" | "unchecked") { + await page.goto("/settings/layout/dashboard"); + const toggle = page.locator('[data-slot="today-card-switch"]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + const saved = page.waitForResponse( + (res) => + res.url().includes("/api/dashboard/widgets") && + res.request().method() === "PUT", + ); + await page + .locator('[data-slot="settings-dashboard-layout-save"]') + .click(); + const response = await saved; + expect(response.status()).toBe(200); + const body = response.request().postDataJSON() as { + todayCardVisible?: boolean; + }; + expect(body.todayCardVisible).toBe(expected === "checked"); + } + + await flip("unchecked"); + await page.goto("/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect(hero).toHaveCount(0); + await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( + 0, + ); + + // A phone width shows no card either. + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(hero).toHaveCount(0); + await page.setViewportSize({ width: 1280, height: 720 }); + + await flip("checked"); + await page.goto("/"); + await expect(hero.first()).toBeVisible(); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index f3ed47952..9eeb1d669 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -270,6 +270,25 @@ export const DAY_DOORS_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateDayDoors.json", ); +/** + * The score-history journey's own account (`score-history.spec.ts`). + * + * It switches the dashboard's top card off and back on and remembers a range + * tab per score page, both per-account preferences; on a shared account a + * parallel spec would find its dashboard without the card it asserts. + */ +export const E2E_SCORE_HISTORY = { + email: "e2e-score-history@healthlog.test", + username: "e2e-score-history", + password: "Sh7!Vn2qLp9xWc4R", + role: "USER", +} as const; + +export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScoreHistory.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -995,6 +1014,32 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The score-history journey's account. Every module on, a fresh layout: + // the journey moves its top card and range tabs, so a re-run starts from + // the defaults. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + dashboard_widgets_json = NULL`, + [ + cuid(), + E2E_SCORE_HISTORY.username, + E2E_SCORE_HISTORY.email, + await hashPassword(E2E_SCORE_HISTORY.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1605,6 +1650,9 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); + // The score-history journey's jar — see `E2E_SCORE_HISTORY`. + await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/messages/de.json b/messages/de.json index 3776f3684..ce779efcd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -702,7 +702,8 @@ "body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.", "action": "Zum Schritt" }, - "quickAddSymptom": "Symptom erfassen" + "quickAddSymptom": "Symptom erfassen", + "todayCardVisible": "Obere Karte anzeigen" }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index 9bd00706f..f32159a0c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -702,7 +702,8 @@ "body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.", "action": "Open the step" }, - "quickAddSymptom": "Log symptom" + "quickAddSymptom": "Log symptom", + "todayCardVisible": "Show the top card" }, "measurements": { "title": "Measurements", diff --git a/messages/es.json b/messages/es.json index 937e152ac..8aa56b605 100644 --- a/messages/es.json +++ b/messages/es.json @@ -702,7 +702,8 @@ "body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.", "action": "Abrir el paso" }, - "quickAddSymptom": "Registrar síntoma" + "quickAddSymptom": "Registrar síntoma", + "todayCardVisible": "Mostrar la tarjeta superior" }, "measurements": { "title": "Mediciones", diff --git a/messages/fr.json b/messages/fr.json index 620b79947..1482e0220 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -702,7 +702,8 @@ "body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.", "action": "Ouvrir l'étape" }, - "quickAddSymptom": "Noter un symptôme" + "quickAddSymptom": "Noter un symptôme", + "todayCardVisible": "Afficher la carte du haut" }, "measurements": { "title": "Mesures", diff --git a/messages/it.json b/messages/it.json index 71b7106c9..f4b9918de 100644 --- a/messages/it.json +++ b/messages/it.json @@ -702,7 +702,8 @@ "body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.", "action": "Apri il passaggio" }, - "quickAddSymptom": "Registra sintomo" + "quickAddSymptom": "Registra sintomo", + "todayCardVisible": "Mostra la scheda in alto" }, "measurements": { "title": "Misurazioni", diff --git a/messages/ko.json b/messages/ko.json index 65dfaff04..3ae10251e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -702,7 +702,8 @@ "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", "action": "단계 열기" }, - "quickAddSymptom": "증상 기록" + "quickAddSymptom": "증상 기록", + "todayCardVisible": "상단 카드 표시" }, "measurements": { "title": "측정", diff --git a/messages/pl.json b/messages/pl.json index fbe0acb2b..c69c7e1a1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -702,7 +702,8 @@ "body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.", "action": "Otwórz krok" }, - "quickAddSymptom": "Zapisz objaw" + "quickAddSymptom": "Zapisz objaw", + "todayCardVisible": "Pokaż górną kartę" }, "measurements": { "title": "Pomiary", diff --git a/playwright.config.ts b/playwright.config.ts index ed9e0ffb8..796f9b4fe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -289,6 +289,11 @@ export default defineConfig({ // account under the first. They prove server state through stable // data-slots, not a mobile layout, so they run in one project. "ai-optional-account.spec.ts", + // The score-history journey moves its account's top card and range + // tabs; two projects on that one account would flip them under each + // other. It checks the phone width by resizing inside the desktop + // project. + "score-history.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 5242915c0..b6c5811cb 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -42,6 +42,17 @@ describe("dashboard hero — page wiring", () => { ); }); + it("hides the top card, its skeleton and its error card only on an explicit false", () => { + // One gate in front of the whole three-way branch, so switching the card + // off leaves no reserve and no retry card where it stood. + const gate = src.indexOf("layout.todayCardVisible === false ? null :"); + expect(gate).toBeGreaterThan(-1); + const hero = src.indexOf(" { expect(src).not.toContain("DashboardHero"); expect(src).not.toContain("DashboardHeroSkeleton"); diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 177950e2f..0e9ce687f 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -929,7 +929,9 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { selectedScoreRings: ["READINESS", "SLEEP_SCORE"], heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"], enabledHeroItemKinds: ["milestone"], + todayCardVisible: false, }); + expect(stored.todayCardVisible).toBe(false); const advanced = new Date("2026-07-24T12:00:00.000Z"); transactionUser.findUnique.mockResolvedValue({ dashboardWidgetsJson: stored, @@ -958,6 +960,10 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect( Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"), ).toBe(false); + // A reset brings a hidden top card back; shown is stored by omission. + expect( + Object.prototype.hasOwnProperty.call(persisted, "todayCardVisible"), + ).toBe(false); expect(persisted.comparisonBaseline).toBe("lastYear"); expect(persisted.chartOverlayPrefs).toEqual({ weight: { @@ -1076,6 +1082,96 @@ describe("dashboard widgets — hero primary content", () => { }); }); +describe("dashboard widgets — the top card switch", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + function persistedLayout() { + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { todayCardVisible?: boolean } }; + }; + return updateArg.data.dashboardWidgetsJson; + } + + it("accepts and persists todayCardVisible: false", 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, todayCardVisible: false }), + ); + expect(res.status).toBe(200); + expect(persistedLayout().todayCardVisible).toBe(false); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(false); + }); + + it("rejects a non-boolean 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, todayCardVisible: "no" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps a hidden top card hidden when the client omits the field", async () => { + // A save from a client that predates the field must not bring it back. + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + 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); + expect(persistedLayout().todayCardVisible).toBe(false); + }); + + it("stores showing it again as the omitted default", async () => { + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + 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, todayCardVisible: true }), + ); + expect(res.status).toBe(200); + expect(persistedLayout()).not.toHaveProperty("todayCardVisible"); + }); + + it("GET answers true for a layout that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + } as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(true); + }); +}); + describe("a toggle that cannot do anything is not offered", () => { // A switch that is on over a tile that can never paint, with nothing // saying why, is the defect the HRV fallback exists to remove — so the diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 13a1c37a7..688355b0e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -170,6 +170,10 @@ const layoutSchema = z.object({ // 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.42 — whether the dashboard shows its top card. Same additive, + // preserve-when-absent contract as `hero`; the default (shown) is omitted + // from storage. + todayCardVisible: z.boolean().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 @@ -503,6 +507,8 @@ export const DELETE = apiHandler(async () => { ...existingLayout, widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, + // A reset brings a hidden top card back. + todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible, }); const updated = await tx.user.update({ diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 63c6ccf7f..6dc157f45 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -954,7 +954,10 @@ export default function DashboardPageClient({ // rehydrates from disk. The error / empty branches stay reachable only // post-mount. DO NOT add `["daily", …]` to that allowlist, or render // the hero from any client-only source, without revisiting this gate. - digestQuery.data ? ( + // v1.42 — the top card can be switched off in the layout settings; + // only an explicit `false` hides it, and with it its skeleton and + // its error card. + layout.todayCardVisible === false ? null : digestQuery.data ? ( — hero content", () => { expect(html).toContain("Notification settings are managed separately."); }); }); + +describe(" — the top card switch", () => { + function control(html: string): string { + const match = html.match(/]*data-slot="today-card-switch"[^>]*>/); + expect(match).not.toBeNull(); + return match![0]; + } + + it("offers one labelled switch, on by default", () => { + const html = render(); + expect(html.match(/data-slot="today-card-switch"/g)).toHaveLength(1); + expect(control(html)).toContain('data-state="checked"'); + const id = control(html).match(/id="([^"]+)"/)![1]; + expect(html).toContain(`for="${id}"`); + expect(html).toContain("Show the top card"); + expect(html).toContain("Using default layout"); + }); + + it("reads a layout that predates the field as shown", () => { + const legacy: DashboardLayout = { ...DEFAULT_DASHBOARD_LAYOUT }; + delete legacy.todayCardVisible; + queryState.layout = legacy; + const html = render(); + expect(control(html)).toContain('data-state="checked"'); + }); + + it("shows a hidden top card as off, and as a customised layout", () => { + queryState.layout = { + ...DEFAULT_DASHBOARD_LAYOUT, + todayCardVisible: false, + }; + const html = render(); + expect(control(html)).toContain('data-state="unchecked"'); + expect(html).toContain("Custom layout active"); + }); + + it("labels it in German", () => { + const html = render(, "de"); + expect(html).toContain("Obere Karte anzeigen"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 8d71687fe..b7e313fca 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -207,6 +207,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { const heroDescriptionId = useId(); const heroNotificationNoteId = useId(); const heroContentSelectId = useId(); + const todayCardSwitchId = 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 @@ -404,6 +405,12 @@ export function DashboardLayoutSection({ id }: { id: string }) { }); } + /** v1.42 — the server-persisted on/off switch for the dashboard's top card. */ + function setTodayCardVisible(visible: boolean) { + if (!layout) return; + setDraft({ ...layout, todayCardVisible: visible }); + } + /** * Hero primary content — the server-persisted `hero` field on the layout * blob. The value set is closed ("score" | "reminders"); anything else a @@ -483,6 +490,20 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{t("dashboard.heroItemsDescription")}

+ {/* v1.42 — the top card itself can be switched off. Shown is the + default; the choices below shape the card while it is shown. */} +
+ + +
{/* 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. */} @@ -758,6 +779,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {