feat(dashboard): tighten explainability detail views

This commit is contained in:
2026-03-31 22:50:47 +02:00
parent db50e2e555
commit 7ace137d16
11 changed files with 580 additions and 65 deletions
@@ -0,0 +1,39 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { WidgetContainer } from "./WidgetContainer.js";
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
},
}));
describe("WidgetContainer", () => {
it("hides the description while details are off", () => {
const html = renderToStaticMarkup(
<WidgetContainer title="Utilization" description="Context text" onRemove={() => {}}>
<div>Body</div>
</WidgetContainer>,
);
expect(html).not.toContain("Context text");
});
it("shows the description while details are on", () => {
const html = renderToStaticMarkup(
<WidgetContainer
title="Utilization"
description="Context text"
onRemove={() => {}}
showDetails
>
<div>Body</div>
</WidgetContainer>,
);
expect(html).toContain("Context text");
});
});
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
interface WidgetContainerProps {
@@ -56,7 +57,7 @@ export function WidgetContainer({
</span>
) : null}
</div>
{description && (
{showDetails && description && (
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{description}
</p>
@@ -0,0 +1,105 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PeakTimesWidget } from "./PeakTimesWidget.js";
const { useQueryMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(),
}));
vi.mock("next/dynamic", () => ({
default: () => () => <div>chart</div>,
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
dashboard: {
getPeakTimes: {
useQuery: useQueryMock,
},
},
},
}));
vi.mock("~/components/ui/InfoTooltip.js", () => ({
InfoTooltip: () => <span>i</span>,
}));
describe("PeakTimesWidget", () => {
it("shows calendar basis only when details are enabled", () => {
globalThis.React = React;
useQueryMock.mockReturnValue({
data: [
{
period: "2026-03",
periodStart: "2026-03-01",
totalHours: 120,
capacityHours: 160,
bookedHours: 120,
remainingHours: 40,
overbookedHours: 0,
utilizationPct: 75,
groups: [
{
name: "Delivery",
hours: 120,
capacityHours: 160,
remainingHours: 40,
overbookedHours: 0,
utilizationPct: 75,
},
],
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 2,
resourceCount: 2,
groupCount: 1,
baseAvailableHours: 176,
effectiveAvailableHours: 160,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 1,
absenceHoursDeduction: 8,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
resourceCount: 1,
effectiveAvailableHours: 80,
},
{
countryCode: "DE",
countryName: "Germany",
federalState: "HH",
metroCityName: "Hamburg",
resourceCount: 1,
effectiveAvailableHours: 80,
},
],
bookedHours: 120,
capacityHours: 160,
remainingCapacityHours: 40,
overbookedHours: 0,
utilizationPct: 75,
},
},
],
isLoading: false,
});
const compactHtml = renderToStaticMarkup(
<PeakTimesWidget config={{ showDetails: false }} onConfigChange={() => {}} />,
);
const detailedHtml = renderToStaticMarkup(
<PeakTimesWidget config={{ showDetails: true }} onConfigChange={() => {}} />,
);
expect(compactHtml).not.toContain("calendar bases");
expect(compactHtml).not.toContain("DE / BY / Munich");
expect(detailedHtml).toContain("2 calendar bases");
expect(detailedHtml).toContain("DE / BY / Munich (1x");
expect(detailedHtml).toContain("DE / HH / Hamburg (1x");
});
});
@@ -20,15 +20,30 @@ type PeakDepartmentRow = {
utilizationPct: number;
};
type PeakCalendarLocation = {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
effectiveAvailableHours: number;
};
type PeakPeriodRow = {
period: string;
label: string;
bookedHours: number;
capacityHours: number;
baseAvailableHours: number;
holidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
remainingHours: number;
overbookedHours: number;
utilizationPct: number;
isCurrentPeriod: boolean;
calendarContextCount: number;
calendarLocations: PeakCalendarLocation[];
groups: PeakDepartmentRow[];
};
@@ -39,6 +54,10 @@ function formatHours(value: number): string {
}).format(value);
}
function formatDayEquivalent(value: number): string {
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
}
function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
if (!periodStart) {
return fallback;
@@ -68,6 +87,49 @@ function utilizationTextTone(utilizationPct: number): string {
return "text-emerald-600 dark:text-emerald-300";
}
function compactMetricLabel(row: PeakPeriodRow | null | undefined, fallback: string): string {
if (!row) {
return fallback;
}
return `${row.label} · ${row.utilizationPct}%`;
}
function formatLocation(location: PeakCalendarLocation): string {
const parts = [location.countryCode ?? location.countryName, location.federalState, location.metroCityName]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function formatCapacityBasis(row: PeakPeriodRow | null | undefined): string {
if (!row) {
return "No capacity basis";
}
return [
`${formatHours(row.baseAvailableHours)}h base`,
`- ${formatHours(row.holidayHoursDeduction)}h holidays`,
`- ${formatHours(row.absenceHoursDeduction)}h absences (${formatDayEquivalent(row.absenceDayEquivalent)}d)`,
`= ${formatHours(row.capacityHours)}h effective`,
].join(" ");
}
function formatCalendarBasis(row: PeakPeriodRow | null | undefined): string {
if (!row) {
return "No calendar basis";
}
if (row.calendarLocations.length === 0) {
return "No location-based calendar basis";
}
return row.calendarLocations
.slice(0, 3)
.map((location) =>
`${formatLocation(location)} (${location.resourceCount}x · ${formatHours(location.effectiveAvailableHours)}h)`)
.join(" · ");
}
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
if (rows.length <= limit) {
return rows;
@@ -95,6 +157,7 @@ function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepa
}
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const now = new Date();
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
const endDate = new Date(
@@ -114,6 +177,10 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const derivation = period.derivation;
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
const baseAvailableHours = derivation.baseAvailableHours ?? capacityHours;
const holidayHoursDeduction = derivation.publicHolidayHoursDeduction ?? 0;
const absenceDayEquivalent = derivation.absenceDayEquivalent ?? 0;
const absenceHoursDeduction = derivation.absenceHoursDeduction ?? 0;
const remainingHours =
period.remainingHours ??
derivation.remainingCapacityHours ??
@@ -132,10 +199,16 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
bookedHours,
capacityHours,
baseAvailableHours,
holidayHoursDeduction,
absenceDayEquivalent,
absenceHoursDeduction,
remainingHours,
overbookedHours,
utilizationPct,
isCurrentPeriod: period.period === currentPeriodKey,
calendarContextCount: derivation.calendarContextCount ?? 0,
calendarLocations: derivation.calendarLocations ?? [],
groups: (period.groups ?? [])
.map((group) => {
const groupCapacityHours = group.capacityHours ?? 0;
@@ -191,9 +264,9 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
if (isLoading && periods.length === 0) {
return (
<div className="flex h-full flex-col gap-3 pt-2">
<div className="grid grid-cols-3 gap-2">
<div className="flex gap-2">
{[...Array(3)].map((_, index) => (
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
<div key={index} className="h-10 flex-1 rounded-full shimmer-skeleton" />
))}
</div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
@@ -205,60 +278,45 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
return (
<div className="flex h-full flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Current
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
{[
{ label: "Current", row: currentPeriodRow, fallback: "No data" },
{ label: "Pinned", row: selectedPeriodRow, fallback: "Hover or pin" },
{ label: "Peak", row: peakPeriodRow, fallback: "No data" },
].map((metric) => (
<div
key={metric.label}
className="min-w-0 flex-1 rounded-full border border-slate-200/80 bg-white/85 px-3 py-1.5 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
{metric.label}
</span>
<span className={`shrink-0 text-xs font-semibold ${utilizationTextTone(metric.row?.utilizationPct ?? 0)}`}>
{metric.row?.utilizationPct ?? 0}%
</span>
</div>
{showDetails ? (
<div className="mt-0.5 truncate text-[10px] text-slate-500 dark:text-slate-400">
{compactMetricLabel(metric.row, metric.fallback)}
</div>
) : null}
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
{currentPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{currentPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Selected
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
{selectedPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "Hover or pin"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Peak
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
{peakPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{peakPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
))}
</div>
<InfoTooltip
content={
<span>
The top chart shows total booked load against effective capacity.<br />
The current month is marked with a blue accent.<br />
Hover any month to inspect details and click to pin the department breakdown.
</span>
}
width="w-80"
position="bottom"
/>
{showDetails ? (
<InfoTooltip
content={
<span>
The top chart shows total booked load against effective capacity.<br />
Effective capacity is base availability minus regional holidays and approved absences.<br />
Hover any month to inspect details and click to pin the department breakdown.
</span>
}
width="w-80"
position="bottom"
/>
) : null}
</div>
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
@@ -277,15 +335,28 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Department Utilization
</div>
{showDetails ? (
<div className="text-xs text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "No active month"}
</div>
</div>
) : null}
</div>
{showDetails ? (
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
{selectedPeriodRow ? (
<div>{selectedPeriodRow.calendarContextCount} calendar base{selectedPeriodRow.calendarContextCount === 1 ? "" : "s"}</div>
) : null}
{selectedPeriodRow ? (
<div>{formatCapacityBasis(selectedPeriodRow)}</div>
) : null}
{selectedPeriodRow ? (
<div>{formatCalendarBasis(selectedPeriodRow)}</div>
) : null}
</div>
</div>
) : null}
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
{departmentRows.length > 0 ? (
@@ -0,0 +1,69 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { TopValueWidget } from "./TopValueWidget.js";
const { useQueryMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(),
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
dashboard: {
getTopValueResources: {
useQuery: useQueryMock,
},
},
},
}));
vi.mock("~/components/ui/InfoTooltip.js", () => ({
InfoTooltip: () => <span>i</span>,
}));
vi.mock("~/components/dashboard/WidgetFilterBar.js", () => ({
WidgetFilterBar: () => <div>filters</div>,
}));
vi.mock("~/hooks/useWidgetFilterOptions.js", () => ({
useWidgetFilterOptions: () => ({ chapters: [] }),
}));
describe("TopValueWidget", () => {
it("adds score breakdown explainability to the score badge hover text", () => {
useQueryMock.mockReturnValue({
data: [
{
id: "res_1",
eid: "pparker",
displayName: "Peter Parker",
chapter: "Delivery",
lcrCents: 9_500,
valueScore: 91,
valueScoreBreakdown: {
skillDepth: 85,
skillBreadth: 74,
costEfficiency: 93,
chargeability: 78,
experience: 88,
total: 91,
},
valueScoreUpdatedAt: "2026-03-03T00:00:00.000Z",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
},
],
isLoading: false,
});
const html = renderToStaticMarkup(
<TopValueWidget config={{ limit: 10 }} onConfigChange={() => {}} />,
);
expect(html).toContain("Peter Parker: Value score 91");
expect(html).toContain("Skill depth 85");
expect(html).toContain("Cost efficiency 93");
expect(html).toContain("Updated Mar 3, 2026");
});
});
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -9,6 +9,65 @@ import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
type ValueScoreBreakdown = {
skillDepth: number;
skillBreadth: number;
costEfficiency: number;
chargeability: number;
experience: number;
total: number;
};
function formatLocation(resource: {
countryName?: string | null;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}): string | null {
const parts = [
resource.countryName ?? resource.countryCode,
resource.federalState,
resource.metroCityName,
]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : null;
}
function formatScoreTooltip(resource: {
displayName: string;
valueScore: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: string | null;
}) {
if (!resource.valueScoreBreakdown) {
return `${resource.displayName}: Value score ${resource.valueScore ?? "n/a"}`;
}
const updatedAt = resource.valueScoreUpdatedAt
? new Date(resource.valueScoreUpdatedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
const breakdown = resource.valueScoreBreakdown;
const parts = [
`${resource.displayName}: Value score ${resource.valueScore ?? breakdown.total}`,
`Skill depth ${breakdown.skillDepth}`,
`Skill breadth ${breakdown.skillBreadth}`,
`Cost efficiency ${breakdown.costEfficiency}`,
`Chargeability ${breakdown.chargeability}`,
`Experience ${breakdown.experience}`,
];
if (updatedAt) {
parts.push(`Updated ${updatedAt}`);
}
return parts.join(" | ");
}
export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const limit = (config.limit as number) || 10;
const { chapters } = useWidgetFilterOptions({ chapters: true });
@@ -36,7 +95,20 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
const chapter = (config.chapter as string) ?? "";
const list = useMemo(() => {
const all = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>;
const all = (data ?? []) as Array<{
id: string;
eid: string;
displayName: string;
chapter: string | null;
lcrCents: number;
valueScore: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: string | null;
countryName?: string | null;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}>;
if (!chapter) return all;
return all.filter((r) => r.chapter === chapter);
}, [data, chapter]);
@@ -159,10 +231,19 @@ export function TopValueWidget({ config, onConfigChange }: WidgetProps) {
<tr key={r.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
<td className="px-3 py-2">
<div className="font-medium text-gray-900">{r.displayName}</div>
{formatLocation(r) ? (
<div className="mt-0.5 text-[10px] leading-4 text-gray-500">
{formatLocation(r)}
</div>
) : null}
</td>
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "\u2014"}</td>
<td className="px-3 py-2 text-right">
<span
title={formatScoreTooltip(r)}
aria-label={formatScoreTooltip(r)}
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
(r.valueScore ?? 0) >= 70
? "bg-green-100 text-green-700"
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createDefaultDashboardLayout } from "@capakraken/shared/schemas";
import { shouldHydrateDashboardFromDb } from "./useDashboardLayout.js";
describe("shouldHydrateDashboardFromDb", () => {
it("hydrates from the database on first load when no local changes happened yet", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: false,
})).toBe(true);
});
it("skips initial database hydration after a local first-load change", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: true,
})).toBe(false);
});
it("does not re-hydrate once the initial database handoff already happened", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: createDefaultDashboardLayout(),
hasHydratedFromDb: true,
hasLocalChangesBeforeHydration: false,
})).toBe(false);
});
it("does not hydrate when the backend has no stored layout", () => {
expect(shouldHydrateDashboardFromDb({
remoteLayout: null,
hasHydratedFromDb: false,
hasLocalChangesBeforeHydration: false,
})).toBe(false);
});
});
+41 -4
View File
@@ -37,9 +37,23 @@ function saveToStorage(config: DashboardLayoutConfig) {
} 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() {
const [config, setConfig] = useState<DashboardLayoutConfig>(() => loadFromStorage() ?? createDefaultDashboardLayout());
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasHydratedFromDbRef = useRef(false);
const hasLocalChangesBeforeHydrationRef = useRef(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: dbData } = trpc.user.getDashboardLayout.useQuery(undefined, {
@@ -50,14 +64,37 @@ export function useDashboardLayout() {
// Sync from DB on load (DB wins if it has data)
useEffect(() => {
if (dbData?.layout) {
const dbConfig = normalizeDashboardLayout(dbData.layout);
setConfig(dbConfig);
saveToStorage(dbConfig);
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);
saveToStorage(dbConfig);
}, [dbData]);
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);
saveToStorage(newConfig);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);