chore(release): v1.4.28.1

## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix

"Speichern" on the Dashboard works again for every account whose
layout was last persisted before v1.4.28. The v1.4.28 retire of the
`glp1` widget id left an orphan entry in `dashboardWidgetsJson` for
legacy users, which made the save validator reject the payload; the
resolver now filters unknown widget ids on read.

### Fixed

- **Dashboard save against legacy layouts.**
  `resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops
  widget ids that are no longer in the registry before returning
  the layout, so the `dashboardWidgetsJson` round-trip
  (`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`)
  succeeds even when the persisted JSON still names `glp1` or any
  other retired widget.

### Tests

- `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the
  retired-id filter (19/19 green).
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 09:28:40 +02:00
1 parent 2d111fcb24
commit 232ea43d99
27 files changed
+3662 -167

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ describe("GET /api/version", () => {
it("returns the package.json version", async () => {
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i);
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
expect(body.data.license).toBe("AGPL-3.0");
expect(body.data.repository).toContain("github.com");
expect(body.data.changelog).toContain("CHANGELOG");
@@ -162,6 +162,28 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id).sort(),
);
});
it("drops widget ids the current build no longer knows about", () => {
// v1.4.28 retired the `glp1` tile. Users who saved a layout before
// then still have the orphan id in `dashboardWidgetsJson`; the PUT
// route's Zod enum would reject the entire blob on the next save
// round-trip, surfacing the "Speichern fehlgeschlagen" toast.
// The resolver filters unknown ids on read so the GET shape is
// current-build-safe and the next save round-trips cleanly.
const stored = {
version: 1,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "glp1", visible: true, tileVisible: true, order: 1 },
{ id: "bp", visible: true, tileVisible: true, order: 2 },
],
};
const resolved = resolveDashboardLayout(stored);
const ids = resolved.widgets.map((w) => w.id);
expect(ids).not.toContain("glp1");
expect(ids).toContain("weight");
expect(ids).toContain("bp");
});
});
/**
+12 -3
View File
@@ -229,13 +229,22 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
return DEFAULT_DASHBOARD_LAYOUT;
}
// Drop widget ids the current build does not know about. v1.4.28
// retired the `glp1` tile; any user who saved a layout before then
// still has the orphan id in `dashboardWidgetsJson`, and the PUT
// route's Zod enum rejects the entire blob on the next save round-
// trip. Filtering on read keeps the GET shape current-build-safe
// and lets the Settings UI re-PUT a clean array.
const knownIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const filtered = candidate.widgets.filter((w) => knownIds.has(w.id));
// Merge with defaults so new widgets introduced in later versions show up
// automatically (invisible by default, users opt-in).
const savedIds = new Set(candidate.widgets.map((w) => w.id));
const savedIds = new Set(filtered.map((w) => w.id));
const missing = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => !savedIds.has(w.id),
);
const maxOrder = Math.max(0, ...candidate.widgets.map((w) => w.order));
const maxOrder = Math.max(0, ...filtered.map((w) => w.order));
const appended = missing.map((w, i) => ({
...w,
visible: false, // default-invisible on auto-upgrade
@@ -247,7 +256,7 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
// never had the field (the v1.4.15 schema upgrade). Users who saved
// a layout before this release see no behavioural change until they
// explicitly toggle the new strip switch.
const normalized = candidate.widgets.map((w) => ({
const normalized = filtered.map((w) => ({
...w,
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
}));