import { z } from "zod"; import { DASHBOARD_GRID_COLUMNS, DASHBOARD_LAYOUT_VERSION, DASHBOARD_WIDGET_CATALOG, DASHBOARD_WIDGET_TYPES, type DashboardLayoutConfig, type DashboardWidgetCatalogEntry, type DashboardWidgetConfigMap, type DashboardWidgetInstance, type DashboardWidgetType, } from "../types/dashboard.js"; import { ProjectStatus } from "../types/enums.js"; const DASHBOARD_WIDGET_BY_TYPE = Object.fromEntries( DASHBOARD_WIDGET_CATALOG.map((widget) => [widget.type, widget]), ) as Record; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function toNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function toInt(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } const dashboardWidgetTypeSchema = z.enum(DASHBOARD_WIDGET_TYPES); const widgetChromeConfigSchema = z.object({ showDetails: z.boolean().optional(), }); const resourceTableWidgetConfigSchema = widgetChromeConfigSchema.extend({ chapter: z.preprocess(toNonEmptyString, z.string().optional()), }); const projectTableWidgetConfigSchema = widgetChromeConfigSchema.extend({ search: z.preprocess(toNonEmptyString, z.string().optional()), status: z.nativeEnum(ProjectStatus).optional(), }); const peakTimesWidgetConfigSchema = widgetChromeConfigSchema.extend({ granularity: z.enum(["week", "month"]).optional(), groupBy: z.enum(["project", "chapter", "resource"]).optional(), }); const demandWidgetConfigSchema = widgetChromeConfigSchema.extend({ groupBy: z.enum(["project", "person", "chapter"]).optional(), }); const topValueWidgetConfigSchema = widgetChromeConfigSchema.extend({ limit: z.number().int().min(1).max(100).optional(), }); const chargeabilityWidgetConfigSchema = widgetChromeConfigSchema.extend({ topN: z.number().int().min(1).max(100).optional(), watchlistThreshold: z.number().int().min(0).max(100).optional(), }); const myProjectsWidgetConfigSchema = widgetChromeConfigSchema.extend({ showFavorites: z.boolean().optional(), showResponsible: z.boolean().optional(), }); export const dashboardWidgetConfigSchemas = { "stat-cards": widgetChromeConfigSchema, "resource-table": resourceTableWidgetConfigSchema, "project-table": projectTableWidgetConfigSchema, "peak-times-chart": peakTimesWidgetConfigSchema, "demand-view": demandWidgetConfigSchema, "top-value-resources": topValueWidgetConfigSchema, "chargeability-overview": chargeabilityWidgetConfigSchema, "my-projects": myProjectsWidgetConfigSchema, "budget-forecast": widgetChromeConfigSchema, "skill-gap": widgetChromeConfigSchema, "project-health": widgetChromeConfigSchema, } as const; type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas; export function getDashboardWidgetCatalogEntry(type: DashboardWidgetType): DashboardWidgetCatalogEntry { return DASHBOARD_WIDGET_BY_TYPE[type]; } export function getNextDashboardWidgetY(widgets: DashboardWidgetInstance[]): number { return widgets.reduce((max, widget) => Math.max(max, widget.y + widget.h), 0); } export function normalizeDashboardWidgetConfig( type: T, config: unknown, ): DashboardWidgetConfigMap[T] { const schema = dashboardWidgetConfigSchemas[type]; const parsed = schema.safeParse(isRecord(config) ? config : {}); return { ...DASHBOARD_WIDGET_BY_TYPE[type].defaultConfig, ...(parsed.success ? parsed.data : {}), } as DashboardWidgetConfigMap[T]; } export function createDashboardWidget( type: T, options: { id: string; title?: string; x?: number; y?: number; w?: number; h?: number; minW?: number; minH?: number; config?: unknown; }, ): DashboardWidgetInstance { const widgetDef = DASHBOARD_WIDGET_BY_TYPE[type]; const minW = Math.max(1, toInt(options.minW) ?? widgetDef.minSize.w); const minH = Math.max(1, toInt(options.minH) ?? widgetDef.minSize.h); const w = clamp(Math.max(minW, toInt(options.w) ?? widgetDef.defaultSize.w), minW, DASHBOARD_GRID_COLUMNS); const h = Math.max(minH, toInt(options.h) ?? widgetDef.defaultSize.h); const title = toNonEmptyString(options.title); return { id: options.id, type, x: clamp(Math.max(0, toInt(options.x) ?? 0), 0, Math.max(0, DASHBOARD_GRID_COLUMNS - w)), y: Math.max(0, toInt(options.y) ?? 0), w, h, minW, minH, config: normalizeDashboardWidgetConfig(type, options.config), ...(title ? { title } : {}), }; } export function createDefaultDashboardLayout(): DashboardLayoutConfig { return { version: DASHBOARD_LAYOUT_VERSION, gridCols: DASHBOARD_GRID_COLUMNS, widgets: [ createDashboardWidget("stat-cards", { id: "default-stat-cards", x: 0, y: 0, }), ], }; } export function normalizeDashboardLayout(input: unknown): DashboardLayoutConfig { if (!isRecord(input)) { return createDefaultDashboardLayout(); } const savedGridCols = clamp(Math.max(1, toInt(input.gridCols) ?? DASHBOARD_GRID_COLUMNS), 1, 24); // Migrate layouts saved with 12 columns to the current 16-column grid. const needsColumnMigration = savedGridCols === 12 && DASHBOARD_GRID_COLUMNS === 16; const gridCols = needsColumnMigration ? DASHBOARD_GRID_COLUMNS : savedGridCols; const rawWidgets = Array.isArray(input.widgets) ? input.widgets : []; const widgets: DashboardWidgetInstance[] = []; const seenIds = new Set(); let nextY = 0; rawWidgets.forEach((rawWidget, index) => { if (!isRecord(rawWidget)) return; const typeResult = dashboardWidgetTypeSchema.safeParse(rawWidget.type); if (!typeResult.success) return; const type = typeResult.data; const baseId = toNonEmptyString(rawWidget.id) ?? `${type}-${index + 1}`; let id = baseId; let suffix = 1; while (seenIds.has(id)) { suffix += 1; id = `${baseId}-${suffix}`; } seenIds.add(id); // Scale x and w when migrating from a 12-column to 16-column layout. const scaleCol = (v: number) => Math.round(v * 16 / 12); const rawX = typeof rawWidget.x === "number" ? (needsColumnMigration ? scaleCol(rawWidget.x) : rawWidget.x) : undefined; const rawW = typeof rawWidget.w === "number" ? (needsColumnMigration ? scaleCol(rawWidget.w) : rawWidget.w) : undefined; const rawMinW = typeof rawWidget.minW === "number" ? (needsColumnMigration ? scaleCol(rawWidget.minW) : rawWidget.minW) : undefined; const widgetOptions: Parameters>[1] = { id, ...(typeof rawWidget.title === "string" ? { title: rawWidget.title } : {}), ...(rawX !== undefined ? { x: rawX } : {}), ...(typeof rawWidget.y === "number" ? { y: rawWidget.y } : {}), ...(rawW !== undefined ? { w: rawW } : {}), ...(typeof rawWidget.h === "number" ? { h: rawWidget.h } : {}), ...(rawMinW !== undefined ? { minW: rawMinW } : {}), ...(typeof rawWidget.minH === "number" ? { minH: rawWidget.minH } : {}), ...(rawWidget.config !== undefined ? { config: rawWidget.config } : {}), }; const widget = createDashboardWidget(type, widgetOptions); const placedWidget = { ...widget, x: clamp(widget.x, 0, Math.max(0, gridCols - widget.w)), y: toInt(rawWidget.y) !== undefined && (toInt(rawWidget.y) ?? 0) >= 0 ? widget.y : nextY, w: clamp(widget.w, widget.minW, gridCols), }; widgets.push(placedWidget); nextY = Math.max(nextY, placedWidget.y + placedWidget.h); }); return { version: DASHBOARD_LAYOUT_VERSION, gridCols, widgets, }; } export const dashboardWidgetInstanceSchema = z.object({ id: z.string().min(1), type: dashboardWidgetTypeSchema, title: z.string().min(1).optional(), x: z.number().int().min(0), y: z.number().int().min(0), w: z.number().int().min(1), h: z.number().int().min(1), minW: z.number().int().min(1), minH: z.number().int().min(1), config: z.record(z.unknown()), }); export const dashboardLayoutSchema = z .unknown() .transform((value) => normalizeDashboardLayout(value)) .pipe( z.object({ version: z.literal(DASHBOARD_LAYOUT_VERSION), widgets: z.array(dashboardWidgetInstanceSchema), gridCols: z.number().int().min(1).max(24), }), );