add ISO date literals and days until/since

This commit is contained in:
Horizontal42 2026-07-01 20:27:55 +03:00
parent eff90d3219
commit f8f9095fca
10 changed files with 70 additions and 8 deletions

View file

@ -282,6 +282,24 @@ describe("dates", () => {
const t = calc("time in Tokyo"); const t = calc("time in Tokyo");
expect(t).toMatch(/^\d{2}:\d{2}$/); 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", () => { describe("extension API", () => {

View file

@ -42,6 +42,8 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
} }
case "date": case "date":
return evalDateWord(node.word); return evalDateWord(node.word);
case "datelit":
return { kind: "date", ms: node.ms, hasTime: false };
case "agg": case "agg":
return evalAgg(node.name, ctx); return evalAgg(node.name, ctx);
case "neg": { 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 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) { switch (op) {
case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit); case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
case "minus": 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; const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
return qty(new Decimal(raw)); 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}`); default: throw new EvalError(`unknown function ${name}`);
} }
} }

View file

@ -26,6 +26,8 @@ export function formatNumber(v: Decimal, s: EngineSettings): string {
} }
export function formatValue(v: Value, s: EngineSettings): string { export function formatValue(v: Value, s: EngineSettings): string {
if (v.kind === "chart") return "";
if (v.kind === "percent") { if (v.kind === "percent") {
return formatNumber(v.value, s) + "%"; return formatNumber(v.value, s) + "%";
} }

View file

@ -211,7 +211,8 @@ export class SumEngine {
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
} }
if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; 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
} }
} }

View file

@ -3,12 +3,13 @@
import { Decimal, NumeralRepr } from "./types"; import { Decimal, NumeralRepr } from "./types";
export interface Lex { export interface Lex {
type: "num" | "word" | "sym"; type: "num" | "word" | "sym" | "date";
raw: string; raw: string;
start: number; start: number;
end: number; end: number;
value?: Decimal; value?: Decimal;
repr?: NumeralRepr; repr?: NumeralRepr;
dateMs?: number;
} }
const WORD_RE = /^[\p{L}_]+/u; const WORD_RE = /^[\p{L}_]+/u;
@ -32,6 +33,18 @@ export function lexLine(text: string): Lex[] {
} }
const rest = text.slice(i); 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") { if (ch >= "0" && ch <= "9") {
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest); const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
if (hex) { if (hex) {

View file

@ -19,6 +19,7 @@ export type Node =
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
| { k: "var"; name: string } | { k: "var"; name: string }
| { k: "date"; word: DateWord } | { k: "date"; word: DateWord }
| { k: "datelit"; ms: number }
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { 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: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
| { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node }
@ -360,6 +361,9 @@ class Parser {
case "date": case "date":
this.i++; this.i++;
return { k: "date", word: tk.word }; return { k: "date", word: tk.word };
case "datelit":
this.i++;
return { k: "datelit", ms: tk.ms };
case "agg": case "agg":
this.i++; this.i++;
return { k: "agg", name: tk.name }; return { k: "agg", name: tk.name };

View file

@ -58,7 +58,7 @@ const BUILTIN_FUNCS = [
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan", "sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor", "ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
"fact", "factorial", "random", "fact", "factorial", "random", "until", "since",
]; ];
export class Registry { 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", "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", "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", "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("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });

View file

@ -15,10 +15,11 @@ export type Token =
| { t: "pctop"; op: PctOp; start: number; end: number } | { t: "pctop"; op: PctOp; start: number; end: number }
| { t: "percent"; start: number; end: number } | { t: "percent"; start: number; end: number }
| { t: "func"; name: string; 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: "scale"; mult: Decimal; start: number; end: number }
| { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; 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: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number } | { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number } | { t: "rparen"; start: number; end: number }
@ -45,6 +46,12 @@ export function tokenize(line: string, reg: Registry): Token[] {
continue; continue;
} }
if (lx.type === "date") {
tokens.push({ t: "datelit", ms: lx.dateMs!, ...span });
i++;
continue;
}
const m = reg.match(lexes, lowers, i); const m = reg.match(lexes, lowers, i);
if (m) { if (m) {
const end = lexes[i + m.length - 1].end; const end = lexes[i + m.length - 1].end;

View file

@ -433,7 +433,9 @@ export const EN: VocabLocale = {
"max.variants": "max,maximum,largest", "max.variants": "max,maximum,largest",
"product.variants": "product", "product.variants": "product",
"random.variants": "random,rand", "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": { "Percent": {
"as_a_percent_of.variants": "as a % of,as a percent of", "as_a_percent_of.variants": "as a % of,as a percent of",
@ -911,7 +913,9 @@ export const RU: VocabLocale = {
"max.variants": "максимум,макс,наибольшее", "max.variants": "максимум,макс,наибольшее",
"product.variants": "произведение,произв", "product.variants": "произведение,произв",
"random.variants": "случайное,случайно,рандом", "random.variants": "случайное,случайно,рандом",
"chart.variants": "график,спарклайн,диаграмма" "chart.variants": "график,спарклайн,диаграмма",
"until.variants": "осталось до,дней до,дней до даты",
"since.variants": "прошло с,дней с,дней прошло с"
}, },
"Percent": { "Percent": {
"as_a_percent_of.variants": "как процент от,как процент", "as_a_percent_of.variants": "как процент от,как процент",

View file

@ -40,7 +40,7 @@ function buildDecorations(state: EditorState): DecorationSet {
let cls: string | null = null; let cls: string | null = null;
switch (tk.t) { switch (tk.t) {
case "num": case "const": case "scale": cls = "tok-number"; break; 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 "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
case "func": case "agg": cls = "tok-var"; break; case "func": case "agg": cls = "tok-var"; break;
default: cls = null; default: cls = null;