mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
62140a4aee
commit
668f568289
21 files changed
+714
-10
No files matched your search
@@ -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."
|
||||
|
||||
@@ -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<number[]> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
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);
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -702,7 +702,8 @@
|
||||
"body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.",
|
||||
"action": "단계 열기"
|
||||
},
|
||||
"quickAddSymptom": "증상 기록"
|
||||
"quickAddSymptom": "증상 기록",
|
||||
"todayCardVisible": "상단 카드 표시"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "측정",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<TodayHero", gate);
|
||||
const skeleton = src.indexOf("<TodayHeroSkeleton", gate);
|
||||
expect(hero).toBeGreaterThan(gate);
|
||||
expect(skeleton).toBeGreaterThan(hero);
|
||||
});
|
||||
|
||||
it("no longer references the retired legacy hero", () => {
|
||||
expect(src).not.toContain("DashboardHero");
|
||||
expect(src).not.toContain("DashboardHeroSkeleton");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ? (
|
||||
<TodayHero
|
||||
digest={digestQuery.data}
|
||||
renderFilteredAllClear={renderFilteredHeroAllClear}
|
||||
|
||||
@@ -621,3 +621,44 @@ describe("<DashboardLayoutSection> — hero content", () => {
|
||||
expect(html).toContain("Notification settings are managed separately.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardLayoutSection> — the top card switch", () => {
|
||||
function control(html: string): string {
|
||||
const match = html.match(/<button[^>]*data-slot="today-card-switch"[^>]*>/);
|
||||
expect(match).not.toBeNull();
|
||||
return match![0];
|
||||
}
|
||||
|
||||
it("offers one labelled switch, on by default", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
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(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
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(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
expect(control(html)).toContain('data-state="unchecked"');
|
||||
expect(html).toContain("Custom layout active");
|
||||
});
|
||||
|
||||
it("labels it in German", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
expect(html).toContain("Obere Karte anzeigen");
|
||||
});
|
||||
});
|
||||
@@ -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 }) {
|
||||
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
|
||||
{t("dashboard.heroItemsDescription")}
|
||||
</p>
|
||||
{/* v1.42 — the top card itself can be switched off. Shown is the
|
||||
default; the choices below shape the card while it is shown. */}
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<Label htmlFor={todayCardSwitchId} className="text-sm font-medium">
|
||||
{t("dashboard.todayCardVisible")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={todayCardSwitchId}
|
||||
checked={layout.todayCardVisible !== false}
|
||||
onCheckedChange={setTodayCardVisible}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="today-card-switch"
|
||||
/>
|
||||
</div>
|
||||
{/* 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 }) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="settings-dashboard-layout-save"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
@@ -777,7 +799,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) &&
|
||||
JSON.stringify(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS) ===
|
||||
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) &&
|
||||
(layout.hero ?? "score") === DEFAULT_DASHBOARD_LAYOUT.hero
|
||||
(layout.hero ?? "score") === DEFAULT_DASHBOARD_LAYOUT.hero &&
|
||||
layout.todayCardVisible !== false
|
||||
? t("dashboard.layoutUsingDefaults")
|
||||
: t("dashboard.layoutCustomized")}
|
||||
</p>
|
||||
|
||||
@@ -1061,6 +1061,7 @@ describe("layout field merge disposition", () => {
|
||||
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
|
||||
hero: "reminders",
|
||||
todayCardVisible: false,
|
||||
};
|
||||
|
||||
it("assigns a disposition to every top-level layout field", () => {
|
||||
@@ -1087,6 +1088,7 @@ describe("layout field merge disposition", () => {
|
||||
"hero",
|
||||
"heroRingOrder",
|
||||
"selectedScoreRings",
|
||||
"todayCardVisible",
|
||||
"widgets",
|
||||
]);
|
||||
});
|
||||
@@ -1226,3 +1228,55 @@ describe("resolveDashboardLayout() — hero primary content", () => {
|
||||
expect(resolveDashboardLayout(serialized).hero).toBe("reminders");
|
||||
});
|
||||
});
|
||||
|
||||
describe("todayCardVisible — the dashboard top card switch", () => {
|
||||
const base = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, order: 0 }],
|
||||
};
|
||||
|
||||
it("shows the top card by default", () => {
|
||||
expect(DEFAULT_DASHBOARD_LAYOUT.todayCardVisible).toBe(true);
|
||||
expect(resolveDashboardLayout(base).todayCardVisible).toBe(true);
|
||||
expect(resolveDashboardLayout(null).todayCardVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("hides it only on an explicit false", () => {
|
||||
expect(
|
||||
resolveDashboardLayout({ ...base, todayCardVisible: false })
|
||||
.todayCardVisible,
|
||||
).toBe(false);
|
||||
for (const value of [true, "false", 0, null]) {
|
||||
expect(
|
||||
resolveDashboardLayout({ ...base, todayCardVisible: value })
|
||||
.todayCardVisible,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is not driven by the retired heroVisible flag", () => {
|
||||
expect(
|
||||
resolveDashboardLayout({ ...base, heroVisible: false }).todayCardVisible,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stores false and omits the default", () => {
|
||||
const hidden = serializeDashboardLayout(
|
||||
resolveDashboardLayout({ ...base, todayCardVisible: false }),
|
||||
);
|
||||
expect(hidden.todayCardVisible).toBe(false);
|
||||
const shown = serializeDashboardLayout(resolveDashboardLayout(base));
|
||||
expect(shown).not.toHaveProperty("todayCardVisible");
|
||||
});
|
||||
|
||||
it("round-trips through serialize and resolve", () => {
|
||||
const stored = serializeDashboardLayout({
|
||||
...resolveDashboardLayout(base),
|
||||
todayCardVisible: false,
|
||||
});
|
||||
expect(
|
||||
resolveDashboardLayout(JSON.parse(JSON.stringify(stored)))
|
||||
.todayCardVisible,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -617,6 +617,15 @@ export interface DashboardLayout {
|
||||
* `"score"`.
|
||||
*/
|
||||
hero?: HeroPrimaryContent;
|
||||
/**
|
||||
* v1.42 — whether the dashboard shows its top card (the Today card above
|
||||
* the tiles) at all. Optional and additive: missing resolves to `true`,
|
||||
* and the serializer omits the default, so an untouched account's blob
|
||||
* stays as it was and clients that predate the field round-trip it
|
||||
* untouched. Deliberately not `heroVisible`: that name belonged to the
|
||||
* retired opt-in hero and old blobs may still carry it.
|
||||
*/
|
||||
todayCardVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,6 +660,7 @@ export const LAYOUT_FIELD_MERGE_DISPOSITION = {
|
||||
heroRingOrder: "preserve",
|
||||
enabledHeroItemKinds: "preserve",
|
||||
hero: "preserve",
|
||||
todayCardVisible: "preserve",
|
||||
} as const satisfies Record<keyof DashboardLayout, "replace" | "preserve">;
|
||||
|
||||
/**
|
||||
@@ -725,6 +735,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
heroRingOrder: [...DEFAULT_HERO_RING_ORDER],
|
||||
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
|
||||
hero: DEFAULT_HERO_PRIMARY_CONTENT,
|
||||
todayCardVisible: true,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "bp", visible: true, tileVisible: true, order: 1 },
|
||||
@@ -899,6 +910,8 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
// default "score" so a stale client cannot swap the hero to a
|
||||
// composition the renderer doesn't know how to draw.
|
||||
hero: coerceHeroPrimaryContent(candidate.hero),
|
||||
// Only an explicit `false` hides the top card; anything else shows it.
|
||||
todayCardVisible: candidate.todayCardVisible !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -952,5 +965,7 @@ export function serializeDashboardLayout(
|
||||
// contract as `enabledHeroItemKinds`), so an untouched account's blob
|
||||
// is byte-identical to the pre-field shape.
|
||||
...(hero === DEFAULT_HERO_PRIMARY_CONTENT ? {} : { hero }),
|
||||
// Shown is the default and is omitted the same way.
|
||||
...(layout.todayCardVisible === false ? { todayCardVisible: false } : {}),
|
||||
};
|
||||
}
|
||||
@@ -251,6 +251,19 @@ describe("admitAccountSettings", () => {
|
||||
expect(coachReasoningLevel(parseCoachPrefs(restored))).toBe("high");
|
||||
});
|
||||
|
||||
it("restores a hidden dashboard top card with the rest of the layout", () => {
|
||||
const layout = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
todayCardVisible: false,
|
||||
};
|
||||
const restored = admitAccountSettings(
|
||||
JSON.parse(JSON.stringify({ dashboardWidgetsJson: layout })),
|
||||
ctx,
|
||||
).data.dashboardWidgetsJson;
|
||||
expect(restored).toEqual(layout);
|
||||
});
|
||||
|
||||
it("writes a database null for a JSON setting the file clears", () => {
|
||||
expect(
|
||||
admitAccountSettings({ dashboardWidgetsJson: null }, ctx).data
|
||||
|
||||
@@ -78,11 +78,17 @@ const dashboardLayoutSchema = z
|
||||
.describe(
|
||||
'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.',
|
||||
),
|
||||
todayCardVisible: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "DashboardLayoutBody",
|
||||
description:
|
||||
"Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, `enabledHeroItemKinds`, and `hero` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.",
|
||||
"Per-user dashboard widget layout. `widgets` is the ordered tile and chart list. `comparisonBaseline`, `chartOverlayPrefs`, `selectedScoreRings`, `heroRingOrder`, `enabledHeroItemKinds`, `hero`, and `todayCardVisible` are additive dashboard preferences. Every field except `version` is optional on input and preserved when absent from PUT. Missing `enabledHeroItemKinds` resolves to every current kind, while an empty array hides every Today highlight. Widget ids use the closed dashboard catalogue; unknown ids are dropped before validation.",
|
||||
});
|
||||
|
||||
const dashboardLayoutPutBody = dashboardLayoutSchema
|
||||
|
||||
Reference in new issue
Block a user