Files
tessera-ctl/apps/web/src/lib/stores/dashboard-store.ts
T
schalli 3f5afb0f54 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
2026-09-16 09:09:00 +02:00

157 lines
4.8 KiB
TypeScript

import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
export interface WidgetInstance {
id: string;
widgetType: string;
config: Record<string, unknown>;
}
interface DashboardState {
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: WidgetInstance[];
isEditMode: boolean;
isDirty: boolean;
isLoading: boolean;
error: string | null;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
addWidget: (widgetType: WidgetType) => Promise<void>;
removeWidget: (id: string) => Promise<void>;
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
}
/**
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
* Save only on exiting edit mode (D-01), not on every drag.
*
* quick-260916-bwo: Gespeicherte Anordnungen in alten Raster-Einheiten werden
* beim Laden einmalig umgerechnet (`migrateGridLayouts`) und sofort mit dem
* Marker `__gridVersion` zurueckgeschrieben. Der Marker lebt NUR im
* gespeicherten JSON, nie im Zustand (dieser Store iteriert mit Object.keys
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [],
isEditMode: false,
isDirty: false,
isLoading: false,
error: null,
setEditMode: (mode: boolean) => {
const prev = get().isEditMode;
set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01)
if (prev && !mode && get().isDirty) {
get().saveLayout();
}
},
updateLayouts: (layouts) => {
set({
layouts: layouts as DashboardState['layouts'],
isDirty: true,
});
},
addWidget: async (widgetType: WidgetType) => {
try {
const newWidget = await api.addWidget(widgetType);
const constraints = WIDGET_CONSTRAINTS[widgetType];
set((state) => {
// Add default layout entry for all breakpoints
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
const arr = [...(newLayouts[bp] || [])];
// Place at next available y position
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
arr.push({
i: newWidget.id,
x: 0,
y: maxY,
w: constraints.defaultW,
h: constraints.defaultH,
});
newLayouts[bp] = arr;
}
return {
widgets: [...state.widgets, newWidget],
layouts: newLayouts,
isDirty: true,
};
});
} catch (err) {
console.error('Failed to add widget:', err);
}
},
removeWidget: async (id: string) => {
try {
await api.removeWidget(id);
set((state) => {
const newLayouts = { ...state.layouts };
for (const bp of Object.keys(newLayouts)) {
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
}
return {
widgets: state.widgets.filter((w) => w.id !== id),
layouts: newLayouts,
isDirty: true,
};
});
} catch (err) {
console.error('Failed to remove widget:', err);
}
},
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(),
api.fetchWidgets(),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({
layouts: migratedLayouts,
widgets,
isLoading: false,
});
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) {
try {
await api.saveLayout(withGridVersion(migratedLayouts));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
}
} catch {
set({
error: 'Failed to load dashboard',
isLoading: false,
});
}
},
saveLayout: async () => {
try {
await api.saveLayout(withGridVersion(get().layouts));
set({ isDirty: false });
} catch (err) {
console.error('Failed to save layout:', err);
}
},
}));