From ea0dc2b56bdb2140108f1344dea8f3166c02e5b5 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:03:40 +0100 Subject: [PATCH] feat(base-formula): add date and coercion functions, wire registry --- .../core/base/formula/__tests__/eval.spec.ts | 3 +- .../base/formula/__tests__/typecheck.spec.ts | 3 +- packages/base-formula/src/functions/date.ts | 54 +++++++++++++++++++ packages/base-formula/src/functions/index.ts | 8 +++ packages/base-formula/src/index.server.ts | 5 +- 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 packages/base-formula/src/functions/date.ts create mode 100644 packages/base-formula/src/functions/index.ts diff --git a/apps/server/src/core/base/formula/__tests__/eval.spec.ts b/apps/server/src/core/base/formula/__tests__/eval.spec.ts index 0dbdb94f7..b2686110d 100644 --- a/apps/server/src/core/base/formula/__tests__/eval.spec.ts +++ b/apps/server/src/core/base/formula/__tests__/eval.spec.ts @@ -45,8 +45,7 @@ describe("evaluate", () => { }); }); -// TODO: unskip after Task 15 wires the function registry with round/concat. -describe.skip("evaluate with registered functions", () => { +describe("evaluate with registered functions", () => { it("invokes registered functions", () => { expect(run('round(1.6)', {})).toBe(2); expect(run('concat("a", "b", "c")', {})).toBe("abc"); diff --git a/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts b/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts index 86a1617ba..894c0723c 100644 --- a/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts +++ b/apps/server/src/core/base/formula/__tests__/typecheck.spec.ts @@ -1,4 +1,3 @@ -// TODO: unskip after Task 15 lands date functions and populates the registry. import { parseRaw, resolve, typecheck, registry } from "@docmost/base-formula/server"; import type { FormulaAST } from "@docmost/base-formula/server"; @@ -9,7 +8,7 @@ const mk = (src: string, propTypes: Record { +describe("typecheck", () => { it("infers number for arithmetic", () => { const { ast, typeMap } = mk('prop("Price") * 2', { Price: "number" }); expect(typecheck(ast, typeMap, registry).resultType).toBe("number"); diff --git a/packages/base-formula/src/functions/date.ts b/packages/base-formula/src/functions/date.ts new file mode 100644 index 000000000..8dfda8b22 --- /dev/null +++ b/packages/base-formula/src/functions/date.ts @@ -0,0 +1,54 @@ +// packages/base-formula/src/functions/date.ts +import { register } from "./registry"; +import { makeErrorCell } from "../error"; + +const toDate = (v: unknown): Date | null => { + if (v == null) return null; + const d = new Date(String(v)); + return isNaN(d.getTime()) ? null : d; +}; + +register({ + name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date", + eval: () => new Date().toISOString(), + doc: "Current timestamp.", category: "date", +}); +register({ + name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date", + eval: () => { + const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString(); + }, + doc: "Midnight UTC of today.", category: "date", +}); +register({ + name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date", + eval: ([base, amt, unit]) => { + const d = toDate(base); + if (!d) return makeErrorCell("DATE_INVALID", "invalid date"); + const n = Number(amt); + const u = String(unit); + const r = new Date(d); + if (u === "days") r.setUTCDate(r.getUTCDate() + n); + else if (u === "hours") r.setUTCHours(r.getUTCHours() + n); + else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n); + else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n); + else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n); + else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`); + return r.toISOString(); + }, + doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date", +}); +register({ + name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number", + eval: ([a, b, unit]) => { + const da = toDate(a), db = toDate(b); + if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date"); + const ms = db.getTime() - da.getTime(); + const u = String(unit); + if (u === "days") return Math.floor(ms / 86_400_000); + if (u === "hours") return Math.floor(ms / 3_600_000); + if (u === "minutes") return Math.floor(ms / 60_000); + return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`); + }, + doc: "Difference between two dates in a given unit.", category: "date", +}); diff --git a/packages/base-formula/src/functions/index.ts b/packages/base-formula/src/functions/index.ts new file mode 100644 index 000000000..27c695872 --- /dev/null +++ b/packages/base-formula/src/functions/index.ts @@ -0,0 +1,8 @@ +// packages/base-formula/src/functions/index.ts +import "./logic"; +import "./math"; +import "./string"; +import "./date"; +import "./coercion"; +export { registry, register } from "./registry"; +export type { FormulaFn } from "./registry"; diff --git a/packages/base-formula/src/index.server.ts b/packages/base-formula/src/index.server.ts index 10c4b0284..4b85e0478 100644 --- a/packages/base-formula/src/index.server.ts +++ b/packages/base-formula/src/index.server.ts @@ -7,7 +7,8 @@ export * from "./parser"; export * from "./resolver"; export * from "./typecheck"; export * from "./format"; -export { registry, register } from "./functions/registry"; -export type { FormulaFn } from "./functions/registry"; +import "./functions/index"; // side-effect: populate registry +export { registry, register } from "./functions/index"; +export type { FormulaFn } from "./functions/index"; export * from "./graph"; export * from "./eval";