Skip to content

Commit 2045c57

Browse files
os-zhuangCopilot
andcommitted
spec: define date macro contract (DATE_MACRO_TOKENS, schemas, helpers)
Promote the relative-date placeholder vocabulary used by dashboards, reports, and list-view filters from an undocumented frontend convention to a first-class platform contract in `@objectstack/spec`. - New `packages/spec/src/data/date-macros.zod.ts` exports: - DATE_MACRO_TOKENS (36 fixed tokens incl. today / yesterday / tomorrow / now, current_/last_/next_ × week|month|quarter|year × start|end, bare aliases). - DATE_MACRO_PARAM_RE for {N_<unit>_(ago|from_now)} grammar covering minute|hour|day|week|month|year. - DateMacroTokenSchema / DateMacroPlaceholderSchema (zod). - isDateMacroToken(), parseDateMacroParam() helpers. - DATE_MACRO_DESCRIPTIONS table (for skill/doc generation). - Test (7 cases) verifies cardinality, regex anchoring, schema parsing. - Skill doc (objectstack-ui/SKILL.md) now embeds the full token table with DO/DON'T guidance and cross-links the spec module. The resolver implementation lives in `@object-ui/core` (resolveDateMacros); this commit gives both the resolver and authoring AIs a single source of truth for which placeholders are valid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2325863 commit 2045c57

4 files changed

Lines changed: 360 additions & 1 deletion

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
DATE_MACRO_TOKENS,
6+
DATE_MACRO_PARAM_RE,
7+
DATE_MACRO_UNITS,
8+
DATE_MACRO_DESCRIPTIONS,
9+
DateMacroPlaceholderSchema,
10+
DateMacroTokenSchema,
11+
isDateMacroToken,
12+
parseDateMacroParam,
13+
} from './date-macros.zod';
14+
15+
describe('date macro contract', () => {
16+
it('every fixed token has a description', () => {
17+
for (const t of DATE_MACRO_TOKENS) {
18+
expect(DATE_MACRO_DESCRIPTIONS[t]).toBeTypeOf('string');
19+
}
20+
});
21+
22+
it('fixed tokens pass the token schema', () => {
23+
for (const t of DATE_MACRO_TOKENS) {
24+
expect(() => DateMacroTokenSchema.parse(t)).not.toThrow();
25+
}
26+
});
27+
28+
it('parameterised tokens are accepted for every unit and direction', () => {
29+
for (const unit of DATE_MACRO_UNITS) {
30+
for (const direction of ['ago', 'from_now'] as const) {
31+
const token = `7_${unit}s_${direction}`;
32+
expect(isDateMacroToken(token)).toBe(true);
33+
const parsed = parseDateMacroParam(token);
34+
expect(parsed).toEqual({ n: 7, unit, direction });
35+
}
36+
}
37+
});
38+
39+
it('singular unit form is accepted (1_day_ago, 1_year_from_now)', () => {
40+
expect(isDateMacroToken('1_day_ago')).toBe(true);
41+
expect(isDateMacroToken('1_year_from_now')).toBe(true);
42+
});
43+
44+
it('rejects unknown tokens', () => {
45+
for (const bad of [
46+
'90_days', '90 days ago', 'days_ago_30', 'foo', '{today}',
47+
'last_century_start', 'now()', '30_decades_ago',
48+
]) {
49+
expect(isDateMacroToken(bad), bad).toBe(false);
50+
}
51+
});
52+
53+
it('placeholder schema accepts {token} and ${token}', () => {
54+
expect(() => DateMacroPlaceholderSchema.parse('{today}')).not.toThrow();
55+
expect(() => DateMacroPlaceholderSchema.parse('${30_days_ago}')).not.toThrow();
56+
expect(() => DateMacroPlaceholderSchema.parse('today')).toThrow();
57+
expect(() => DateMacroPlaceholderSchema.parse('{not_a_macro}')).toThrow();
58+
});
59+
60+
it('regex anchors prevent partial matches', () => {
61+
expect(DATE_MACRO_PARAM_RE.test('prefix_30_days_ago')).toBe(false);
62+
expect(DATE_MACRO_PARAM_RE.test('30_days_ago_suffix')).toBe(false);
63+
});
64+
});
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { z } from 'zod';
4+
5+
/**
6+
* Date Macro Tokens — the declarative placeholders the UI substitutes
7+
* into filter values before sending a query to the data engine.
8+
*
9+
* # Why this lives in `spec`
10+
*
11+
* Filter values in dashboards, views, reports and pages travel as JSON.
12+
* Because JSON cannot evaluate code, callers cannot write `daysAgo(30)`
13+
* inline; they use a tiny placeholder grammar instead:
14+
*
15+
* { published_at: { $gte: '{last_quarter_start}' } }
16+
* { signal_at: { $gte: '{30_days_ago}' } }
17+
*
18+
* The placeholders are expanded **client-side** by
19+
* `resolveDateMacros()` in `@object-ui/core` immediately before the
20+
* filter is handed to the data source. The data engine itself only
21+
* sees ISO date / timestamp strings, never `{tokens}`.
22+
*
23+
* AI agents and template authors author these placeholders directly,
24+
* so the **set of recognised tokens is part of the platform contract**
25+
* and must live here next to the rest of the JSON-DSL schemas, not
26+
* inside any single UI implementation.
27+
*
28+
* # Two flavours of token
29+
*
30+
* 1. **Fixed tokens** — small, finite list (`{today}`,
31+
* `{current_quarter_start}`, `{last_year_end}`, …). Enumerated by
32+
* `DATE_MACRO_TOKENS` below.
33+
*
34+
* 2. **Parameterised tokens** — `{N_days_ago}`, `{N_weeks_from_now}`,
35+
* etc., where `N` is any non-negative integer. Matched by
36+
* `DATE_MACRO_PARAM_RE`. Units: `minute(s)`, `hour(s)`, `day(s)`,
37+
* `week(s)`, `month(s)`, `year(s)`. Directions: `ago`, `from_now`.
38+
*
39+
* # Out of scope
40+
*
41+
* - CEL expressions (`cel\`daysAgo(30)\``) run **server-side** in the
42+
* formula engine. They are unrelated to these placeholders; see
43+
* `@objectstack/formula`.
44+
* - Token resolution semantics (week-start day, timezone, fiscal
45+
* calendars) are defined by the resolver implementation; spec only
46+
* freezes the **vocabulary**.
47+
*/
48+
49+
/** Single-point tokens — moments in time. */
50+
export const DATE_MACRO_INSTANT_TOKENS = [
51+
'today',
52+
'yesterday',
53+
'tomorrow',
54+
'now',
55+
] as const;
56+
57+
/** Period boundaries for the current/last/next week/month/quarter/year. */
58+
export const DATE_MACRO_PERIOD_TOKENS = [
59+
'current_week_start', 'current_week_end',
60+
'current_month_start', 'current_month_end',
61+
'current_quarter_start', 'current_quarter_end',
62+
'current_year_start', 'current_year_end',
63+
64+
'last_week_start', 'last_week_end',
65+
'last_month_start', 'last_month_end',
66+
'last_quarter_start', 'last_quarter_end',
67+
'last_year_start', 'last_year_end',
68+
69+
'next_week_start', 'next_week_end',
70+
'next_month_start', 'next_month_end',
71+
'next_quarter_start', 'next_quarter_end',
72+
'next_year_start', 'next_year_end',
73+
] as const;
74+
75+
/**
76+
* Bare aliases — `{week_start}` means `{current_week_start}`, etc.
77+
* Provided because every other major dashboard tool accepts them and
78+
* AI agents reach for them by default.
79+
*/
80+
export const DATE_MACRO_ALIAS_TOKENS = [
81+
'week_start', 'week_end',
82+
'month_start', 'month_end',
83+
'quarter_start','quarter_end',
84+
'year_start', 'year_end',
85+
] as const;
86+
87+
/** All fixed tokens — concat of the three groups above. */
88+
export const DATE_MACRO_TOKENS = [
89+
...DATE_MACRO_INSTANT_TOKENS,
90+
...DATE_MACRO_PERIOD_TOKENS,
91+
...DATE_MACRO_ALIAS_TOKENS,
92+
] as const;
93+
94+
export type DateMacroToken = (typeof DATE_MACRO_TOKENS)[number];
95+
96+
/**
97+
* Parameterised token grammar.
98+
*
99+
* {<N>_<unit>(s)?_<direction>}
100+
*
101+
* Examples that match:
102+
* - `30_days_ago`
103+
* - `1_day_ago`
104+
* - `90_days_from_now`
105+
* - `2_weeks_ago`
106+
* - `6_months_from_now`
107+
* - `1_year_ago`
108+
* - `15_minutes_ago`
109+
* - `2_hours_from_now`
110+
*
111+
* Capture groups: 1=N (string of digits), 2=unit (singular or plural,
112+
* no trailing `s` stripped), 3=direction (`ago` | `from_now`).
113+
*/
114+
export const DATE_MACRO_PARAM_RE =
115+
/^(\d+)_(minutes?|hours?|days?|weeks?|months?|years?)_(ago|from_now)$/;
116+
117+
/**
118+
* Units recognised by the parameterised grammar. Listed here so the
119+
* resolver and any token-completion UI can share the source of truth.
120+
*/
121+
export const DATE_MACRO_UNITS = [
122+
'minute', 'hour', 'day', 'week', 'month', 'year',
123+
] as const;
124+
125+
export type DateMacroUnit = (typeof DATE_MACRO_UNITS)[number];
126+
export type DateMacroDirection = 'ago' | 'from_now';
127+
128+
export interface DateMacroParamMatch {
129+
readonly n: number;
130+
readonly unit: DateMacroUnit;
131+
readonly direction: DateMacroDirection;
132+
}
133+
134+
/**
135+
* Test helper: return whether `token` (without braces) is a valid
136+
* date macro — either a fixed token or a parameterised one. Use
137+
* this in lint passes or AI-side validators.
138+
*/
139+
export function isDateMacroToken(token: string): boolean {
140+
return (
141+
(DATE_MACRO_TOKENS as readonly string[]).includes(token) ||
142+
DATE_MACRO_PARAM_RE.test(token)
143+
);
144+
}
145+
146+
/** Parse a parameterised token. Returns null if the token isn't parameterised. */
147+
export function parseDateMacroParam(token: string): DateMacroParamMatch | null {
148+
const m = token.match(DATE_MACRO_PARAM_RE);
149+
if (!m) return null;
150+
const unit = m[2].replace(/s$/, '') as DateMacroUnit;
151+
return {
152+
n: parseInt(m[1], 10),
153+
unit,
154+
direction: m[3] as DateMacroDirection,
155+
};
156+
}
157+
158+
/**
159+
* Strict zod schema for the **token name** (the bit inside `{}`).
160+
* Accepts any fixed token or anything matching `DATE_MACRO_PARAM_RE`.
161+
*/
162+
export const DateMacroTokenSchema = z
163+
.string()
164+
.refine(isDateMacroToken, {
165+
message:
166+
'Unknown date macro. Must be a fixed token (see DATE_MACRO_TOKENS) ' +
167+
'or match {N_<unit>_(ago|from_now)} where unit is one of: ' +
168+
DATE_MACRO_UNITS.join(', '),
169+
});
170+
171+
/**
172+
* Match the **wrapped** form, i.e. the literal a filter author writes:
173+
* `{today}` or `${30_days_ago}`. Used by lint passes that walk filter
174+
* trees looking for placeholder strings.
175+
*/
176+
export const DATE_MACRO_WRAPPED_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/;
177+
178+
/**
179+
* Zod schema for a complete placeholder string (`'{today}'`,
180+
* `'${30_days_ago}'`). Use as an opt-in refinement on filter values
181+
* to fail loudly on unknown tokens instead of silently sending them
182+
* to SQL.
183+
*/
184+
export const DateMacroPlaceholderSchema = z
185+
.string()
186+
.refine(
187+
(s) => {
188+
const m = s.match(DATE_MACRO_WRAPPED_RE);
189+
return !!m && isDateMacroToken(m[1]);
190+
},
191+
{ message: 'Not a recognised {date-macro} placeholder' },
192+
);
193+
194+
/**
195+
* Description table — handy for skill / docs generation. Pure data,
196+
* intentionally not exported through any zod schema.
197+
*/
198+
export const DATE_MACRO_DESCRIPTIONS: Record<DateMacroToken, string> = {
199+
today: 'Start of today (YYYY-MM-DD).',
200+
yesterday:'Start of yesterday.',
201+
tomorrow: 'Start of tomorrow.',
202+
now: 'Current timestamp (full ISO).',
203+
204+
current_week_start: 'Monday 00:00 of this week.',
205+
current_week_end: 'Sunday of this week.',
206+
current_month_start: '1st of this month.',
207+
current_month_end: 'Last day of this month.',
208+
current_quarter_start: '1st day of this quarter.',
209+
current_quarter_end: 'Last day of this quarter.',
210+
current_year_start: 'Jan 1 of this year.',
211+
current_year_end: 'Dec 31 of this year.',
212+
213+
last_week_start: 'Monday of last week.',
214+
last_week_end: 'Sunday of last week.',
215+
last_month_start: '1st of last month.',
216+
last_month_end: 'Last day of last month.',
217+
last_quarter_start: '1st day of last quarter.',
218+
last_quarter_end: 'Last day of last quarter.',
219+
last_year_start: 'Jan 1 of last year.',
220+
last_year_end: 'Dec 31 of last year.',
221+
222+
next_week_start: 'Monday of next week.',
223+
next_week_end: 'Sunday of next week.',
224+
next_month_start: '1st of next month.',
225+
next_month_end: 'Last day of next month.',
226+
next_quarter_start: '1st day of next quarter.',
227+
next_quarter_end: 'Last day of next quarter.',
228+
next_year_start: 'Jan 1 of next year.',
229+
next_year_end: 'Dec 31 of next year.',
230+
231+
week_start: 'Alias for current_week_start.',
232+
week_end: 'Alias for current_week_end.',
233+
month_start: 'Alias for current_month_start.',
234+
month_end: 'Alias for current_month_end.',
235+
quarter_start: 'Alias for current_quarter_start.',
236+
quarter_end: 'Alias for current_quarter_end.',
237+
year_start: 'Alias for current_year_start.',
238+
year_end: 'Alias for current_year_end.',
239+
};

packages/spec/src/data/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export * from './query.zod';
44
export * from './filter.zod';
5+
export * from './date-macros.zod';
56
export * from './object.zod';
67
export * from './field.zod';
78
export * from './validation.zod';

skills/objectstack-ui/SKILL.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ export const LeadDetailPage: Page = {
380380
> **Variable substitution**`{first_name}`, `{current_user.first_name}`,
381381
> `{current_quarter_start}` etc. resolve from the page's `variables` block,
382382
> the bound record, and the runtime context. Declare `variables: [...]` at
383-
> the page root for any non-record value.
383+
> the page root for any non-record value. For relative-date placeholders
384+
> (`{today}`, `{30_days_ago}`, `{N_<unit>_(ago|from_now)}` …) see the
385+
> [Date Macros](#date-macros--filter-placeholders) reference below — the
386+
> full token list is published as `DATE_MACRO_TOKENS` in `@objectstack/spec`.
384387
385388
> **Actions in header** — pass full `Action` objects into
386389
> `page:header.properties.actions`; do **not** create a sibling action node.
@@ -459,6 +462,58 @@ export const SalesDashboard: Dashboard = {
459462

460463
> **Tokens in filters:** `{current_quarter_start}`, `{current_user.id}` are
461464
> resolved at request time. Avoid baking absolute dates into definitions.
465+
> The full list of supported date placeholders is documented in
466+
> [Date Macros](#date-macros--filter-placeholders) below.
467+
468+
---
469+
470+
## Date Macros — Filter Placeholders
471+
472+
Dashboards, reports, list-view filters, and other UI metadata can embed
473+
relative-date placeholders that are resolved on the client just before
474+
the request leaves the browser. The canonical contract is published as
475+
[`DATE_MACRO_TOKENS`](../../packages/spec/src/data/date-macros.zod.ts) in
476+
`@objectstack/spec`; the resolver lives in `@object-ui/core`
477+
(`resolveDateMacros`). Keep the two in lockstep.
478+
479+
Both `{token}` and `${token}` forms are accepted.
480+
481+
### Fixed tokens (36)
482+
483+
| Category | Tokens |
484+
|:--|:--|
485+
| Instants | `today`, `yesterday`, `tomorrow`, `now` |
486+
| Current period | `current_week_start` / `_end`, `current_month_start` / `_end`, `current_quarter_start` / `_end`, `current_year_start` / `_end` |
487+
| Last period | `last_week_start` / `_end`, `last_month_start` / `_end`, `last_quarter_start` / `_end`, `last_year_start` / `_end` |
488+
| Next period | `next_week_start`, `next_month_start`, `next_quarter_start`, `next_year_start` |
489+
| Bare aliases | `week_start`, `week_end`, `month_start`, `month_end`, `quarter_start`, `quarter_end`, `year_start`, `year_end` (same as `current_*`) |
490+
491+
### Parameterised tokens — `{N_<unit>_(ago|from_now)}`
492+
493+
`N` is any positive integer; `<unit>` is one of
494+
`minute(s) | hour(s) | day(s) | week(s) | month(s) | year(s)`.
495+
`minute`/`hour` resolve to a full ISO timestamp; coarser units resolve to
496+
`YYYY-MM-DD`.
497+
498+
```
499+
{30_days_ago} {7_days_from_now} {1_day_ago}
500+
{2_weeks_ago} {6_months_from_now} {1_year_ago}
501+
{15_minutes_ago} {2_hours_from_now}
502+
```
503+
504+
### DO / DON'T
505+
506+
* **DO** type-check tokens against the spec — `isDateMacroToken(tok)` from
507+
`@objectstack/spec` returns `false` for anything unsupported.
508+
* **DO** prefer `Field.datetime()` for "near-now" filters (minute/hour
509+
precision); driver-sql automatically coerces ISO macros to the stored
510+
ms-epoch representation.
511+
* **DON'T** invent tokens. Unknown placeholders silently pass through as
512+
literal strings — the resulting SQL compares text against
513+
`'{my_made_up_token}'` and matches zero rows.
514+
* **DON'T** combine multiple tokens inside one value without resolution
515+
semantics (`'{today}-{tomorrow}'` is fine; `{today_or_tomorrow}` is
516+
not — there is no such token).
462517

463518
---
464519

0 commit comments

Comments
 (0)