diff --git a/apps/web/src/components/dashboard/dashboard-grid.test.tsx b/apps/web/src/components/dashboard/dashboard-grid.test.tsx index 034340b..c442636 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.test.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.test.tsx @@ -178,7 +178,7 @@ describe('DashboardGrid', () => { expect(captured.props?.containerPadding).toBeUndefined(); }); - it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die verdoppelten Vorgaben als data-grid', async () => { + it('quick-260916-bwo Test 5: Widget ohne gespeicherten Eintrag bekommt die Vorgaben und die inhaltsgetriebenen Minima als data-grid', async () => { captured.props = null; const { DashboardGrid } = await import('./dashboard-grid'); render( @@ -196,6 +196,81 @@ describe('DashboardGrid', () => { const first = children[0]; expect(isValidElement(first)).toBe(true); const props = (first as React.ReactElement>).props; - expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }); + // quick-260916-dyv: Uhr-Minimum 2x2 (vorher verdoppelt 4x4), Vorgabe 4x4 unveraendert. + expect(props['data-grid']).toEqual({ x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); + }); + + it('quick-260916-dyv Test 9: gespeicherte minW/minH werden in JEDEM Breakpoint aus WIDGET_CONSTRAINTS ueberschrieben, x/y/w/h bleiben, unbekannte Typen und Schluessel unveraendert', async () => { + captured.props = null; + const { DashboardGrid } = await import('./dashboard-grid'); + const layouts = { + lg: [ + { i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 8, minH: 8 }, + { i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 }, + ], + md: [{ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 4, minH: 4 }], + sm: [], + xs: [], + xxs: [], + }; + render( + , + ); + + const passed = captured.props?.layouts as Record>>; + expect(Object.keys(passed)).toEqual(Object.keys(layouts)); + expect(passed.lg[0]).toEqual({ i: 'inst-1', x: 2, y: 4, w: 6, h: 6, minW: 2, minH: 2 }); + expect(passed.md[0]).toEqual({ i: 'inst-1', x: 0, y: 0, w: 4, h: 4, minW: 2, minH: 2 }); + expect(passed.md[0].minW).toBe(2); + // Unbekannter Typ: Eintrag bleibt woertlich erhalten (kein Absturz, T-DYV-01). + expect(passed.lg[1]).toEqual({ i: 'inst-x', x: 10, y: 0, w: 5, h: 5, minW: 7, minH: 7 }); + expect(passed.sm).toEqual([]); + }); + + it('quick-260916-dyv Test 9b: gespeicherte Groesse unter dem neuen Minimum wird auf das Minimum angehoben (Rechner h 8 -> 9, w bleibt wenn >= minW)', async () => { + captured.props = null; + const { DashboardGrid } = await import('./dashboard-grid'); + const layouts = { + lg: [ + // Rechner: gespeichert mit h 8 (bwo-Minimum), neues Minimum 3x9 -> h wird 9, w 6 bleibt. + { i: 'calc-1', x: 0, y: 0, w: 6, h: 8, minW: 4, minH: 8 }, + // Suche: gespeichert mit w 4 (unter minW 6) -> w wird 6, h 4 bleibt. + { i: 'srch-1', x: 6, y: 0, w: 4, h: 4, minW: 6, minH: 4 }, + ], + md: [{ i: 'calc-1', x: 0, y: 0, w: 3, h: 7, minW: 4, minH: 8 }], + sm: [], + xs: [], + xxs: [], + }; + render( + , + ); + + const passed = captured.props?.layouts as Record>>; + expect(passed.lg[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 6, h: 9, minW: 3, minH: 9 }); + expect(passed.lg[1]).toEqual({ i: 'srch-1', x: 6, y: 0, w: 6, h: 4, minW: 6, minH: 2 }); + // Jeder Breakpoint: md-Rechner 3x7 -> 3x9 (w schon = minW, h angehoben). + expect(passed.md[0]).toEqual({ i: 'calc-1', x: 0, y: 0, w: 3, h: 9, minW: 3, minH: 9 }); + // Eingabe unveraendert (keine Mutation des Store-Objekts). + expect(layouts.lg[0].h).toBe(8); + expect(layouts.lg[0].minH).toBe(8); }); }); diff --git a/apps/web/src/components/dashboard/dashboard-grid.tsx b/apps/web/src/components/dashboard/dashboard-grid.tsx index 27ca937..1b4fd59 100644 --- a/apps/web/src/components/dashboard/dashboard-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-grid.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Responsive, noCompactor } from 'react-grid-layout'; import type { ResponsiveLayouts } from 'react-grid-layout'; import 'react-grid-layout/css/styles.css'; @@ -17,14 +17,72 @@ import { TesseraLogo } from '@/components/brand/tessera-logo'; const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 }; +interface LayoutItemShape { + i: string; + x: number; + y: number; + w: number; + h: number; + minW?: number; + minH?: number; +} + interface DashboardGridProps { - layouts: Record>; + layouts: Record>; widgets: Array<{ id: string; widgetType: string; config: Record }>; isEditMode: boolean; onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onRemoveWidget: (id: string) => void; } +/** + * quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben. + * + * Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren` + * (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den + * Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest + * `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen + * minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt + * persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer + * bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes + * Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind + * die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht). + * + * Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit + * h 8, neues minH 9), wird w/h auf das Minimum angehoben: RGL selbst klemmt + * w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`, + * chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`, + * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — + * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. + * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. + */ +function applyConstraintMinima( + layouts: Record>, + widgets: Array<{ id: string; widgetType: string }>, +): Record> { + const typeById = new Map(); + for (const w of widgets) typeById.set(w.id, w.widgetType); + + const result: Record> = {}; + for (const key of Object.keys(layouts)) { + const entries = layouts[key]; + if (!Array.isArray(entries)) continue; + result[key] = entries.map((entry) => { + const type = typeById.get(entry.i); + const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; + if (!constraints) return { ...entry }; + return { + ...entry, + w: Math.max(entry.w, constraints.minW), + h: Math.max(entry.h, constraints.minH), + minW: constraints.minW, + minH: constraints.minH, + }; + }); + } + return result; +} + /** * Dashboard grid wrapper using react-grid-layout v2 Responsive component. * Renders widgets in a draggable/resizable grid when in edit mode. @@ -53,6 +111,11 @@ export function DashboardGrid({ return () => observer.disconnect(); }, []); + // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — + // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge + // stabil bleibt. + const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); + // Empty state (D-02) if (widgets.length === 0) { return ( @@ -77,7 +140,7 @@ export function DashboardGrid({ width={width} breakpoints={BREAKPOINTS} cols={COLS} - layouts={layouts as ResponsiveLayouts} + layouts={effectiveLayouts as ResponsiveLayouts} rowHeight={20} margin={[8, 8] as [number, number]} // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt @@ -98,7 +161,7 @@ export function DashboardGrid({
l.i === widget.id) ?? { + ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? { x: 0, y: 0, w: constraints?.defaultW ?? 4, diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index 25c7ef8..b0f95bb 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -53,24 +53,26 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { 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. + it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => { + // Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit + // quick-260916-dyv inhaltsgetrieben (Rechnung im Plan), die Vorgaben + // (defaultW/defaultH) bleiben das Doppelte der alten 12-Spalten-Werte. + // 3 und 9 sind ungerade — die bwo-Pruefung "jeder Wert ist gerade" entfaellt. 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 }, + clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, + search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, + calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 }, + note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, + calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 }, + favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, + link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 }, + stopwatch: { minW: 4, minH: 3, 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); + expect(typeof WIDGET_CONSTRAINTS[type][field]).toBe('number'); counted++; } } diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index a8521cb..e0bbd8e 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -32,17 +32,22 @@ export const WIDGET_CONSTRAINTS: Record< WidgetType, { minW: number; minH: number; defaultW: number; defaultH: number } > = { - // quick-260916-bwo: Raster verdoppelt (24 Spalten / 20 px) — jeder Wert ist das Doppelte des alten 12-Spalten-Werts, Widgets bleiben optisch gleich gross. + // quick-260916-dyv: minW/minH = kleinste noch bedienbare Kachel je Typ im + // 24-Spalten/20-px-Raster, aus dem Innenaufbau gerechnet (Suche: Auswahl 120 + + // Eingabe + Knopf; Rechner: Anzeige 40 + Speicherzeile 28 + 5 Tastenreihen 28 = + // 240 px -> 9 Zeilen; Stoppuhr: kompakte Bedienleiste). defaultW/defaultH = + // altes 12-Spalten-Mass x2, unveraendert. Gespeicherte minW/minH werden in + // dashboard-grid.tsx aus dieser Tabelle ueberschrieben. // 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 }, + clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, + search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, + calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 }, + note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, // Phase 8 new widgets (D-01) - 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 }, + calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 }, + favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, + link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 }, + stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 }, }; /** diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx index 903fce4..35affec 100644 --- a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx @@ -200,14 +200,17 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
- {/* Controls */} -
+ {/* Controls + quick-260916-dyv: kompakte Bedienleiste, damit die laufende Stoppuhr + (Stop + Runde + Reset) in 4 Spalten passt (ca. 151 px statt ca. 222 px) + und die Kachel auf 4x3 schrumpfen kann. */} +
{!isRunning ? ( @@ -217,7 +220,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }: type="button" aria-label={t('stopwatch.stop')} onClick={handleStop} - className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity" + className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity" > {t('stopwatch.stop')} @@ -225,7 +228,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }: type="button" aria-label={t('stopwatch.lap')} onClick={handleLap} - className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity" + className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground hover:opacity-90 transition-opacity" > {t('stopwatch.lap')} @@ -235,7 +238,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }: type="button" aria-label={t('stopwatch.reset')} onClick={handleReset} - className="rounded-md border border-border px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" + className="rounded-md border border-border px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors" > {t('stopwatch.reset')}