feat(quick-260916-bwo): Dashboard-Raster verdoppelt (24 Spalten, 20 px, 8 px Abstand), Konstanten x2, einmalige Umrechnung gespeicherter Anordnungen mit Marker __gridVersion

- dashboard-grid.tsx: COLS 24/20/12/8/2, rowHeight 20, margin 8 (containerPadding folgt), Rueckfallwerte 4
- widget-registry.tsx: alle 32 Werte in WIDGET_CONSTRAINTS verdoppelt
- grid-layout-migration.ts (neu): migrateGridLayouts/withGridVersion, Marker nur im JSON, Idempotenz (T-BWO-02)
- dashboard-store.ts: Umrechnung beim Laden, Sofort-Speichern mit Marker, withGridVersion bei jedem saveLayout
- Tests: Migration 7 (neu), Store 6 (neu), Registry +1 (Tabelle), Grid +2 (Props ueber Mock), API-Spec +2 (Durchreichung __gridVersion, timeFontSizePt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-16 09:09:00 +02:00
parent 50f201ecc1
commit 3f5afb0f54
9 changed files with 511 additions and 25 deletions
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import {
GRID_SCALE_FACTOR,
GRID_VERSION,
GRID_VERSION_KEY,
migrateGridLayouts,
withGridVersion,
} from './grid-layout-migration';
/**
* grid-layout-migration.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
*
* Sieben Tests fuer die einmalige Umrechnung gespeicherter Anordnungen von
* den alten Einheiten (12 Spalten / 40 px) in die neuen (24 Spalten / 20 px).
* Kern ist T-BWO-02: die Verdopplung darf GENAU EINMAL geschehen. Der Marker
* `__gridVersion` steht dafuer im gespeicherten JSON, nie im Zustand — Test 4
* (Idempotenz) wird rot, sobald jemand die Marker-Pruefung entfernt.
*/
function altLayouts() {
return {
lg: [
{ i: 'a', x: 1, y: 2, w: 2, h: 3, minW: 2, minH: 2, moved: false, static: false },
{ i: 'b', x: 2, y: 0, w: 6, h: 2, maxW: 12, maxH: 8 },
],
md: [{ i: 'a', x: 0, y: 0, w: 2, h: 2 }],
sm: [],
xs: [],
xxs: [],
};
}
describe('grid-layout-migration (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung ohne Marker -> alle Positionen und Groessen x2, migrated true, kein Marker im Ergebnis', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 4 });
expect(layouts.sm).toEqual([]);
expect(layouts.xs).toEqual([]);
expect(layouts.xxs).toEqual([]);
expect(migrated).toBe(true);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
expect(GRID_SCALE_FACTOR).toBe(2);
});
it('Test 2: markierte Anordnung (__gridVersion 2) bleibt unveraendert, migrated false', () => {
const alt = altLayouts();
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts).toEqual(alt);
expect(migrated).toBe(false);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
});
it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => {
const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] };
const r1 = migrateGridLayouts(empty);
expect(r1.layouts).toEqual(empty);
expect(r1.migrated).toBe(false);
const r2 = migrateGridLayouts({});
expect(r2.layouts).toEqual({});
expect(r2.migrated).toBe(false);
});
it('Test 4: Idempotenz — einmal umgerechnet und markiert wird nicht erneut verdoppelt (T-BWO-02)', () => {
const once = migrateGridLayouts(altLayouts());
const twice = migrateGridLayouts(withGridVersion(once.layouts));
expect(twice.layouts).toEqual(once.layouts);
expect(twice.migrated).toBe(false);
});
it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => {
const { layouts } = migrateGridLayouts(altLayouts());
const marked = withGridVersion(layouts);
expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION);
expect(GRID_VERSION).toBe(2);
expect(marked.lg).toBe(layouts.lg);
expect(marked.md).toBe(layouts.md);
expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY);
expect((layouts as Record<string, unknown>)[GRID_VERSION_KEY]).toBeUndefined();
});
it('Test 6: Zukunft und Robustheit — Marker 3 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => {
const alt = altLayouts();
const future = migrateGridLayouts({ ...alt, __gridVersion: 3 });
expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true);
expect(stringMarker.layouts.lg[0].x).toBe(2);
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
});
expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 4, h: 4, resizeHandles: ['se'], moved: true, static: false,
});
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true);
});
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 2, y: 2, w: 2, h: 2 });
expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false });
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* grid-layout-migration — einmalige Umrechnung gespeicherter Dashboard-
* Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo).
*
* Warum: Das Raster wurde von 12 Spalten / 40 px Zeilenhoehe auf 24 Spalten /
* 20 px verdoppelt (`dashboard-grid.tsx`). Eine in ALTEN Einheiten gespeicherte
* Anordnung wuerde im neuen Raster halb so gross und an der halben Position
* erscheinen. Deshalb werden `x, y, w, h` (und, falls vorhanden, `minW, minH,
* maxW, maxH`) jedes Elements in jedem Breakpoint GENAU EINMAL mit 2
* multipliziert.
*
* Marker: Damit die Verdopplung nur einmal geschieht, traegt das gespeicherte
* JSON den Schluessel `__gridVersion: 2`. Der Marker lebt NUR im persistierten
* JSON (Spalte `DashboardLayout.layouts`, Json, kein Schema noetig) — nie im
* Zustand des Stores, der mit `Object.keys` ueber die Breakpoints iteriert und
* `.filter` auf jedem Wert aufruft (ein Zahlwert wuerde dort abstuerzen).
* `migrateGridLayouts` entfernt den Marker beim Laden, `withGridVersion`
* haengt ihn beim Speichern wieder an. Fehlt der Marker beim Speichern, wird
* beim naechsten Laden ERNEUT verdoppelt — deshalb muss JEDER Speichervorgang
* `withGridVersion` benutzen (T-BWO-02, Store-Tests pinnen das).
*
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
* React- oder Store-Abhaengigkeit.
*/
export const GRID_VERSION = 2;
export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2;
export interface GridLayoutItem {
i: string;
x: number;
y: number;
w: number;
h: number;
[key: string]: unknown;
}
export type GridLayouts = Record<string, GridLayoutItem[]>;
const SCALED_FIELDS = ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] as const;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
*/
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } {
if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false };
}
const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1;
const needsScaling = version < GRID_VERSION;
const layouts: GridLayouts = {};
let migrated = false;
for (const key of Object.keys(raw)) {
if (key === GRID_VERSION_KEY) continue;
const value = raw[key];
if (!Array.isArray(value)) continue;
layouts[key] = value.map((item) => {
const copy = { ...(item as GridLayoutItem) };
if (needsScaling) {
for (const field of SCALED_FIELDS) {
const n = copy[field];
if (typeof n === 'number') {
copy[field] = n * GRID_SCALE_FACTOR;
}
}
migrated = true;
}
return copy;
});
}
return { layouts, migrated };
}
/**
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern.
*/
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> {
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
}
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* dashboard-store.test — NEU (quick-260916-bwo, feineres Dashboard-Raster).
*
* Sechs Tests fuer die Anbindung der einmaligen Umrechnung im Store:
* `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit
* Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 —
* fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand
* selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die
* Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten
* Vorgabegroessen an. Der Store hatte bisher keine Testdatei.
*/
vi.mock('@/lib/dashboard-api', () => ({
fetchLayout: vi.fn(),
fetchWidgets: vi.fn(),
saveLayout: vi.fn(),
addWidget: vi.fn(),
removeWidget: vi.fn(),
updateWidgetConfig: vi.fn(),
}));
import * as api from '@/lib/dashboard-api';
const { useDashboardStore } = await import('./dashboard-store');
const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] };
beforeEach(() => {
useDashboardStore.setState({
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [],
isEditMode: false,
isDirty: false,
isLoading: false,
error: null,
});
vi.clearAllMocks();
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
vi.mocked(api.saveLayout).mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT mit Marker gespeichert', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
});
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 2, w: 4, h: 4 });
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith(
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
);
expect(state.isDirty).toBe(false);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
});
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 1, y: 1, w: 2, h: 2 });
expect(api.saveLayout).not.toHaveBeenCalled();
expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
});
it('Test 3: leere Vorgabe-Anordnung ohne Marker -> kein Speichern', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
await useDashboardStore.getState().loadDashboard();
expect(api.saveLayout).not.toHaveBeenCalled();
expect(useDashboardStore.getState().layouts).toEqual(EMPTY);
});
it('Test 4: jedes Speichern traegt den Marker (T-BWO-02), der Zustand bleibt markerfrei', async () => {
const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] };
useDashboardStore.getState().updateLayouts(layouts);
expect(useDashboardStore.getState().isDirty).toBe(true);
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith(expect.objectContaining({ __gridVersion: 2, ...layouts }));
expect(useDashboardStore.getState().isDirty).toBe(false);
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
});
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
});
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined();
const state = useDashboardStore.getState();
expect(state.layouts.lg[0].x).toBe(2);
expect(state.error).toBeNull();
expect(state.isLoading).toBe(false);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen angelegt', async () => {
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
await useDashboardStore.getState().addWidget('clock');
const state = useDashboardStore.getState();
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
expect(state.isDirty).toBe(true);
});
});
+23 -3
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
export interface WidgetInstance {
id: string;
@@ -28,6 +29,14 @@ interface DashboardState {
/**
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
* Save only on exiting edit mode (D-01), not on every drag.
*
* quick-260916-bwo: Gespeicherte Anordnungen in alten Raster-Einheiten werden
* beim Laden einmalig umgerechnet (`migrateGridLayouts`) und sofort mit dem
* Marker `__gridVersion` zurueckgeschrieben. Der Marker lebt NUR im
* gespeicherten JSON, nie im Zustand (dieser Store iteriert mit Object.keys
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
@@ -108,15 +117,26 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
const [layouts, widgets] = await Promise.all([
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(),
api.fetchWidgets(),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({
layouts: layouts as DashboardState['layouts'],
layouts: migratedLayouts,
widgets,
isLoading: false,
});
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) {
try {
await api.saveLayout(withGridVersion(migratedLayouts));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
}
} catch {
set({
error: 'Failed to load dashboard',
@@ -127,7 +147,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
saveLayout: async () => {
try {
await api.saveLayout(get().layouts);
await api.saveLayout(withGridVersion(get().layouts));
set({ isDirty: false });
} catch (err) {
console.error('Failed to save layout:', err);