From f8f9095fcaf3359c1290ce3def11390d7ef18d75 Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:27:55 +0300 Subject: [PATCH] add ISO date literals and days until/since --- src/engine/engine.test.ts | 18 ++++++++++++++++++ src/engine/evaluator.ts | 12 +++++++++++- src/engine/formatter.ts | 2 ++ src/engine/index.ts | 3 ++- src/engine/lexer.ts | 15 ++++++++++++++- src/engine/parser.ts | 4 ++++ src/engine/registry.ts | 5 ++++- src/engine/tokenizer.ts | 9 ++++++++- src/engine/vocab-data.ts | 8 ++++++-- src/ui/editor.ts | 2 +- 10 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts index 5501da9..9a37e9c 100644 --- a/src/engine/engine.test.ts +++ b/src/engine/engine.test.ts @@ -282,6 +282,24 @@ describe("dates", () => { const t = calc("time in Tokyo"); expect(t).toMatch(/^\d{2}:\d{2}$/); }); + it("ISO date literal produces a formatted date", () => { + const r = calc("2024-01-01"); + expect(r).not.toBeNull(); + expect(typeof r).toBe("string"); + }); + it("ISO date subtraction: 2026-01-01 - 2024-01-01 = 731 days", () => { + expect(calc("2026-01-01 - 2024-01-01")).toBe("731 day"); + }); + it("days until future date returns positive days", () => { + const r = calc("days until 2099-01-01"); + expect(r).not.toBeNull(); + expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0); + }); + it("days since past date returns positive days", () => { + const r = calc("days since 2000-01-01"); + expect(r).not.toBeNull(); + expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0); + }); }); describe("extension API", () => { diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index 4eaf43f..13032aa 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -42,6 +42,8 @@ export function evaluate(node: Node, ctx: EvalCtx): Value { } case "date": return evalDateWord(node.word); + case "datelit": + return { kind: "date", ms: node.ms, hasTime: false }; case "agg": return evalAgg(node.name, ctx); case "neg": { @@ -192,7 +194,7 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, } } const q = (l.kind === "quantity" ? l : r) as Quantity; - const p = (l.kind === "percent" ? l : r).value; + const p = (l.kind === "percent" ? l : r as { kind: "percent"; value: Decimal }).value; switch (op) { case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit); case "minus": @@ -513,6 +515,14 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo; return qty(new Decimal(raw)); } + case "until": { + if (x.kind !== "date") throw new EvalError("until needs a date"); + return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false }); + } + case "since": { + if (x.kind !== "date") throw new EvalError("since needs a date"); + return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x); + } default: throw new EvalError(`unknown function ${name}`); } } diff --git a/src/engine/formatter.ts b/src/engine/formatter.ts index 28fdf24..9c757f6 100644 --- a/src/engine/formatter.ts +++ b/src/engine/formatter.ts @@ -26,6 +26,8 @@ export function formatNumber(v: Decimal, s: EngineSettings): string { } export function formatValue(v: Value, s: EngineSettings): string { + if (v.kind === "chart") return ""; + if (v.kind === "percent") { return formatNumber(v.value, s) + "%"; } diff --git a/src/engine/index.ts b/src/engine/index.ts index 8680bc2..9b6388b 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -211,7 +211,8 @@ export class SumEngine { return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; } if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; - return { double: v.ms }; + if (v.kind === "date") return { double: v.ms }; + return { double: 0 }; // chart — not representable as ExtensionValue } } diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts index a46af53..8fa9f3b 100644 --- a/src/engine/lexer.ts +++ b/src/engine/lexer.ts @@ -3,12 +3,13 @@ import { Decimal, NumeralRepr } from "./types"; export interface Lex { - type: "num" | "word" | "sym"; + type: "num" | "word" | "sym" | "date"; raw: string; start: number; end: number; value?: Decimal; repr?: NumeralRepr; + dateMs?: number; } const WORD_RE = /^[\p{L}_]+/u; @@ -32,6 +33,18 @@ export function lexLine(text: string): Lex[] { } const rest = text.slice(i); + if (ch >= "0" && ch <= "9") { + const iso = /^\d{4}-\d{2}-\d{2}(?!\d)/.exec(rest); + if (iso) { + const [y, mo, d] = iso[0].split("-").map(Number); + const dt = new Date(y, mo - 1, d); + if (!isNaN(dt.getTime())) { + out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: dt.getTime() }); + i += iso[0].length; + continue; + } + } + } if (ch >= "0" && ch <= "9") { const hex = /^0[xX][0-9a-fA-F]+/.exec(rest); if (hex) { diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 2737db0..5705cb2 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -19,6 +19,7 @@ export type Node = | { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "var"; name: string } | { k: "date"; word: DateWord } + | { k: "datelit"; ms: number } | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node } @@ -360,6 +361,9 @@ class Parser { case "date": this.i++; return { k: "date", word: tk.word }; + case "datelit": + this.i++; + return { k: "datelit", ms: tk.ms }; case "agg": this.i++; return { k: "agg", name: tk.name }; diff --git a/src/engine/registry.ts b/src/engine/registry.ts index 335a82e..7c646b2 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -58,7 +58,7 @@ const BUILTIN_FUNCS = [ "sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor", - "fact", "factorial", "random", + "fact", "factorial", "random", "until", "since", ]; export class Registry { @@ -181,6 +181,9 @@ export function buildRegistry(): Registry { for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false }); reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false }); diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts index 8a0ef5f..1a7c5cc 100644 --- a/src/engine/tokenizer.ts +++ b/src/engine/tokenizer.ts @@ -15,10 +15,11 @@ export type Token = | { t: "pctop"; op: PctOp; start: number; end: number } | { t: "percent"; start: number; end: number } | { t: "func"; name: string; start: number; end: number } - | { t: "agg"; name: "sum" | "avg" | "prev"; start: number; end: number } + | { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart"; start: number; end: number } | { t: "scale"; mult: Decimal; start: number; end: number } | { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "date"; word: DateWord; start: number; end: number } + | { t: "datelit"; ms: number; start: number; end: number } | { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number } | { t: "lparen"; start: number; end: number } | { t: "rparen"; start: number; end: number } @@ -45,6 +46,12 @@ export function tokenize(line: string, reg: Registry): Token[] { continue; } + if (lx.type === "date") { + tokens.push({ t: "datelit", ms: lx.dateMs!, ...span }); + i++; + continue; + } + const m = reg.match(lexes, lowers, i); if (m) { const end = lexes[i + m.length - 1].end; diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts index 2b58106..aaebe90 100644 --- a/src/engine/vocab-data.ts +++ b/src/engine/vocab-data.ts @@ -433,7 +433,9 @@ export const EN: VocabLocale = { "max.variants": "max,maximum,largest", "product.variants": "product", "random.variants": "random,rand", - "chart.variants": "chart,spark,sparkline,graph" + "chart.variants": "chart,spark,sparkline,graph", + "until.variants": "until,days until,day until", + "since.variants": "since,days since,day since" }, "Percent": { "as_a_percent_of.variants": "as a % of,as a percent of", @@ -911,7 +913,9 @@ export const RU: VocabLocale = { "max.variants": "максимум,макс,наибольшее", "product.variants": "произведение,произв", "random.variants": "случайное,случайно,рандом", - "chart.variants": "график,спарклайн,диаграмма" + "chart.variants": "график,спарклайн,диаграмма", + "until.variants": "осталось до,дней до,дней до даты", + "since.variants": "прошло с,дней с,дней прошло с" }, "Percent": { "as_a_percent_of.variants": "как процент от,как процент", diff --git a/src/ui/editor.ts b/src/ui/editor.ts index dcb1698..79dbdb1 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -40,7 +40,7 @@ function buildDecorations(state: EditorState): DecorationSet { let cls: string | null = null; switch (tk.t) { case "num": case "const": case "scale": cls = "tok-number"; break; - case "unit": case "currency": case "repr": case "date": cls = "tok-unit"; break; + case "unit": case "currency": case "repr": case "date": case "datelit": cls = "tok-unit"; break; case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break; case "func": case "agg": cls = "tok-var"; break; default: cls = null;