126 lines
3.3 KiB
TypeScript
126 lines
3.3 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { formulaEngine, type FormulaError } from "@/lib/formula";
|
|
|
|
import type {
|
|
DataSourceField,
|
|
DataSourceRow,
|
|
DataSourceSnapshot,
|
|
} from "./types";
|
|
|
|
const fieldKeySchema = z
|
|
.string()
|
|
.min(1)
|
|
.max(80)
|
|
.regex(
|
|
/^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u,
|
|
"Field keys may contain letters, marks, numbers, and underscores.",
|
|
);
|
|
|
|
export const dataSourceFieldSchema = z.strictObject({
|
|
key: fieldKeySchema,
|
|
label: z.string().trim().min(1).max(120),
|
|
type: z.enum(["text", "number", "badge", "media"]),
|
|
formula: z.string().trim().max(500).nullable().optional(),
|
|
displayPrecision: z.number().int().min(0).max(20).optional(),
|
|
});
|
|
|
|
export const dataSourceRowSchema = z.strictObject({
|
|
id: z.string().uuid(),
|
|
values: z.record(z.string(), z.string().nullable()),
|
|
hidden: z.boolean().optional(),
|
|
});
|
|
|
|
export const dataSourceSnapshotSchema = z.strictObject({
|
|
id: z.string().uuid(),
|
|
name: z.string().trim().min(1).max(160),
|
|
version: z.number().int().positive(),
|
|
fields: z.array(dataSourceFieldSchema).min(1).max(80),
|
|
rows: z.array(dataSourceRowSchema).max(2_000),
|
|
});
|
|
|
|
export interface EvaluatedDataSourceCell {
|
|
raw: string | null;
|
|
canonical: string | null;
|
|
display: string | null;
|
|
error?: FormulaError;
|
|
}
|
|
|
|
export interface EvaluatedDataSourceRow {
|
|
id: string;
|
|
hidden: boolean;
|
|
cells: Record<string, EvaluatedDataSourceCell>;
|
|
}
|
|
|
|
export interface EvaluatedDataSource {
|
|
source: DataSourceSnapshot;
|
|
rows: EvaluatedDataSourceRow[];
|
|
valid: boolean;
|
|
}
|
|
|
|
export type { DataSourceField, DataSourceRow, DataSourceSnapshot };
|
|
|
|
function assertUniqueFieldKeys(fields: readonly DataSourceField[]): void {
|
|
const keys = new Set<string>();
|
|
for (const field of fields) {
|
|
if (keys.has(field.key)) {
|
|
throw new Error(`Duplicate data-source field key: ${field.key}`);
|
|
}
|
|
keys.add(field.key);
|
|
}
|
|
}
|
|
|
|
export function evaluateDataSource(
|
|
input: DataSourceSnapshot,
|
|
): EvaluatedDataSource {
|
|
const source = dataSourceSnapshotSchema.parse(input);
|
|
assertUniqueFieldKeys(source.fields);
|
|
|
|
const rows = source.rows.map((row: DataSourceRow): EvaluatedDataSourceRow => {
|
|
const numericFields = source.fields.filter(
|
|
(field) => field.type === "number" || field.formula,
|
|
);
|
|
const evaluation = formulaEngine.evaluate(
|
|
numericFields.map((field) => ({
|
|
key: field.key,
|
|
value: row.values[field.key],
|
|
formula: field.formula,
|
|
displayPrecision: field.displayPrecision,
|
|
})),
|
|
);
|
|
|
|
const cells = Object.fromEntries(
|
|
source.fields.map((field) => {
|
|
const raw = row.values[field.key] ?? null;
|
|
if (field.type !== "number" && !field.formula) {
|
|
return [
|
|
field.key,
|
|
{ raw, canonical: raw, display: raw } satisfies EvaluatedDataSourceCell,
|
|
];
|
|
}
|
|
|
|
const calculated = evaluation.fields[field.key];
|
|
return [
|
|
field.key,
|
|
{
|
|
raw,
|
|
canonical: calculated?.canonical ?? null,
|
|
display: calculated?.display ?? null,
|
|
error: calculated?.error,
|
|
} satisfies EvaluatedDataSourceCell,
|
|
];
|
|
}),
|
|
);
|
|
|
|
return { id: row.id, hidden: row.hidden ?? false, cells };
|
|
});
|
|
|
|
return {
|
|
source,
|
|
rows,
|
|
valid: rows.every((row) =>
|
|
Object.values(row.cells).every((cell) => !cell.error),
|
|
),
|
|
};
|
|
}
|