fix(dashboard): top-tile selector actually filters the tile-strip

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:31:00 +02:00
1 parent 4df6dac036
commit 93e712dc60
4 files changed
+111 -52

No files matched your search

+12 -15
View File
@@ -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),
+34 -22
View File
@@ -995,28 +995,40 @@ export default function DashboardPage() {
* makes the scroll feel deliberate rather than arbitrary on
* touch.
*/}
<div
// CSS Grid with `auto-fit + minmax(9rem, 1fr)` is the v1.4.4
// attempt's flex-strip replacement: every tile gets EXACTLY
// the same width (1fr each in the row's track list), the gap
// is symmetric, and the strip starts and ends at the same
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
// scrolling — Marc tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex">
{entry.node}
</div>
))}
</div>
{/* 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 && (
<div
// CSS Grid with `auto-fit + minmax(9rem, 1fr)` is the v1.4.4
// attempt's flex-strip replacement: every tile gets EXACTLY
// the same width (1fr each in the row's track list), the gap
// is symmetric, and the strip starts and ends at the same
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
// scrolling — Marc tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex">
{entry.node}
</div>
))}
</div>
)}
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
{entry.node}
@@ -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(),
);
});
});
+27 -15
View File
@@ -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;