engine: configurable date formats, both input and display

Besides ISO, the lexer now accepts D.M.YYYY / M/D/YYYY literals
(31.12.2024, 12/31/2024) with a round-trip validity check so
out-of-range components (2026-13-45, 32.13.2024) fall back to plain
arithmetic instead of silently rolling over to a nearby valid date —
that rollover bug affected ISO literals too, not just the new forms.

Day/month order for ambiguous literals and display layout both come
from a new dateFormat setting (system/iso/dmy/mdy); "system" resolves
once via Intl against the OS locale. Intl.DateTimeFormat instances
are now cached by options key instead of rebuilt on every render.

App-side wiring (settings UI, persistence) comes in a follow-up commit.
This commit is contained in:
Horizontal42 2026-07-03 11:35:47 +03:00
parent 3a264c4a3f
commit 0b2293f92f
7 changed files with 132 additions and 19 deletions

View file

@ -1,4 +1,16 @@
// Date helpers and a city → IANA timezone map for "time in Tokyo". // Date helpers and a city → IANA timezone map for "time in Tokyo".
import { DateOrder } from "./types";
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
export function detectDateOrder(): DateOrder {
const parts = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "numeric", year: "numeric" })
.formatToParts(new Date(2000, 11, 31));
for (const p of parts) {
if (p.type === "day") return "dmy";
if (p.type === "month") return "mdy";
}
return "dmy";
}
const Z: Record<string, string> = { const Z: Record<string, string> = {
// ---- common abbreviations // ---- common abbreviations

View file

@ -244,4 +244,41 @@ describe("review fixes", () => {
expect(r[0].text).toBe("10"); expect(r[0].text).toBe("10");
expect(r[0].commentStart).toBe(6); expect(r[0].commentStart).toBe(6);
}); });
it("invalid ISO date does not roll over — falls back to arithmetic", () => {
expect(calc("2026-13-45")).toBe("1,968");
});
describe("date literal formats", () => {
const dmyEng = new SumEngine({ dateFormat: "dmy" });
const mdyEng = new SumEngine({ dateFormat: "mdy" });
const isoEng = new SumEngine({ dateFormat: "iso" });
const calcWith = (e: SumEngine, expr: string) => {
const r = e.evaluateDocument(expr);
return r[r.length - 1].text;
};
it("dot-separated date respects the configured day/month order", () => {
expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("slash-separated date respects the configured day/month order", () => {
expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day");
});
it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => {
// dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan
expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day");
expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day");
});
it("an unambiguous component (>12) wins regardless of dateFormat", () => {
expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("an invalid date (both components out of range) is not parsed as a date", () => {
const r = dmyEng.evaluateDocument("32.13.2024");
expect(r[0].value?.kind).not.toBe("date");
});
it("display format overrides the OS locale layout", () => {
expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15");
expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024");
expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024");
});
});
}); });

View file

@ -1,6 +1,31 @@
// Turns values into display strings: unit/currency formats plus // Turns values into display strings: unit/currency formats plus
// precision and grouping settings. // precision and grouping settings.
import { Decimal, EngineSettings, Value } from "./types"; import { Decimal, DateVal, EngineSettings, Value } from "./types";
// Intl.DateTimeFormat construction is relatively expensive and every line with
// a date rebuilds one on each render — cache by locale+options instead.
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = (locale ?? "") + JSON.stringify(opts);
let f = dtfCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, opts);
dtfCache.set(key, f);
}
return f;
}
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
const parts = dtf("en-GB", {
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
}).formatToParts(v.ms);
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
const [y, mo, d] = [get("year"), get("month"), get("day")];
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
}
function group(intPart: string, sep: string): string { function group(intPart: string, sep: string): string {
if (!sep) return intPart; if (!sep) return intPart;
@ -35,15 +60,16 @@ export function formatValue(v: Value, s: EngineSettings): string {
if (v.kind === "date") { if (v.kind === "date") {
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone }; const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
if (v.timeOnly) { if (v.timeOnly) {
return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms); return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
} }
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
if (v.hasTime) { if (v.hasTime) {
return new Intl.DateTimeFormat(undefined, { return dtf(undefined, {
...opts, hour: "2-digit", minute: "2-digit", hour12: false, ...opts, hour: "2-digit", minute: "2-digit", hour12: false,
day: "numeric", month: "short", year: "numeric", day: "numeric", month: "short", year: "numeric",
}).format(v.ms); }).format(v.ms);
} }
return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms); return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
} }
// quantity // quantity

View file

@ -1,11 +1,12 @@
// Public facade: evaluates whole documents line by line and exposes the // Public facade: evaluates whole documents line by line and exposes the
// extension API (numi.addUnit / addFunction / setVariable). // extension API (numi.addUnit / addFunction / setVariable).
import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
import { Registry, buildRegistry, Completion } from "./registry"; import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer"; import { tokenize, Token } from "./tokenizer";
import { parseLine } from "./parser"; import { parseLine } from "./parser";
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime";
import snapshot from "./rates-snapshot.json"; import snapshot from "./rates-snapshot.json";
import { CRYPTO } from "./extraunits"; import { CRYPTO } from "./extraunits";
@ -20,6 +21,10 @@ function findCommentStart(line: string): number {
return m ? m.index + m[1].length : -1; return m ? m.index + m[1].length : -1;
} }
function resolveDateOrder(fmt: EngineSettings["dateFormat"]): DateOrder {
return fmt === "dmy" || fmt === "mdy" ? fmt : detectDateOrder();
}
export interface LineResult { export interface LineResult {
/** formatted result, null when the line has none (text, comment, header, error) */ /** formatted result, null when the line has none (text, comment, header, error) */
text: string | null; text: string | null;
@ -53,9 +58,12 @@ export class SumEngine {
settings: EngineSettings; settings: EngineSettings;
private globals = new Map<string, Value>(); private globals = new Map<string, Value>();
private historicalRates = new Map<string, Map<string, number>>(); private historicalRates = new Map<string, Map<string, number>>();
/** resolved day/month order for ambiguous date literals like "01/02/2024" */
private dateOrder: DateOrder;
constructor(settings: Partial<EngineSettings> = {}) { constructor(settings: Partial<EngineSettings> = {}) {
this.settings = { ...defaultSettings, ...settings }; this.settings = { ...defaultSettings, ...settings };
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
this.reg = buildRegistry(); this.reg = buildRegistry();
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot }); this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
} }
@ -76,6 +84,7 @@ export class SumEngine {
updateSettings(patch: Partial<EngineSettings>): void { updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch }; this.settings = { ...this.settings, ...patch };
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
} }
completions(): Completion[] { completions(): Completion[] {
@ -109,7 +118,7 @@ export class SumEngine {
continue; continue;
} }
const tokens = tokenize(line, this.reg); const tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys()); const knownVars = new Set(vars.keys());
const parsed = parseLine(tokens, knownVars, line); const parsed = parseLine(tokens, knownVars, line);

View file

@ -1,6 +1,6 @@
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the // Splits a line into raw lexemes (numbers, words, symbols). Shared by the
// tokenizer and the phrase registry so both segment text the same way. // tokenizer and the phrase registry so both segment text the same way.
import { Decimal, NumeralRepr } from "./types"; import { DateOrder, Decimal, NumeralRepr } from "./types";
export interface Lex { export interface Lex {
type: "num" | "word" | "sym" | "date" | "xref"; type: "num" | "word" | "sym" | "date" | "xref";
@ -23,8 +23,18 @@ const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[   ]\d{3})+(?:[.,]\d+)?(?!\d)/;
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/; const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/; const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
// 31.12.2024 / 12/31/2024 — same separator on both sides, 4-digit year
const SLASH_DOT_DATE_RE = /^(\d{1,2})([./])(\d{1,2})\2(\d{4})(?!\d)/;
export function lexLine(text: string): Lex[] { /** ms for a real calendar date; null when the components roll over (2026-13-45) */
function validDateMs(y: number, mo: number, d: number): number | null {
const dt = new Date(y, mo - 1, d);
return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d
? dt.getTime()
: null;
}
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
const out: Lex[] = []; const out: Lex[] = [];
let i = 0; let i = 0;
while (i < text.length) { while (i < text.length) {
@ -36,16 +46,29 @@ export function lexLine(text: string): Lex[] {
const rest = text.slice(i); const rest = text.slice(i);
if (ch >= "0" && ch <= "9") { if (ch >= "0" && ch <= "9") {
const iso = /^\d{4}-\d{2}-\d{2}(?!\d)/.exec(rest); const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest);
if (iso) { if (iso) {
const [y, mo, d] = iso[0].split("-").map(Number); const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3]));
const dt = new Date(y, mo - 1, d); if (ms !== null) {
if (!isNaN(dt.getTime())) { out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms });
out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: dt.getTime() });
i += iso[0].length; i += iso[0].length;
continue; continue;
} }
} }
const dm = SLASH_DOT_DATE_RE.exec(rest);
if (dm) {
const a = Number(dm[1]);
const b = Number(dm[3]);
// a>12 must be the day, b>12 must be the month; both ≤12 → configured order
const dayFirst = a > 12 ? true : b > 12 ? false : dateOrder === "dmy";
const y = Number(dm[4]);
const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b);
if (ms !== null) {
out.push({ type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms });
i += dm[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);

View file

@ -1,5 +1,5 @@
// Lexes a line and resolves phrases through the registry into semantic tokens. // Lexes a line and resolves phrases through the registry into semantic tokens.
import { Decimal, NumeralRepr, Unit } from "./types"; import { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
import { lexLine } from "./lexer"; import { lexLine } from "./lexer";
import { Registry, PctOp, DateWord, BitOp } from "./registry"; import { Registry, PctOp, DateWord, BitOp } from "./registry";
@ -30,11 +30,8 @@ export type Token =
| { t: "unknown"; start: number; end: number } | { t: "unknown"; start: number; end: number }
| { t: "junk"; raw: string; start: number; end: number }; | { t: "junk"; raw: string; start: number; end: number };
const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {}; export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dmy"): Token[] {
const lexes = lexLine(line, dateOrder);
export function tokenize(line: string, reg: Registry): Token[] {
void SYM_OPS;
const lexes = lexLine(line);
const lowers = lexes.map((l) => l.raw.toLowerCase()); const lowers = lexes.map((l) => l.raw.toLowerCase());
const tokens: Token[] = []; const tokens: Token[] = [];
let i = 0; let i = 0;

View file

@ -24,6 +24,12 @@ export type Dimension =
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain"; export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
/** day-first (31.12.2024) or month-first (12/31/2024) for ambiguous date literals */
export type DateOrder = "dmy" | "mdy";
/** "system" renders via the OS locale; the rest force a fixed layout */
export type DateFormat = "system" | "iso" | "dmy" | "mdy";
/** A resolved unit instance attached to a value. */ /** A resolved unit instance attached to a value. */
export interface Unit { export interface Unit {
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */ /** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
@ -88,10 +94,13 @@ export interface EngineSettings {
groupSeparator: string; groupSeparator: string;
/** decimal separator shown in output */ /** decimal separator shown in output */
decimalSeparator: string; decimalSeparator: string;
/** date display layout; also disambiguates 01/02/2024 on input */
dateFormat: DateFormat;
} }
export const defaultSettings: EngineSettings = { export const defaultSettings: EngineSettings = {
precision: 2, precision: 2,
groupSeparator: ",", groupSeparator: ",",
decimalSeparator: ".", decimalSeparator: ".",
dateFormat: "system",
}; };