Skip to content

Commit 4d97bec

Browse files
os-zhuangclaude
andcommitted
feat(formula): timezone-aware today()/daysFromNow()/daysAgo() (#1980)
ADR-0053 Phase 2 D1. today()/daysFromNow()/daysAgo() become calendar-day functions resolved in a reference timezone (threaded ExecutionContext.timezone -> EvalContext.timezone -> CEL stdlib). Each returns the reference-tz calendar day as a UTC-midnight Date — the representation consistent with Field.date hydration, the SQL driver's date-filter normalization, and Phase 1 storage — so `record.date == daysFromNow(n)` matches in-memory too. Timezone math uses Intl.DateTimeFormat (DST-safe). BEHAVIOR CHANGE: daysFromNow(n)/daysAgo(n) previously kept now's wall-clock time; they now drop it and return the calendar day at midnight (the ADR "defect #3" fix). today() is unchanged at UTC. Sub-day offsets: now() + duration("Nh"). objectql threads execCtx.timezone into applyFormulaPlan (read-time formula fields) and applyFieldDefaults (default-value expressions). With no reference tz configured the zone is UTC. Tests: tz-aware today()/daysFromNow()/daysAgo() (America/New_York day boundary); daysFromNow midnight semantics; existing wall-clock assertions updated to calendar-day. formula 121 green, objectql 639 green. Part of #1980. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 11af299 commit 4d97bec

7 files changed

Lines changed: 93 additions & 11 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
feat(formula): timezone-aware `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2)
7+
8+
These are now **calendar-day** functions resolved in a reference timezone, threaded from `ExecutionContext.timezone` (#1978) through `EvalContext.timezone` into the CEL stdlib. Each returns the reference-tz calendar day expressed as a **UTC-midnight `Date`** (ADR-0053 decision D1) — the one representation consistent with how `Field.date` strings hydrate, how the SQL driver normalizes date filters, and how Phase 1 stores dates. So `record.close_date == daysFromNow(30)` now matches in-memory too, not just at the storage boundary. The timezone calculation uses `Intl.DateTimeFormat` (DST-safe; no hand-rolled offset math).
9+
10+
**⚠️ Behavior change:** `daysFromNow(n)` / `daysAgo(n)` previously kept the wall-clock time of `now` (e.g. `daysFromNow(30)` at `10:00Z``…T10:00:00Z`). They now drop the time and return the calendar day at **midnight** (`…T00:00:00Z`) — the ADR-0053 "defect #3" fix. `today()` is unchanged at UTC (it already truncated to start-of-day). For a genuine sub-day offset use the documented escape hatch `now() + duration("Nh")`.
11+
12+
With no reference timezone configured the zone resolves to `UTC`, so `today()` is byte-for-byte unchanged; only the `daysFromNow`/`daysAgo` midnight-truncation differs from before. `objectql` threads `execCtx.timezone` into read-time formula evaluation (`applyFormulaPlan`) and default-value expressions (`applyFieldDefaults`).
13+
14+
Part of #1980. (Consuming a non-UTC reference timezone end-to-end also needs the `localization` settings manifest noted in #1978.)

packages/formula/src/cel-engine.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,28 @@ describe('celEngine', () => {
4040
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z');
4141
});
4242

43-
it('daysFromNow(n) advances by n days from pinned now', () => {
43+
it('daysFromNow(n) returns the calendar day n days out, at midnight (ADR-0053 D1)', () => {
44+
// Calendar-day semantics: the wall-clock time of `now` is dropped, so
45+
// `record.date == daysFromNow(n)` matches in-memory (the defect-3 fix).
4446
const pinned = new Date('2026-01-15T10:00:00Z');
4547
const r = celEngine.evaluate(cel('daysFromNow(30)'), { now: pinned });
4648
expect(r.ok).toBe(true);
47-
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T10:00:00.000Z');
49+
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z');
50+
});
51+
52+
it('today()/daysFromNow()/daysAgo() resolve the calendar day in the reference timezone', () => {
53+
// 2026-01-15T02:00Z is still Jan 14 in America/New_York (UTC-5).
54+
const pinned = new Date('2026-01-15T02:00:00Z');
55+
const tz = 'America/New_York';
56+
const today = celEngine.evaluate(cel('today()'), { now: pinned, timezone: tz });
57+
expect(today.ok && (today.value as Date).toISOString()).toBe('2026-01-14T00:00:00.000Z');
58+
const tomorrow = celEngine.evaluate(cel('daysFromNow(1)'), { now: pinned, timezone: tz });
59+
expect(tomorrow.ok && (tomorrow.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z');
60+
const yesterday = celEngine.evaluate(cel('daysAgo(1)'), { now: pinned, timezone: tz });
61+
expect(yesterday.ok && (yesterday.value as Date).toISOString()).toBe('2026-01-13T00:00:00.000Z');
62+
// Default (UTC) sees Jan 15.
63+
const utc = celEngine.evaluate(cel('today()'), { now: pinned });
64+
expect(utc.ok && (utc.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z');
4865
});
4966

5067
it('classifies parse errors with kind=parse', () => {

packages/formula/src/cel-engine.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ export const DEFAULT_LIMITS = {
3232
maxCallArguments: 16,
3333
} as const;
3434

35-
function buildEnv(now: () => Date): Environment {
35+
function buildEnv(now: () => Date, timezone = 'UTC'): Environment {
3636
const env = new Environment({
3737
unlistedVariablesAreDyn: true,
3838
enableOptionalTypes: true,
3939
limits: DEFAULT_LIMITS,
4040
});
41-
return registerNumericCoercions(registerStdLib(env, now));
41+
return registerNumericCoercions(registerStdLib(env, now, timezone));
4242
}
4343

4444
/**
@@ -272,7 +272,7 @@ export const celEngine: DialectEngine = {
272272

273273
const now = () => ctx.now ?? new Date();
274274
try {
275-
const env = buildEnv(now);
275+
const env = buildEnv(now, ctx.timezone ?? 'UTC');
276276
const scope = buildScope(ctx);
277277
try {
278278
const raw = env.evaluate(source, scope);

packages/formula/src/seed-eval.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ describe('resolveSeed', () => {
2323
const pinned = new Date('2026-01-15T10:00:00Z');
2424
const r = resolveSeed(cel('daysFromNow(30)'), { now: pinned });
2525
expect(r.ok).toBe(true);
26-
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T10:00:00.000Z');
26+
// Calendar-day semantics (ADR-0053 D1): the calendar day 30 days out, at
27+
// midnight — the wall-clock time of `now` is dropped.
28+
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z');
2729
});
2830

2931
it('walks arrays', () => {
@@ -77,7 +79,9 @@ describe('resolveSeed', () => {
7779
{ now: pinned },
7880
);
7981
expect(r.ok).toBe(true);
82+
// The pinned now's calendar day is honored (Jun 1 + 30d = Jul 1); the time
83+
// is dropped — daysFromNow is a calendar-day function (ADR-0053 D1).
8084
if (r.ok)
81-
expect((r.value.close_date as Date).toISOString()).toBe('2026-07-01T12:00:00.000Z');
85+
expect((r.value.close_date as Date).toISOString()).toBe('2026-07-01T00:00:00.000Z');
8286
});
8387
});

packages/formula/src/stdlib.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ import type { Environment } from '@marcbachmann/cel-js';
1515

1616
import type { EvalContext } from './types';
1717

18+
/**
19+
* Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
20+
* (ADR-0053 Phase 2). Uses `Intl.DateTimeFormat` so DST transitions are
21+
* handled correctly — never hand-rolled offset math. An unknown zone throws,
22+
* which the caller treats as a fall-through to UTC.
23+
*/
24+
function partsInTz(d: Date, tz: string): { y: number; m: number; day: number } {
25+
const parts = new Intl.DateTimeFormat('en-US', {
26+
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
27+
}).formatToParts(d);
28+
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
29+
return { y: get('year'), m: get('month'), day: get('day') };
30+
}
31+
32+
/**
33+
* The calendar day of an instant *in a reference timezone*, expressed as a
34+
* UTC-midnight `Date` (ADR-0053 Phase 2, decision D1). This is the one
35+
* representation consistent with how `Field.date` strings hydrate (UTC
36+
* midnight), how the SQL driver normalizes date filters, and how Phase 1
37+
* stores dates — so `record.date == today()` compares cleanly. Falls back to
38+
* the UTC calendar day for `UTC` or an invalid zone.
39+
*/
40+
function calendarDayUtc(d: Date, tz: string): Date {
41+
if (tz && tz !== 'UTC') {
42+
try {
43+
const { y, m, day } = partsInTz(d, tz);
44+
return new Date(Date.UTC(y, m - 1, day));
45+
} catch {
46+
// unknown zone → fall through to UTC
47+
}
48+
}
49+
return startOfDayUtc(d);
50+
}
51+
1852
/** Truncate a Date to start-of-day in UTC. */
1953
function startOfDayUtc(d: Date): Date {
2054
const out = new Date(d.getTime());
@@ -50,20 +84,25 @@ function addDaysUtc(d: Date, n: number): Date {
5084
export function registerStdLib(
5185
env: Environment,
5286
now: () => Date,
87+
timezone = 'UTC',
5388
): Environment {
89+
// `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they
90+
// resolve to the reference-tz calendar day expressed as a UTC-midnight Date
91+
// (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a
92+
// genuine sub-day offset use `now() + duration("Nh")`.
5493
return env
5594
.registerFunction('now(): google.protobuf.Timestamp', () => now())
5695
.registerFunction(
5796
'today(): google.protobuf.Timestamp',
58-
() => startOfDayUtc(now()),
97+
() => calendarDayUtc(now(), timezone),
5998
)
6099
.registerFunction(
61100
'daysFromNow(int): google.protobuf.Timestamp',
62-
(n: bigint | number) => addDaysUtc(now(), Number(n)),
101+
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)),
63102
)
64103
.registerFunction(
65104
'daysAgo(int): google.protobuf.Timestamp',
66-
(n: bigint | number) => addDaysUtc(now(), -Number(n)),
105+
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)),
67106
)
68107
// Returns true when `value` is null, undefined, empty string, or empty list.
69108
// Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic.

packages/formula/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import type { Expression } from '@objectstack/spec';
2323
export interface EvalContext {
2424
/** Logical "now" snapshot — pinned per evaluation run for determinism. */
2525
now?: Date;
26+
/**
27+
* Reference timezone (IANA name, e.g. `America/New_York`) for calendar-day
28+
* functions `today()` / `daysFromNow()` / `daysAgo()` (ADR-0053 Phase 2).
29+
* Defaults to `UTC` when unset.
30+
*/
31+
timezone?: string;
2632
/** Current authenticated subject (hook / action / view contexts). */
2733
user?: {
2834
id: string;

packages/objectql/src/engine.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ function applyFormulaPlan(
108108
): void {
109109
if (!plan.length) return;
110110
const now = nowSnapshot ?? new Date();
111+
const timezone = execCtx?.timezone;
111112
const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined;
112113
const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined;
113114
for (const rec of records) {
114115
if (rec == null) continue;
115116
for (const fp of plan) {
116-
const r = ExpressionEngine.evaluate(fp.expression, { now, user, org, record: rec });
117+
const r = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, record: rec });
117118
rec[fp.name] = r.ok ? r.value : null;
118119
}
119120
}
@@ -761,6 +762,7 @@ export class ObjectQL implements IDataEngine {
761762
if (typeof dv === 'object' && dv !== null && (dv as any).dialect && typeof (dv as any).source === 'string') {
762763
const result = ExpressionEngine.evaluate(dv as any, {
763764
now,
765+
timezone: execCtx?.timezone,
764766
user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined,
765767
org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined,
766768
record: out,

0 commit comments

Comments
 (0)