5cc177ccf9
Two related fixes: 1. When localStorage has a saved layout, mark it as authoritative so a DB response carrying older data (e.g. from a save cancelled by navigating away within the 2-second debounce window) cannot overwrite it. 2. Flush any pending debounced DB save immediately on component unmount so that navigating away within the window doesn't silently lose changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
9.9 KiB
TypeScript
276 lines
9.9 KiB
TypeScript
"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<DashboardLayoutConfig>(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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof normalizeDashboardLayout> | null>(null);
|
|
// Holds the latest layout that has been queued for DB save but not yet flushed.
|
|
const pendingLayoutSaveRef = useRef<DashboardLayoutConfig | null>(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<ReturnType<typeof setTimeout> | 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<string, unknown>) => {
|
|
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,
|
|
};
|
|
}
|