"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); // isHydrated: true once we have applied the best available layout (localStorage or DB). // While false, callers should show a skeleton instead of the default 1-widget layout. const [isHydrated, setIsHydrated] = useState(false); const saveTimeoutRef = useRef | null>(null); const hasHydratedFromDbRef = useRef(false); const hasLocalChangesBeforeHydrationRef = useRef(false); const hasHydratedFromStorageRef = useRef(false); // Holds the DB config that needs to be persisted to localStorage once userId is available. const pendingStorageSaveRef = useRef | null>(null); // Holds the latest layout that has been queued for DB save but not yet flushed. const pendingLayoutSaveRef = useRef(null); // 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); hasHydratedFromStorageRef.current = true; if (stored) { setConfig(stored); // Treat localStorage data as authoritative local state so a stale DB // response (e.g. from a save that was cancelled by page navigation within // the 2-second debounce window) cannot overwrite it. hasLocalChangesBeforeHydrationRef.current = true; } // localStorage check is complete — show whatever we have now. setIsHydrated(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 [saveStatus, setSaveStatus] = useState<"idle" | "saved">("idle"); const saveStatusTimerRef = useRef | null>(null); const saveMutation = trpc.user.saveDashboardLayout.useMutation({ onSuccess: () => { setSaveStatus("saved"); if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current); saveStatusTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2500); }, }); // Sync from DB on load (DB wins if it has data). useEffect(() => { // Wait until the query has settled (undefined = still in-flight). if (dbData === undefined || hasHydratedFromDbRef.current) return; const remoteLayout = dbData?.layout ?? null; if (remoteLayout === null) { // DB has no saved layout — don't block localStorage hydration. // If localStorage already ran, we're done; otherwise localStorage will call setIsHydrated. if (hasHydratedFromStorageRef.current) { setIsHydrated(true); } return; } if (!shouldHydrateDashboardFromDb({ remoteLayout, hasHydratedFromDb: hasHydratedFromDbRef.current, hasLocalChangesBeforeHydration: hasLocalChangesBeforeHydrationRef.current, })) { // DB data present but local changes already in-flight — keep local state, mark done. hasHydratedFromDbRef.current = true; setIsHydrated(true); return; } const dbConfig = normalizeDashboardLayout(remoteLayout); hasHydratedFromDbRef.current = true; setConfig(dbConfig); setIsHydrated(true); if (userId) { saveToStorage(userId, dbConfig); } else { // userId not yet resolved — defer the localStorage write until it is. pendingStorageSaveRef.current = dbConfig; } }, [dbData, userId]); // When userId becomes available, flush any pending localStorage save from DB hydration. useEffect(() => { if (!userId || !pendingStorageSaveRef.current) return; saveToStorage(userId, pendingStorageSaveRef.current); pendingStorageSaveRef.current = null; }, [userId]); // Keep a ref to saveMutation so the unmount cleanup can call it without // capturing a stale closure. const saveMutationRef = useRef(saveMutation); saveMutationRef.current = saveMutation; // Flush any pending debounced DB save when the component unmounts so that // navigating away within the 2-second window doesn't silently lose changes. useEffect(() => () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; if (pendingLayoutSaveRef.current) { saveMutationRef.current.mutate({ layout: pendingLayoutSaveRef.current }); pendingLayoutSaveRef.current = null; } } }, []); const persist = useCallback((nextConfig: DashboardLayoutConfig) => { if (!hasHydratedFromDbRef.current) { hasLocalChangesBeforeHydrationRef.current = true; } const newConfig = normalizeDashboardLayout(nextConfig); if (userId) saveToStorage(userId, newConfig); pendingLayoutSaveRef.current = newConfig; if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => { pendingLayoutSaveRef.current = null; 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); }); // Return the same reference when nothing changed so React skips // the re-render. Without this guard, setConfig always produces a new // object → re-render → new layouts prop → react-grid-layout fires // onLayoutChange again → infinite oscillation loop. if (!changed) return prev; const newConfig = { ...prev, widgets: updatedWidgets }; persist(newConfig); return newConfig; }); }, [persist], ); const resetLayout = useCallback(() => { const defaultConfig = createDefaultDashboardLayout(); setConfig(defaultConfig); persist(defaultConfig); }, [persist]); return { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout, }; }