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;