"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { type DashboardLayoutConfig, type DashboardWidgetType, } from "@capakraken/shared/types"; import { createDashboardWidget, createDefaultDashboardLayout, getNextDashboardWidgetY, normalizeDashboardLayout, } from "@capakraken/shared/schemas"; import { trpc } from "~/lib/trpc/client.js"; /** Returns a user-scoped localStorage key to prevent cross-user data bleed. */ function storageKey(userId: string): string { return `capakraken_dashboard_v1_${userId}`; } function generateWidgetId() { return `widget-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; } function loadFromStorage(userId: string): DashboardLayoutConfig | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(storageKey(userId)); if (!raw) return null; return normalizeDashboardLayout(JSON.parse(raw)); } catch { return null; } } function saveToStorage(userId: string, config: DashboardLayoutConfig) { if (typeof window === "undefined") return; try { localStorage.setItem(storageKey(userId), JSON.stringify(config)); } catch {} } export function shouldHydrateDashboardFromDb(params: { remoteLayout: DashboardLayoutConfig | null | undefined; hasHydratedFromDb: boolean; hasLocalChangesBeforeHydration: boolean; }): boolean { const { remoteLayout, hasHydratedFromDb, hasLocalChangesBeforeHydration } = params; return remoteLayout !== null && remoteLayout !== undefined && !hasHydratedFromDb && !hasLocalChangesBeforeHydration; } export function useDashboardLayout() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: meData } = trpc.user.me.useQuery() as { data: { id?: string } | null | undefined }; const userId = meData?.id ?? null; // Initial state: load from user-scoped localStorage once we have the userId. // Before userId resolves, fall back to the default layout so the page is not blank. const [config, setConfig] = useState(createDefaultDashboardLayout); const saveTimeoutRef = useRef | null>(null); const hasHydratedFromDbRef = useRef(false); const hasLocalChangesBeforeHydrationRef = useRef(false); const hasHydratedFromStorageRef = useRef(false); // Once userId is known, hydrate from user-scoped localStorage (if no DB data yet). useEffect(() => { if (!userId || hasHydratedFromStorageRef.current || hasHydratedFromDbRef.current || hasLocalChangesBeforeHydrationRef.current) { return; } const stored = loadFromStorage(userId); if (stored) { hasHydratedFromStorageRef.current = true; setConfig(stored); } else { hasHydratedFromStorageRef.current = true; } }, [userId]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, { staleTime: 30_000, }) as { data: { layout: DashboardLayoutConfig | null; updatedAt: unknown } | null | undefined }; const saveMutation = trpc.user.saveDashboardLayout.useMutation(); // Sync from DB on load (DB wins if it has data) useEffect(() => { const remoteLayout = dbData?.layout ?? null; if (remoteLayout === null || hasHydratedFromDbRef.current) { return; } if (!shouldHydrateDashboardFromDb({ remoteLayout, hasHydratedFromDb: hasHydratedFromDbRef.current, hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current, })) { hasHydratedFromDbRef.current = true; return; } const dbConfig = normalizeDashboardLayout(remoteLayout); hasHydratedFromDbRef.current = true; setConfig(dbConfig); if (userId) saveToStorage(userId, dbConfig); }, [dbData, userId]); useEffect(() => () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } }, []); const persist = useCallback((nextConfig: DashboardLayoutConfig) => { if (!hasHydratedFromDbRef.current) { hasLocalChangesBeforeHydrationRef.current = true; } const newConfig = normalizeDashboardLayout(nextConfig); if (userId) saveToStorage(userId, newConfig); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { saveMutation.mutate({ layout: newConfig }); }, 2000); }, [saveMutation, userId]); const addWidget = useCallback((type: DashboardWidgetType) => { setConfig((prev) => { const newConfig = { ...prev, widgets: [ ...prev.widgets, createDashboardWidget(type, { id: generateWidgetId(), x: 0, y: getNextDashboardWidgetY(prev.widgets), }), ], }; persist(newConfig); return newConfig; }); }, [persist]); const removeWidget = useCallback((id: string) => { setConfig((prev) => { const newConfig = { ...prev, widgets: prev.widgets.filter((w) => w.id !== id) }; persist(newConfig); return newConfig; }); }, [persist]); const updateWidgetConfig = useCallback((id: string, configUpdate: Record) => { setConfig((prev) => { const newConfig = { ...prev, widgets: prev.widgets.map((w) => w.id === id ? { ...w, config: { ...w.config, ...configUpdate } } : w, ), }; persist(newConfig); return newConfig; }); }, [persist]); const onLayoutChange = useCallback( (layout: { i: string; x: number; y: number; w: number; h: number }[]) => { setConfig((prev) => { const layoutMap = new Map(layout.map((item) => [item.i, item])); const previousWidgetMap = new Map(prev.widgets.map((widget) => [widget.id, widget])); const updatedWidgets = prev.widgets.map((w) => { const item = layoutMap.get(w.id); if (!item) return w; return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }; }); // Only persist when the user actually moved/resized something. // react-grid-layout fires onLayoutChange on mount too — we skip that // to avoid overwriting saved positions with compacted coordinates. const changed = updatedWidgets.some((w) => { const orig = previousWidgetMap.get(w.id); return orig && (w.x !== orig.x || w.y !== orig.y || w.w !== orig.w || w.h !== orig.h); }); const newConfig = { ...prev, widgets: updatedWidgets }; if (changed) persist(newConfig); return newConfig; }); }, [persist], ); const resetLayout = useCallback(() => { const defaultConfig = createDefaultDashboardLayout(); setConfig(defaultConfig); persist(defaultConfig); }, [persist]); return { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout, }; }