chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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 resourceTableWidgetConfigSchema = z.object({
|
||||
chapter: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
});
|
||||
|
||||
const projectTableWidgetConfigSchema = z.object({
|
||||
search: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
});
|
||||
|
||||
const peakTimesWidgetConfigSchema = z.object({
|
||||
granularity: z.enum(["week", "month"]).optional(),
|
||||
groupBy: z.enum(["project", "chapter", "resource"]).optional(),
|
||||
});
|
||||
|
||||
const demandWidgetConfigSchema = z.object({
|
||||
groupBy: z.enum(["project", "person", "chapter"]).optional(),
|
||||
});
|
||||
|
||||
const topValueWidgetConfigSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const chargeabilityWidgetConfigSchema = z.object({
|
||||
topN: z.number().int().min(1).max(100).optional(),
|
||||
watchlistThreshold: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export const dashboardWidgetConfigSchemas = {
|
||||
"stat-cards": z.object({}),
|
||||
"resource-table": resourceTableWidgetConfigSchema,
|
||||
"project-table": projectTableWidgetConfigSchema,
|
||||
"peak-times-chart": peakTimesWidgetConfigSchema,
|
||||
"demand-view": demandWidgetConfigSchema,
|
||||
"top-value-resources": topValueWidgetConfigSchema,
|
||||
"chargeability-overview": chargeabilityWidgetConfigSchema,
|
||||
} 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),
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user