|
| 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 | +}; |
0 commit comments