fix(quick-260916-dyv): Mindestgroessen inhaltsgetrieben (kleinste bedienbare Kachel je Typ), gespeicherte Minima ueberschrieben, Stoppuhr-Bedienleiste kompakt

- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2,
  calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3),
  Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual
- dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes
  gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu
  kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege
  woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag
- dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je
  Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt)
- stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit
  Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann

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 10:37:55 +02:00
parent ec1b0ce582
commit dc992c9bc6
5 changed files with 181 additions and 33 deletions
@@ -178,7 +178,7 @@ describe('DashboardGrid', () => {
expect(captured.props?.containerPadding).toBeUndefined(); 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; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
render( render(
@@ -196,6 +196,81 @@ describe('DashboardGrid', () => {
const first = children[0]; const first = children[0];
expect(isValidElement(first)).toBe(true); expect(isValidElement(first)).toBe(true);
const props = (first as React.ReactElement<Record<string, unknown>>).props; 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 }); // 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(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'inst-1', widgetType: 'clock', config: {} },
{ id: 'inst-x', widgetType: 'unknown', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
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(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'calc-1', widgetType: 'calculator', config: {} },
{ id: 'srch-1', widgetType: 'search', config: {} },
]}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
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);
}); });
}); });
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout'; import { Responsive, noCompactor } from 'react-grid-layout';
import type { ResponsiveLayouts } from 'react-grid-layout'; import type { ResponsiveLayouts } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css'; 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 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: 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 { interface DashboardGridProps {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>; layouts: Record<string, Array<LayoutItemShape>>;
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>; widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
isEditMode: boolean; isEditMode: boolean;
onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
onRemoveWidget: (id: string) => 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<string, Array<LayoutItemShape>>,
widgets: Array<{ id: string; widgetType: string }>,
): Record<string, Array<LayoutItemShape>> {
const typeById = new Map<string, string>();
for (const w of widgets) typeById.set(w.id, w.widgetType);
const result: Record<string, Array<LayoutItemShape>> = {};
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. * Dashboard grid wrapper using react-grid-layout v2 Responsive component.
* Renders widgets in a draggable/resizable grid when in edit mode. * Renders widgets in a draggable/resizable grid when in edit mode.
@@ -53,6 +111,11 @@ export function DashboardGrid({
return () => observer.disconnect(); 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) // Empty state (D-02)
if (widgets.length === 0) { if (widgets.length === 0) {
return ( return (
@@ -77,7 +140,7 @@ export function DashboardGrid({
width={width} width={width}
breakpoints={BREAKPOINTS} breakpoints={BREAKPOINTS}
cols={COLS} cols={COLS}
layouts={layouts as ResponsiveLayouts} layouts={effectiveLayouts as ResponsiveLayouts}
rowHeight={20} rowHeight={20}
margin={[8, 8] as [number, number]} margin={[8, 8] as [number, number]}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
@@ -98,7 +161,7 @@ export function DashboardGrid({
<div <div
key={widget.id} key={widget.id}
data-grid={{ data-grid={{
...(layouts.lg?.find((l) => l.i === widget.id) ?? { ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
x: 0, x: 0,
y: 0, y: 0,
w: constraints?.defaultW ?? 4, w: constraints?.defaultW ?? 4,
@@ -53,24 +53,26 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
}); });
it('quick-260916-bwo: jede Groesse ist exakt das Doppelte der alten 12-Spalten-Werte', () => { it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert', () => {
// Raster seit quick-260916-bwo 24 Spalten / 20 px statt 12 / 40 — jeder Wert // Raster 24 Spalten / 20 px (quick-260916-bwo). Die Minima sind seit
// ist das Doppelte, damit Widgets optisch gleich gross bleiben. // 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({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 }, clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 }, calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 }, calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 }, link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
}); });
let counted = 0; let counted = 0;
for (const type of ALL_WIDGET_TYPES) { for (const type of ALL_WIDGET_TYPES) {
for (const field of ['minW', 'minH', 'defaultW', 'defaultH'] as const) { 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++; counted++;
} }
} }
@@ -32,17 +32,22 @@ export const WIDGET_CONSTRAINTS: Record<
WidgetType, WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number } { 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 // Existing widgets
clock: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 }, clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
search: { minW: 6, minH: 4, defaultW: 12, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
calendar: { minW: 6, minH: 6, defaultW: 8, defaultH: 12 }, calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 },
note: { minW: 4, minH: 6, defaultW: 6, defaultH: 8 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
calculator: { minW: 4, minH: 8, defaultW: 6, defaultH: 10 }, calculator: { minW: 3, minH: 9, defaultW: 6, defaultH: 10 },
favorites: { minW: 4, minH: 6, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
link: { minW: 4, minH: 4, defaultW: 4, defaultH: 4 }, link: { minW: 3, minH: 2, defaultW: 4, defaultH: 4 },
stopwatch: { minW: 4, minH: 4, defaultW: 6, defaultH: 6 }, stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
}; };
/** /**
@@ -200,14 +200,17 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
</span> </span>
</div> </div>
{/* Controls */} {/* Controls
<div className="flex items-center justify-center gap-2 py-2"> 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. */}
<div className="flex items-center justify-center gap-1 py-1">
{!isRunning ? ( {!isRunning ? (
<button <button
type="button" type="button"
aria-label={t('stopwatch.start')} aria-label={t('stopwatch.start')}
onClick={handleStart} onClick={handleStart}
className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity" className="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
> >
{t('stopwatch.start')} {t('stopwatch.start')}
</button> </button>
@@ -217,7 +220,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button" type="button"
aria-label={t('stopwatch.stop')} aria-label={t('stopwatch.stop')}
onClick={handleStop} 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')} {t('stopwatch.stop')}
</button> </button>
@@ -225,7 +228,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button" type="button"
aria-label={t('stopwatch.lap')} aria-label={t('stopwatch.lap')}
onClick={handleLap} 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')} {t('stopwatch.lap')}
</button> </button>
@@ -235,7 +238,7 @@ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }:
type="button" type="button"
aria-label={t('stopwatch.reset')} aria-label={t('stopwatch.reset')}
onClick={handleReset} 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')} {t('stopwatch.reset')}
</button> </button>