/** * grid-layout-migration — einmalige, stufenweise Umrechnung gespeicherter * Dashboard-Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo, * quick-260929-dmx). * * Warum: Das Raster wurde zweimal feiner. Eine in ALTEN Einheiten gespeicherte * Anordnung wuerde im neuen Raster kleiner und an anderer Position erscheinen. * Deshalb wird jedes Element in jedem Breakpoint stufenweise umgerechnet, * jede Stufe GENAU EINMAL: * * - Version 1 -> 2 (quick-260916-bwo): 12 Spalten / 40 px Zeilenhoehe wurden * 24 Spalten / 20 px. `x, y, w, h` (und, falls vorhanden, `minW, minH, maxW, * maxH`) werden mit 2 multipliziert. * - Version 2 -> 3 (quick-260929-dmx): NUR die Breite wurde noch einmal * verdoppelt (24 -> 48 Spalten am lg-Breakpoint, Zeilenhoehe und Abstand * unveraendert). Nur `x, w, minW, maxW` werden mit 2 multipliziert; `y, h, * minH, maxH` bleiben. * * Die Stufen sind kumulativ: eine Version-1-Anordnung durchlaeuft beide * (x/w/minW/maxW also x4, y/h/minH/maxH x2), eine Version-2-Anordnung nur die * zweite, eine Version-3-Anordnung keine. * * Marker: Damit jede Stufe nur einmal geschieht, traegt das gespeicherte JSON * den Schluessel `__gridVersion` (aktuell 3). 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 umgerechnet — * 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`. * * Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als * `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein * frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber * `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er * wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde * sie beim naechsten Laden ein zweites Mal skalieren. * * Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das * JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche * (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet * sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das * Ergebnis per `transform: scale` in die dortige Flaeche (siehe * dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in * `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`, * `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER * Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer * `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil * sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben * ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst. * * 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 = 3; export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_SCALE_FACTOR = 2; export const GRID_CANVAS_KEY = '__canvas'; /** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */ export const CANVAS_MIN_WIDTH = 768; /** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */ export const CANVAS_MIN_HEIGHT = 200; /** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */ export interface GridCanvas { w: number; h: number; } export interface GridLayoutItem { i: string; x: number; y: number; w: number; h: number; [key: string]: unknown; } export type GridLayouts = Record; /** Felder je Umrechnungsstufe; `from` ist die Version, von der die Stufe ausgeht. */ const MIGRATION_STEPS: ReadonlyArray<{ from: number; fields: readonly string[] }> = [ // 1 -> 2 (quick-260916-bwo): Spalten UND Zeilen verdoppelt. { from: 1, fields: ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] }, // 2 -> 3 (quick-260929-dmx): nur die Breite verdoppelt. { from: 2, fields: ['x', 'w', 'minW', 'maxW'] }, ]; function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH, * h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst). */ export function parseGridCanvas(value: unknown): GridCanvas | null { if (!isPlainObject(value)) return null; const { w, h } = value; if (typeof w !== 'number' || typeof h !== 'number') return null; if (!Number.isFinite(w) || !Number.isFinite(h)) return null; if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null; return { w, h }; } /** * 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; /** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */ newer: boolean; /** Gueltige Leinwand aus `__canvas`, sonst null. */ canvas: GridCanvas | null; } { if (!isPlainObject(raw)) { return { layouts: {}, migrated: false, newer: false, canvas: null }; } 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 newer = version > GRID_VERSION; const steps = MIGRATION_STEPS.filter((step) => version <= step.from); const needsScaling = steps.length > 0; const layouts: GridLayouts = {}; let migrated = false; for (const key of Object.keys(raw)) { if (key === GRID_VERSION_KEY || key === GRID_CANVAS_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 step of steps) { for (const field of step.fields) { const n = copy[field]; if (typeof n === 'number') { copy[field] = n * GRID_SCALE_FACTOR; } } } migrated = true; } return copy; }); } return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) }; } /** * Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern * an, ohne die Eingabe zu veraendern. */ export function withGridVersion( layouts: GridLayouts, canvas?: GridCanvas | null, ): Record { const result: Record = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION }; if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h }; return result; }