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
@@ -1,3 +1,4 @@
import { Children, isValidElement } from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -34,13 +35,15 @@ vi.mock('next-intl', () => ({
},
}));
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability
// Mock react-grid-layout Responsive to a simple passthrough for jsdom stability.
// quick-260916-bwo: der Mock faengt zusaetzlich die Props ein, damit die
// Raster-Konstanten (cols, rowHeight, margin, breakpoints) pruefbar sind.
const captured = vi.hoisted(() => ({ props: null as Record<string, unknown> | null }));
vi.mock('react-grid-layout', () => ({
Responsive: ({
children,
}: {
children: React.ReactNode;
}) => <div data-testid="responsive-grid">{children}</div>,
Responsive: (props: Record<string, unknown> & { children: React.ReactNode }) => {
captured.props = props;
return <div data-testid="responsive-grid">{props.children}</div>;
},
noCompactor: () => null,
}));
@@ -153,4 +156,46 @@ describe('DashboardGrid', () => {
expect(screen.getByLabelText('Remove widget')).toBeInTheDocument();
expect(document.querySelector('.widget-drag-handle')).toBeInTheDocument();
});
it('quick-260916-bwo Test 4: Grid-Props — 24/20/12/8/2 Spalten, rowHeight 20, margin 8, Breakpoints unveraendert, containerPadding folgt dem margin', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
<DashboardGrid
layouts={{ lg: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-1', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(captured.props).not.toBeNull();
expect(captured.props?.cols).toEqual({ lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 });
expect(captured.props?.rowHeight).toBe(20);
expect(captured.props?.margin).toEqual([8, 8]);
expect(captured.props?.breakpoints).toEqual({ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 });
expect(captured.props?.containerPadding).toBeUndefined();
});
it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die verdoppelten Vorgaben als data-grid', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
render(
<DashboardGrid
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
widgets={[{ id: 'inst-3', widgetType: 'clock', config: {} }]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const children = Children.toArray(captured.props?.children as React.ReactNode);
expect(children).toHaveLength(1);
const first = children[0];
expect(isValidElement(first)).toBe(true);
const props = (first as React.ReactElement<Record<string, unknown>>).props;
expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 });
});
});
@@ -10,8 +10,12 @@ import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
import { WidgetWrapper } from './widgets/widget-wrapper';
import { TesseraLogo } from '@/components/brand/tessera-logo';
// 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.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 1 };
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
interface DashboardGridProps {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
@@ -74,8 +78,10 @@ export function DashboardGrid({
breakpoints={BREAKPOINTS}
cols={COLS}
layouts={layouts as ResponsiveLayouts}
rowHeight={40}
margin={[16, 16] as [number, number]}
rowHeight={20}
margin={[8, 8] as [number, number]}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
dragConfig={{
enabled: isEditMode,
handle: '.widget-drag-handle',
@@ -95,11 +101,11 @@ export function DashboardGrid({
...(layouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0,
y: 0,
w: constraints?.defaultW ?? 2,
h: constraints?.defaultH ?? 2,
w: constraints?.defaultW ?? 4,
h: constraints?.defaultH ?? 4,
}),
minW: constraints?.minW ?? 2,
minH: constraints?.minH ?? 2,
minW: constraints?.minW ?? 4,
minH: constraints?.minH ?? 4,
}}
>
<WidgetWrapper
@@ -52,4 +52,28 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link');
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
});
it('quick-260916-bwo: jede Groesse ist exakt das Doppelte der alten 12-Spalten-Werte', () => {
// Raster seit quick-260916-bwo 24 Spalten / 20 px statt 12 / 40 — jeder Wert
// ist das Doppelte, damit Widgets optisch gleich gross bleiben.
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
});
let counted = 0;
for (const type of ALL_WIDGET_TYPES) {
for (const field of ['minW', 'minH', 'defaultW', 'defaultH'] as const) {
expect(WIDGET_CONSTRAINTS[type][field] % 2, `${type}.${field} muss gerade sein`).toBe(0);
counted++;
}
}
expect(counted).toBe(32);
});
});
@@ -32,16 +32,17 @@ export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
// Existing widgets (values unchanged)
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
// quick-260916-bwo: Raster verdoppelt (24 Spalten / 20 px) — jeder Wert ist das Doppelte des alten 12-Spalten-Werts, Widgets bleiben optisch gleich gross.
// Existing widgets
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01)
calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 },
favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 },
link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 },
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 },
};
/**