-
onLayoutChange(allLayouts)}
- >
- {widgets.map((widget, index) => {
- const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
- return (
- l.i === widget.id) ?? {
- x: 0,
- y: 0,
- w: constraints?.defaultW ?? 8,
- h: constraints?.defaultH ?? 4,
- }),
- minW: constraints?.minW ?? 8,
- minH: constraints?.minH ?? 4,
- }}
- >
-
-
- );
- })}
-
+
+
+
onLayoutChange(allLayouts)}
+ >
+ {widgets.map((widget, index) => {
+ const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
+ return (
+ l.i === widget.id) ?? {
+ x: 0,
+ y: 0,
+ w: constraints?.defaultW ?? 8,
+ h: constraints?.defaultH ?? 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 d89ee47..4a568fe 100644
--- a/apps/web/src/lib/grid-layout-migration.test.ts
+++ b/apps/web/src/lib/grid-layout-migration.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/lib/grid-layout-migration.ts b/apps/web/src/lib/grid-layout-migration.ts
index 1359542..661e359 100644
--- a/apps/web/src/lib/grid-layout-migration.ts
+++ b/apps/web/src/lib/grid-layout-migration.ts
@@ -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
{
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 {
- return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
+export function withGridVersion(
+ layouts: GridLayouts,
+ canvas?: GridCanvas | null,
+): Record {
+ const result: Record = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
+ if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
+ return result;
}
diff --git a/apps/web/src/lib/stores/dashboard-store.test.ts b/apps/web/src/lib/stores/dashboard-store.test.ts
index 3cfe9cb..3efb070 100644
--- a/apps/web/src/lib/stores/dashboard-store.test.ts
+++ b/apps/web/src/lib/stores/dashboard-store.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/lib/stores/dashboard-store.ts b/apps/web/src/lib/stores/dashboard-store.ts
index b9dd10f..d7e26c7 100644
--- a/apps/web/src/lib/stores/dashboard-store.ts
+++ b/apps/web/src/lib/stores/dashboard-store.ts
@@ -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) => void;
@@ -38,6 +51,8 @@ interface DashboardState {
loadDashboard: () => Promise;
saveLayout: () => Promise;
selectDashboard: (id: string) => Promise;
+ /** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
+ captureCanvas: (area: GridCanvas) => Promise;
createDashboard: () => Promise;
renameDashboard: (id: string, name: string) => Promise;
deleteDashboard: (id: string) => Promise;
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise {
* 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()((set, get) => ({
dashboards: [],
@@ -104,6 +127,7 @@ export const useDashboardStore = create()((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()((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()((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()((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()((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()((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()((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 {
diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md
index 7f62466..5b61d58 100644
--- a/docs/anleitung-anwender.md
+++ b/docs/anleitung-anwender.md
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
+**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
+
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).