Files
CapaKraken/apps/web/src/hooks/useDashboardLayout.ts
T
Hartmut 745be7ee8b fix(dashboard): scope localStorage key by userId to prevent cross-user layout bleed (#27)
New users on a shared device were picking up a previous user's stale
(potentially empty) dashboard layout from localStorage because the key
"capakraken_dashboard_v1" was not user-scoped.

- useDashboardLayout: key is now capakraken_dashboard_v1_{userId};
  userId is resolved via trpc.user.me before touching localStorage
- Initial state falls back to createDefaultDashboardLayout() until
  userId resolves, then hydrates from the user-scoped key
- DB layout still wins over localStorage when it has data (unchanged)
- E2E test suite covers: new-user flow, modal widget list, add widget
  persists after reload, cross-user localStorage isolation
- plan.md: added ticket #27 implementation plan

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-01 22:44:41 +02:00

211 lines
6.8 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);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<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);
});
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,
};
}