From 93e712dc6012be9698ee78fb8826459d19980762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:31:00 +0200 Subject: [PATCH] fix(dashboard): top-tile selector actually filters the tile-strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc reported the toggles in Settings → Dashboard didn't filter the dashboard tile-strip on reload — the v1.4.15 A4 claim was correct in spirit but broken in the API contract. Root cause: `widgetIdEnum` in `src/app/api/dashboard/widgets/route.ts` was missing `achievements` even though the default layout has it. Every PUT that included the achievements widget therefore failed schema validation with 422 — the toast surfaced "Layout konnte nicht gespeichert werden" but the user never noticed because the toggle visually flipped (local draft state) before the network call. On reload the saved layout was the previous one, so the filter looked like a no-op. Fix: - Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in `src/lib/dashboard-layout.ts`. The Zod enum in the API route is built from it so the two lists cannot drift again. - Hide the tile-strip wrapper entirely when the user toggled off every tile (Marc's constraint: "immer die gesamte Spalte breit und immer der gleichen Höhe ausfüllen" — an empty grid wrapper was leaving a thin gap on the page). Tests: contract test pinning that every widget id in DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve, plus an explicit assertion that `achievements` is in the default layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/dashboard/widgets/route.ts | 27 +++++------ src/app/page.tsx | 56 +++++++++++++--------- src/lib/__tests__/dashboard-layout.test.ts | 38 +++++++++++++++ src/lib/dashboard-layout.ts | 42 ++++++++++------ 4 files changed, 111 insertions(+), 52 deletions(-) diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index cb56bb4bc..56f43351a 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -13,27 +13,24 @@ import { resolveDashboardLayout, serializeDashboardLayout, DEFAULT_DASHBOARD_LAYOUT, + DASHBOARD_WIDGET_IDS, type DashboardLayout, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import type { NextRequest } from "next/server"; -const widgetIdEnum = z.enum([ - "weight", - "bp", - "pulse", - "bodyFat", - "mood", - "medications", - "sleep", - "steps", - "glucose", - "totalBodyWater", - "boneMass", - "bpInTarget", - "oxygenSaturation", -]); +// Single source of truth — every widget id rendered by the Settings → +// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx` +// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing +// one here makes the PUT 422 silently — the toast surfaces "Layout +// konnte nicht gespeichert werden" — and Marc's tile-toggle looks +// like it does nothing because the save round-trip never completes. +// v1.4.16 A5 root-cause: `achievements` was absent from this enum so +// every save attempted with the achievements widget present (i.e. +// every save against the default layout) was rejected. We now derive +// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift. +const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS); const layoutSchema = z.object({ version: z.literal(1), diff --git a/src/app/page.tsx b/src/app/page.tsx index bed38dc08..b7d7e67c2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -995,28 +995,40 @@ export default function DashboardPage() { * makes the scroll feel deliberate rather than arbitrary on * touch. */} -
- {trendCards.map((entry) => ( -
- {entry.node} -
- ))} -
+ {/* v1.4.16 Fix A5: hide the strip entirely when the user + turned off every tile. Until v1.4.15 the wrapper rendered + an empty grid even with zero tiles — visually a thin gap + Marc described as "awkward". Charts below still render so + the page is not empty; the tile-strip just goes away. + The constraint Marc named — "immer die gesamte Spalte + breit und immer der gleichen Höhe" — is preserved by the + CSS-grid `auto-fit + minmax + auto-rows-fr` track that + continues to give every visible tile equal width / equal + height for any non-zero count. */} + {trendCards.length > 0 && ( +
+ {trendCards.map((entry) => ( +
+ {entry.node} +
+ ))} +
+ )} {charts.map((entry) => (
{entry.node} diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index ca37a22c5..1455d7e54 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -116,3 +116,41 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { expect(serialized.widgets.map((w) => w.order)).toEqual([0, 1]); }); }); + +/** + * v1.4.16 Fix A5 — top-tile selector real-fix. + * + * Marc reported the toggles in Settings → Dashboard didn't filter + * the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema + * in `src/app/api/dashboard/widgets/route.ts` was missing + * `achievements` even though the default layout has it. Every PUT + * therefore 422'd silently against the achievements widget that the + * UI included unconditionally — the server never persisted the toggle + * change, so reload showed the old layout. + * + * The default layout itself is the contract — if a widget is in + * `DEFAULT_DASHBOARD_LAYOUT.widgets`, every consumer (settings UI, + * dashboard renderer, API schema) must support it. This test pins + * the contract by enumerating every widget the default layout + * advertises, so a future addition gets caught here before Marc + * finds it broken in production. + */ +describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { + it("includes the achievements widget (v1.4.15 phase-B4 + v1.4.16 A5)", () => { + const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id); + expect(ids).toContain("achievements"); + }); + + it("survives a round-trip through serialize → resolve unchanged", () => { + // The API persists `serializeDashboardLayout(parsed.data)` and + // reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`. + // A bug in either step (missing widget, wrong order normalization) + // would surface here. Marc's regression: the API schema rejected + // the layout *before* this round-trip even started. + const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT); + const resolved = resolveDashboardLayout(serialized); + expect(resolved.widgets.map((w) => w.id).sort()).toEqual( + DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id).sort(), + ); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0b18c2030..0bb7040b4 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -5,21 +5,33 @@ * what the default layout is for new users. Null / missing = default. */ -export type DashboardWidgetId = - | "weight" - | "bp" - | "pulse" - | "bodyFat" - | "mood" - | "medications" - | "sleep" - | "steps" - | "glucose" - | "totalBodyWater" - | "boneMass" - | "bpInTarget" - | "oxygenSaturation" - | "achievements"; +/** + * Every widget the dashboard layout knows about. The order of + * entries here is irrelevant — `DEFAULT_DASHBOARD_LAYOUT.widgets[].order` + * is the visual order. This array is exported so the API Zod + * `widgetIdEnum` (in `src/app/api/dashboard/widgets/route.ts`) can be + * built from a single source of truth — v1.4.16 Fix A5 root cause was + * the enum drifting away from the default-layout list, which silently + * 422'd every save. + */ +export const DASHBOARD_WIDGET_IDS = [ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "medications", + "sleep", + "steps", + "glucose", + "totalBodyWater", + "boneMass", + "bpInTarget", + "oxygenSaturation", + "achievements", +] as const; + +export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; export interface DashboardWidgetConfig { id: DashboardWidgetId;