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
+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 };
}