diff --git a/apps/web/src/components/dashboard/dashboard-grid.test.tsx b/apps/web/src/components/dashboard/dashboard-grid.test.tsx index 337437d..172bada 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.test.tsx @@ -194,7 +194,7 @@ describe('DashboardGrid', () => { expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument(); }); - it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { + it('quick-260916-bwo Test 4: Grid-Props — 48/40/24/16/4 Spalten (quick-260929-dmx), rowHeight 20, margin 12, Breakpoints unveraendert, containerPadding folgt dem margin', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( @@ -208,7 +208,7 @@ describe('DashboardGrid', () => { ); expect(captured.props).not.toBeNull(); - expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }); + expect(captured.props?.cols).toEqual({ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }); expect(captured.props?.rowHeight).toBe(20); expect(captured.props?.margin).toEqual([12, 12]); expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }); @@ -503,9 +503,9 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => { describe('centeringOffset (Design „Mosaik“, Runde 3)', () => { it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => { - // 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte. - expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300); - expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0); - expect(centeringOffset([], 24, 1212)).toBe(0); + // 48 Spalten auf 1212 px: Spalte 13 px + 12 px Abstand = 25 px je Spalte. + expect(centeringOffset([{ x: 0, w: 24 }], 48, 1212)).toBe(300); + expect(centeringOffset([{ x: 24, w: 24 }], 48, 1212)).toBe(0); + expect(centeringOffset([], 48, 1212)).toBe(0); }); }); diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index 402ee46..581769a 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -14,9 +14,16 @@ import { WidgetWrapper } from './widgets/widget-wrapper'; // quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten, // 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen // in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig -// umgerechnet und mit `__gridVersion: 2` markiert. BREAKPOINTS unveraendert. +// umgerechnet und mit `__gridVersion` markiert. BREAKPOINTS unveraendert. +// +// quick-260929-dmx (Version 3): Raster in der BREITE noch einmal doppelt so +// fein (48 statt 24 Spalten am lg-Breakpoint, uebrige Breakpoints ebenso +// verdoppelt). Zeilenhoehe (20 px) und Abstand (12 px) bleiben. Alle +// Breitenwerte (x, w, minW, maxW, WIDGET_CONSTRAINTS, Rueckfallwerte) sind +// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration +// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position. const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; -const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }; +const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; // quick-260916-dyv: Ziehen zuverlaessig. // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). @@ -379,10 +386,10 @@ export function DashboardGrid({ ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? { x: 0, y: 0, - w: constraints?.defaultW ?? 4, + w: constraints?.defaultW ?? 8, h: constraints?.defaultH ?? 4, }), - minW: constraints?.minW ?? 4, + minW: constraints?.minW ?? 8, minH: constraints?.minH ?? 4, }} > diff --git a/apps/web/src/lib/grid-layout-migration.test.ts b/apps/web/src/lib/grid-layout-migration.test.ts index fdad83e..a57f181 100644 --- a/apps/web/src/lib/grid-layout-migration.test.ts +++ b/apps/web/src/lib/grid-layout-migration.test.ts @@ -8,13 +8,14 @@ import { } from './grid-layout-migration'; /** - * grid-layout-migration.test — NEU (quick-260916-bwo, feineres Dashboard-Raster). + * grid-layout-migration.test (quick-260916-bwo, erweitert quick-260929-dmx). * - * 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. + * Tests fuer die stufenweise Umrechnung gespeicherter Anordnungen: + * v1 (12 Spalten / 40 px) -> v2 (24 / 20 px, alles x2) -> v3 (48 Spalten, + * nur Breite x2). Kern ist T-BWO-02: jede Stufe darf GENAU EINMAL geschehen. + * Der Marker `__gridVersion` steht dafuer im gespeicherten JSON, nie im + * Zustand — der Idempotenz-Test wird rot, sobald jemand die Marker-Pruefung + * entfernt. */ function altLayouts() { @@ -31,14 +32,14 @@ function altLayouts() { } 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', () => { + it('Test 1: v1-Anordnung ohne Marker -> beide Stufen: x/w/minW/maxW x4, y/h/minH/maxH 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, + i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, 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.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 }); + expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 }); expect(layouts.sm).toEqual([]); expect(layouts.xs).toEqual([]); expect(layouts.xxs).toEqual([]); @@ -47,15 +48,44 @@ describe('grid-layout-migration (quick-260916-bwo)', () => { expect(GRID_SCALE_FACTOR).toBe(2); }); - it('Test 2: markierte Anordnung (__gridVersion 2) bleibt unveraendert, migrated false', () => { + it('Test 2: v2-Anordnung (__gridVersion 2) -> nur die zweite Stufe: x/w/minW/maxW x2, y/h/minH/maxH unveraendert, migrated true', () => { const alt = altLayouts(); const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 }); + expect(layouts.lg[0]).toEqual({ + i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false, + }); + expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 }); + expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 }); + expect(migrated).toBe(true); + expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY); + }); + + it('Test 2b: v3-Anordnung (__gridVersion 3) bleibt unveraendert, migrated false', () => { + const alt = altLayouts(); + const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 3 }); + expect(layouts).toEqual(alt); expect(migrated).toBe(false); expect(Object.keys(layouts)).not.toContain(GRID_VERSION_KEY); }); + it('Test 2c: v1 und v2 -> v3 ergeben dieselbe Anordnung, wenn die v2-Eingabe die v1-Eingabe x2 ist', () => { + const v1 = altLayouts(); + const v2 = { + lg: [ + { i: 'a', x: 2, y: 4, w: 4, h: 6, minW: 4, minH: 4, moved: false, static: false }, + { i: 'b', x: 4, y: 0, w: 12, h: 4, maxW: 24, maxH: 16 }, + ], + md: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }], + sm: [], + xs: [], + xxs: [], + __gridVersion: 2, + }; + expect(migrateGridLayouts(v2).layouts).toEqual(migrateGridLayouts(v1).layouts); + }); + it('Test 3: leere Anordnung bleibt leer, migrated false (kein Speichern noetig)', () => { const empty = { lg: [], md: [], sm: [], xs: [], xxs: [] }; const r1 = migrateGridLayouts(empty); @@ -73,6 +103,14 @@ describe('grid-layout-migration (quick-260916-bwo)', () => { expect(twice.layouts).toEqual(once.layouts); expect(twice.migrated).toBe(false); + + // Auch die einstufige v2 -> v3 ist idempotent, und der Marker 3 wird geschrieben. + const onceV2 = migrateGridLayouts({ ...altLayouts(), __gridVersion: 2 }); + const marked = withGridVersion(onceV2.layouts); + expect(marked[GRID_VERSION_KEY]).toBe(3); + const twiceV2 = migrateGridLayouts(marked); + expect(twiceV2.layouts).toEqual(onceV2.layouts); + expect(twiceV2.migrated).toBe(false); }); it('Test 5: withGridVersion haengt den Marker an, ohne die Eingabe zu veraendern', () => { @@ -80,30 +118,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => { const marked = withGridVersion(layouts); expect(marked[GRID_VERSION_KEY]).toBe(GRID_VERSION); - expect(GRID_VERSION).toBe(2); + expect(GRID_VERSION).toBe(3); 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)[GRID_VERSION_KEY]).toBeUndefined(); }); - it('Test 6: Zukunft und Robustheit — Marker 3 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => { + it('Test 6: Zukunft und Robustheit — Marker 4 bleibt, Zeichenketten-Marker zaehlt nicht, nicht-numerische Felder bleiben', () => { const alt = altLayouts(); - const future = migrateGridLayouts({ ...alt, __gridVersion: 3 }); + const future = migrateGridLayouts({ ...alt, __gridVersion: 4 }); 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(stringMarker.layouts.lg[0].x).toBe(4); 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, + i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false, }); expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false); expect(odd.migrated).toBe(true); @@ -112,7 +150,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => { 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.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 }); expect(r.migrated).toBe(true); expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false }); diff --git a/apps/web/src/lib/grid-layout-migration.ts b/apps/web/src/lib/grid-layout-migration.ts index b3d622a..4604aa9 100644 --- a/apps/web/src/lib/grid-layout-migration.ts +++ b/apps/web/src/lib/grid-layout-migration.ts @@ -1,23 +1,35 @@ /** - * grid-layout-migration — einmalige Umrechnung gespeicherter Dashboard- - * Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo). + * grid-layout-migration — einmalige, stufenweise Umrechnung gespeicherter + * Dashboard-Anordnungen in die feineren Raster-Einheiten (quick-260916-bwo, + * quick-260929-dmx). * - * 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. + * 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: * - * 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). + * - 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`. @@ -28,7 +40,7 @@ * React- oder Store-Abhaengigkeit. */ -export const GRID_VERSION = 2; +export const GRID_VERSION = 3; export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_SCALE_FACTOR = 2; @@ -43,7 +55,13 @@ export interface GridLayoutItem { export type GridLayouts = Record; -const SCALED_FIELDS = ['x', 'y', 'w', 'h', 'minW', 'minH', 'maxW', 'maxH'] as const; +/** 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); @@ -62,7 +80,8 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat 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 steps = MIGRATION_STEPS.filter((step) => version <= step.from); + const needsScaling = steps.length > 0; const layouts: GridLayouts = {}; let migrated = false; @@ -75,10 +94,12 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat 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; + 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; diff --git a/apps/web/src/lib/stores/dashboard-store.test.ts b/apps/web/src/lib/stores/dashboard-store.test.ts index 9c2a543..b339c6f 100644 --- a/apps/web/src/lib/stores/dashboard-store.test.ts +++ b/apps/web/src/lib/stores/dashboard-store.test.ts @@ -74,12 +74,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) 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(state.layouts.lg[0]).toEqual({ i: 'a', x: 4, y: 2, w: 8, h: 4 }); expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledWith( 'dash-1', - expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }), + expect.objectContaining({ __gridVersion: 3, lg: [{ i: 'a', x: 4, y: 2, w: 8, h: 4 }] }), ); expect(state.isDirty).toBe(false); expect(state.isLoading).toBe(false); @@ -88,7 +88,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) 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, + lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, }); await useDashboardStore.getState().loadDashboard(); @@ -121,7 +121,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledWith( 'dash-1', - expect.objectContaining({ __gridVersion: 2, ...layouts }), + expect.objectContaining({ __gridVersion: 3, ...layouts }), ); expect(useDashboardStore.getState().isDirty).toBe(false); expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion'); @@ -137,7 +137,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo) await expect(useDashboardStore.getState().loadDashboard()).resolves.toBeUndefined(); const state = useDashboardStore.getState(); - expect(state.layouts.lg[0].x).toBe(2); + expect(state.layouts.lg[0].x).toBe(4); expect(state.error).toBeNull(); expect(state.isLoading).toBe(false); expect(errorSpy).toHaveBeenCalledTimes(1); @@ -213,7 +213,7 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => { await useDashboardStore.getState().loadDashboard(); vi.mocked(api.fetchLayout).mockResolvedValue({ - lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2, + lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, }); vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);