Files
CapaKraken/packages/shared/src/schemas/dashboard.schema.ts
T

242 lines
7.8 KiB
TypeScript

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<DashboardWidgetType, DashboardWidgetCatalogEntry>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<T extends DashboardWidgetType>(
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<T extends DashboardWidgetType>(
type: T,
options: {
id: string;
title?: string;
x?: number;
y?: number;
w?: number;
h?: number;
minW?: number;
minH?: number;
config?: unknown;
},
): DashboardWidgetInstance<T> {
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 gridCols = clamp(Math.max(1, toInt(input.gridCols) ?? DASHBOARD_GRID_COLUMNS), 1, 24);
const rawWidgets = Array.isArray(input.widgets) ? input.widgets : [];
const widgets: DashboardWidgetInstance[] = [];
const seenIds = new Set<string>();
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);
const widgetOptions: Parameters<typeof createDashboardWidget<typeof type>>[1] = {
id,
...(typeof rawWidget.title === "string" ? { title: rawWidget.title } : {}),
...(typeof rawWidget.x === "number" ? { x: rawWidget.x } : {}),
...(typeof rawWidget.y === "number" ? { y: rawWidget.y } : {}),
...(typeof rawWidget.w === "number" ? { w: rawWidget.w } : {}),
...(typeof rawWidget.h === "number" ? { h: rawWidget.h } : {}),
...(typeof rawWidget.minW === "number" ? { minW: rawWidget.minW } : {}),
...(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),
}),
);