feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s

Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:19:27 +02:00
parent bd73fa5e74
commit af87c2c112
9 changed files with 653 additions and 79 deletions
+80 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
GRID_CANVAS_KEY,
GRID_SCALE_FACTOR,
GRID_VERSION,
GRID_VERSION_KEY,
@@ -192,10 +193,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
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, newer: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(null)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts(undefined)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts(42)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts([1, 2])).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
});
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
@@ -209,3 +230,58 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(future.layouts).toEqual(alt);
});
});
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
expect(r.canvas).toEqual({ w: 1600, h: 820 });
expect(Object.keys(r.layouts)).toEqual(['lg']);
expect(r.migrated).toBe(false);
});
it('fehlende oder ungueltige Werte -> null', () => {
const bad: unknown[] = [
undefined,
null,
'x',
[1600, 800],
{ w: 1600 },
{ w: '1600', h: 800 },
{ w: Number.NaN, h: 800 },
{ w: Number.POSITIVE_INFINITY, h: 800 },
{ w: 767, h: 800 },
{ w: 1600, h: 199 },
];
for (const value of bad) {
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
}
// Grenzwerte sind gueltig.
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
w: 768,
h: 200,
});
});
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
const { layouts } = migrateGridLayouts(v3());
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
...layouts,
[GRID_VERSION_KEY]: GRID_VERSION,
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
});
});
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
expect(once.migrated).toBe(true);
expect(once.canvas).toEqual({ w: 1400, h: 700 });
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
expect(twice.migrated).toBe(false);
expect(twice.layouts).toEqual(once.layouts);
expect(twice.canvas).toEqual(once.canvas);
});
});
+51 -6
View File
@@ -41,6 +41,19 @@
* 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
@@ -50,6 +63,17 @@
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;
@@ -74,6 +98,19 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
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
@@ -84,9 +121,11 @@ export function migrateGridLayouts(raw: unknown): {
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 };
return { layouts: {}, migrated: false, newer: false, canvas: null };
}
const markerValue = raw[GRID_VERSION_KEY];
@@ -100,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): {
let migrated = false;
for (const key of Object.keys(raw)) {
if (key === GRID_VERSION_KEY) continue;
if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
const value = raw[key];
if (!Array.isArray(value)) continue;
@@ -121,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): {
});
}
return { layouts, migrated, newer };
return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
}
/**
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern.
* Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
* an, ohne die Eingabe zu veraendern.
*/
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> {
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
export function withGridVersion(
layouts: GridLayouts,
canvas?: GridCanvas | null,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
return result;
}
@@ -54,6 +54,7 @@ beforeEach(() => {
isLoading: false,
error: null,
layoutFromNewerVersion: false,
canvas: null,
});
vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -454,3 +455,106 @@ describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)'
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
});
});
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1600, h: 820 },
});
// Danach aendert sie sich nicht mehr automatisch.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1920, h: 950 },
});
});
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
__canvas: { w: 1400, h: 700 },
});
await useDashboardStore.getState().loadDashboard();
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
);
});
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
);
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(useDashboardStore.getState().canvas).toBeNull();
// Erfassen gilt nun fuer dash-2.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).toHaveBeenLastCalledWith(
'dash-2',
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
);
await useDashboardStore.getState().selectDashboard('dash-1');
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
});
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
useDashboardStore.setState({ activeDashboardId: null });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
expect(useDashboardStore.getState().error).toBeNull();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
+62 -6
View File
@@ -3,7 +3,14 @@ import type { WidgetType } from '@/components/dashboard/widget-registry';
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
import {
CANVAS_MIN_HEIGHT,
CANVAS_MIN_WIDTH,
type GridCanvas,
migrateGridLayouts,
parseGridCanvas,
withGridVersion,
} from '@/lib/grid-layout-migration';
export interface WidgetInstance {
id: string;
@@ -28,6 +35,12 @@ interface DashboardState {
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/
layoutFromNewerVersion: boolean;
/**
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
* noch nicht erfasst.
*/
canvas: GridCanvas | null;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -38,6 +51,8 @@ interface DashboardState {
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>;
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
captureCanvas: (area: GridCanvas) => Promise<void>;
createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>;
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter.
*
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
* erfasst.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [],
@@ -104,6 +127,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isLoading: false,
error: null,
layoutFromNewerVersion: false,
canvas: null,
setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
@@ -214,11 +238,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id),
api.fetchWidgets(first.id),
]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({
dashboards,
activeDashboardId: first.id,
layouts: migratedLayouts,
canvas,
widgets,
isLoading: false,
layoutFromNewerVersion: newer,
@@ -229,7 +254,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) {
try {
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
@@ -249,7 +274,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return;
try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
set({ isDirty: false });
} catch (err) {
console.error('Failed to save layout:', err);
@@ -275,10 +300,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({
activeDashboardId: id,
layouts: migratedLayouts,
canvas,
widgets,
isDirty: false,
isSwitchingDashboard: false,
@@ -287,7 +313,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
});
if (migrated) {
try {
await api.saveLayout(id, withGridVersion(migratedLayouts));
await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
@@ -298,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}
},
/**
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
*
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
* spaeteres `saveLayout` schreibt sie dann mit.
*/
captureCanvas: async (area: GridCanvas) => {
const state = get();
const dashboardId = state.activeDashboardId;
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
if (!canvas) return;
set({ canvas });
try {
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
} catch (err) {
console.error('Failed to persist dashboard canvas:', err);
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => {
try {