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,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);