Skip to content

Commit 04476e7

Browse files
Jack Qclaude
andauthored
feat(spec): dashboard 日期预设名收敛为单一词汇表,date filter 的 defaultValue 作者时受检 (#4614) (#6652)
* feat(spec): make the dashboard date-range preset names one vocabulary and check a date filter's defaultValue against it (#4614) A dashboard's built-in `dateRange` validated its preset name and a `globalFilters` entry of `type: 'date'` did not, so the same typo was an author-time error on one surface and a silent wrong answer on the other. `GlobalFilterSchema.defaultValue` is `string | number | boolean`, which makes a bare preset name the only spelling available for a date filter's default — and nothing checked it. An unrecognised name cannot be lifted to a range, so it fell through to "a bare string date means equality on that day" and reached the backend as `created_at = 'last_7_dayz'`: a condition no row matches, answered 200 OK with a zero. Every tile read 0 while the filter bar showed "All time". WHAT LANDED (A案 two steps) 1. Vocabulary migrated into spec as the single source of truth. `DATE_RANGE_PRESETS` (13 names) + `DateRangePreset`, and `DATE_RANGE_DEFAULT_RANGES` (presets + `custom`) + `DateRangeDefaultRange`, in packages/spec/src/ui/dashboard.zod.ts. Shape copied from `DATE_MACRO_TOKENS`: `as const` array + `(typeof X)[number]` alias. `dateRange.defaultRange` now reads the second list, so its accepted set is unchanged member-for-member (asserted by a test). 2. `GlobalFilterSchema.superRefine` — on `type: 'date'`, a declared `defaultValue` must be a preset name, an ISO date, or a known date-macro token. The macro arm calls `isDateMacroToken`/`DATE_MACRO_WRAPPED_RE` rather than restating the grammar: one token vocabulary, no second dialect. The rejection quotes the offending value and lists all three legal spellings (strict gate + fixable text). Other filter types are untouched. PREMISE DIVERGENCE FOUND AND RESOLVED (no裁决 needed) The brief scoped the check to `type: date|dateRange`. Spec's `GlobalFilterSchema.type` enum has no `dateRange` member — it is ['text','select','date','number','lookup']. objectui's `DashboardFilterDef.type` does have `'dateRange'`, but `resolveDashboardFilterDefs` SYNTHESISES that def from `schema.dateRange` under the reserved name "dateRange"; the `globalFilters` loop only ever reads `f.type ?? 'text'`. So `dateRange` is an objectui-internal def type, not an authorable `globalFilters[].type`, and no enum member was added. The two halves of "date|dateRange" map to: `date` = the globalFilters entry (this superRefine), `dateRange` = the built-in, already an enum and now reading the shared constant. `custom` is deliberately NOT a preset: objectui's PRESET_RANGES has 13 keys, spec's old inline enum had those 13 + `custom`. `custom` names no window ("open the picker"), so it stays legal as `defaultRange` and is rejected as a bare filter default, which gives it no from/to to hand over. 存量 EVALUATION — no ADR-0087 conversion required Scanned the three example apps, content/docs, and packages/ for a date filter `defaultValue` carrying a misspelled preset name. ZERO hits. Reverse-check proving the scan was live (a known-good name must be findable): grepping `this_month` / `this_quarter` / `last_7_days` / `last_30_days` surfaced `defaultRange: 'this_month'` and `'this_quarter'` in content/docs/ui/dashboards.mdx plus its 14-row preset table — i.e. the scan does find preset names where they exist. The tree's ONLY date-filter default is packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts:158 `defaultValue: 'last_7_days'` — a VALID preset, unaffected. Verified by parsing the real shipped module through the new schema (not a fixture): parsed OK, defaultValue preserved as "last_7_days". Also pinned by a named test. `docs/notes/airtable-dashboard-analysis.mdx` has `defaultValue: 'this_quarter'` on a `type: 'select'` filter — a different surface, untouched by this rule. VERIFICATION — real readings - spec full suite: 343 files / 8819 tests passed - spec typecheck: tsc + scripts-typecheck + test-typecheck all clean - check:generated: 10/10 green (2 were stale — skill-refs, api-surface — and were regenerated with --fix, then re-checked green). api-surface delta is 4 pure ADDITIONS (2 const + 2 type), zero removals — no baseline debt (#4593). - check:spec-parsed-alias: OK (1443 bare / 749 pinned / 694 paired). No pin needed: the new types read `(typeof X)[number]`, not z.input/z.infer, so they are outside ADR-0122's population. - pnpm lint: clean - three examples `validate`: all REAL_EXIT=0 with "✓ Validation passed" (warnings present are pre-existing and unrelated — i18n section names, liveness, permissions, flow status) REVERSE VALIDATION (direction predicted BEFORE running) Predicted: neutralising the superRefine turns exactly 5 tests red (misspelled preset, `custom`, non-string, error-message, unknown-macro-token) and leaves the other 45 green. Measured: 5 failed / 45 passed, precisely those 5. Probe reverted; restored run 50/50 green, and the file greps clean of the probe. FOR PM — objectui 联动单 material Repo objectstack-ai/objectui @ 0cf8f0f, file packages/core/src/utils/dashboard-filters.ts: - line 73 `const PRESET_RANGES: Record<string, {from?,to?}>` — the 13 names with their date-macro bounds. Landing point: key it off the spec vocabulary so a spec-side addition becomes a compile error until bounds are supplied: `const PRESET_RANGES: Record<DateRangePreset, {from?: string; to?: string}> = {...}` - line 90 `export const DATE_RANGE_PRESETS = Object.keys(PRESET_RANGES)` becomes a re-export of spec's constant (same NAME, so no consumer churn), which also fixes its type: `string[]` today, a literal union after. - import: `import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui';` `@objectstack/spec/ui` is already objectui's most-used spec subpath (134 imports) and packages/core already imports from it; packages/core/package.json depends on `@objectstack/spec ^17.0.0-rc.5`, so the range covers this minor once published. - consumer: packages/plugin-dashboard/src/DashboardFilterBar.tsx:37,97 — display order now comes from spec; no code change expected. Nothing in objectui was modified by this commit. changeset: @objectstack/spec minor (new authorable validation surface). Docs: content/docs/ui/dashboards.mdx gains a "Date Filter Defaults" section and a pointer from the preset table to the source-of-truth constant. * chore(spec): regenerate api-surface/ui.json after merging origin/main (#4614) The merge driver defers generator-owned artifacts rather than text-merging them (AGENTS.md §11), so the merge commit carried this branch's pre-merge ui.json — which predates #4593's export-type backfill on main. Regenerated from the rebuilt dist so the file describes the MERGED source: main's 73-schema backfill (ActionType/PageComponentType/ReportType and friends reclassified const → type, plus the newly-named types) is restored alongside this branch's four DATE_RANGE_* additions. check:generated 10/10 green after regeneration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8pDLTt6UHXUYY9YE7T1cA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8d6f6e commit 04476e7

6 files changed

Lines changed: 316 additions & 1 deletion

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): make the dashboard date-range preset names a single vocabulary and check a date filter's `defaultValue` against it (#4614)
6+
7+
A dashboard's built-in `dateRange` validated its preset name and a
8+
`globalFilters` entry of `type: 'date'` did not, so the same typo was an
9+
author-time error on one surface and a silent wrong answer on the other.
10+
11+
`GlobalFilterSchema.defaultValue` is `string | number | boolean`, which makes a
12+
bare preset name the only spelling available for a date filter's default —
13+
and nothing checked it. An unrecognised name cannot be lifted to a range, so it
14+
fell through to "a bare string date means equality on that day" and reached the
15+
backend as `created_at = 'last_7_dayz'`: a condition no row matches, answered
16+
`200 OK` with a zero. Every tile read `0` while the filter bar showed
17+
"All time", so the dashboard looked deliberately empty rather than
18+
misconfigured — the failure mode that costs the most time to diagnose, and the
19+
one an AI author reads as a correct answer and builds on.
20+
21+
- **`DATE_RANGE_PRESETS`** (+ the `DateRangePreset` type) is new in
22+
`@objectstack/spec/ui` and is now the vocabulary's single source of truth.
23+
The thirteen names existed three times before this: inline in
24+
`dateRange.defaultRange`, as `PRESET_RANGES` in objectui's
25+
`dashboard-filters` (the module that maps each name to its date-macro
26+
bounds), and as a hand-written table in the dashboard docs.
27+
- **`DATE_RANGE_DEFAULT_RANGES`** (+ `DateRangeDefaultRange`) is the presets
28+
plus the `custom` sentinel, and is what `dateRange.defaultRange` now reads.
29+
`custom` is deliberately not a preset — it names no window, it opens the
30+
picker — so it stays legal there and is rejected as a bare filter default,
31+
which has no `from`/`to` for it to hand over. `defaultRange`'s accepted set
32+
is otherwise unchanged by the extraction, and a test asserts that member for
33+
member.
34+
- **`GlobalFilterSchema` gained a `superRefine`**: on `type: 'date'`, a
35+
declared `defaultValue` must be a preset name, an ISO date (`2026-01-15`,
36+
optionally with an instant), or a known date-macro token (`{today}`,
37+
`{30_days_ago}`). The macro half asks `isDateMacroToken` rather than
38+
restating its grammar, so there is one token vocabulary and no second dialect
39+
to drift. The rejection quotes the offending value back and lists all three
40+
legal spellings, because a dashboard with several date filters otherwise
41+
gives no clue which one is wrong. Every other filter type is untouched — a
42+
`select` filter's values are the author's own vocabulary.
43+
44+
**Existing metadata is unaffected.** The tree's only date-filter default is
45+
`system_overview.dashboard.ts`'s `last_7_days`, which is a valid preset and is
46+
pinned by a test; a corpus scan of the three example apps and the docs found no
47+
misspelled preset name, so no ADR-0087 conversion is required. The accepted set
48+
is a strict superset of what objectui's renderer resolves today, so no
49+
declaration that used to render can stop parsing.
50+
51+
The new exports and the `.describe()` on `defaultRange` are additive; the only
52+
authorable behaviour that changes is that a value which previously parsed and
53+
then silently resolved to nothing is now an author-time error.

content/docs/ui/dashboards.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ dateRange: {
341341
| `last_90_days` | Rolling 90 days |
342342
| `custom` | User-defined range |
343343

344+
The thirteen named ranges are published as `DATE_RANGE_PRESETS` in
345+
`@objectstack/spec/ui` — the vocabulary's single source of truth. `custom` is not
346+
one of them: it selects no window, it opens the picker.
347+
344348
## Global Filters
345349

346350
Add interactive filter controls that apply to all widgets:
@@ -358,6 +362,35 @@ under as a dashboard-level variable (readable in widget expressions as
358362
`page.<name>`) and the key widgets reference in `filterBindings`. It defaults
359363
to `field`; the name `dateRange` is reserved for the built-in date range.
360364

365+
### Date Filter Defaults
366+
367+
A `type: 'date'` filter's `defaultValue` must be a value the dashboard can
368+
actually resolve to a window. Three spellings qualify:
369+
370+
| Spelling | Example | Means |
371+
| :--- | :--- | :--- |
372+
| Preset name | `last_7_days` | The preset range of that name (table above) |
373+
| ISO date | `2026-01-15`, `2026-01-15T08:30:00Z` | That day exactly |
374+
| Date macro | `{today}`, `{30_days_ago}` | Resolved at query time |
375+
376+
{/* os:check */}
377+
```typescript
378+
globalFilters: [
379+
{ field: 'created_at', label: 'Date Range', type: 'date', defaultValue: 'last_7_days' },
380+
]
381+
```
382+
383+
Anything else is rejected at author time. This matters because the failure it
384+
replaces was silent: an unrecognised name cannot be lifted to a range, so it fell
385+
through to "a bare string date means equality on that day" and produced
386+
`created_at = 'last_7_dayz'` — a condition no row matches, which the backend
387+
answers `200 OK` with a zero. Every tile read 0 while the filter bar showed
388+
"All time", so the dashboard looked deliberately empty rather than misconfigured.
389+
390+
`custom` is **not** accepted here. It is a `dateRange.defaultRange` sentinel
391+
meaning "open the picker with no preset applied", and a bare filter value gives
392+
it no `from`/`to` to hand over.
393+
361394
### Per-Widget Filter Bindings
362395

363396
By default a filter applies to its own `field` on every widget (the date

packages/spec/api-surface/ui.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@
102102
"ComponentProps (type)",
103103
"ComponentPropsInput (type)",
104104
"ComponentPropsMap (const)",
105+
"DATE_RANGE_DEFAULT_RANGES (const)",
106+
"DATE_RANGE_PRESETS (const)",
105107
"Dashboard (type)",
106108
"DashboardHeader (type)",
107109
"DashboardHeaderAction (type)",
@@ -124,6 +126,8 @@
124126
"DatasetMeasure (type)",
125127
"DatasetMeasureSchema (const)",
126128
"DatasetSchema (const)",
129+
"DateRangeDefaultRange (type)",
130+
"DateRangePreset (type)",
127131
"DerivedMeasureOp (const)",
128132
"DerivedMeasureOpValue (type)",
129133
"ElementButtonPropsSchema (const)",

packages/spec/src/ui/dashboard.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
WidgetActionTypeSchema,
1212
GlobalFilterSchema,
1313
GlobalFilterOptionsFromSchema,
14+
DATE_RANGE_PRESETS,
15+
DATE_RANGE_DEFAULT_RANGES,
1416
} from './dashboard.zod';
1517

1618
/**
@@ -293,6 +295,124 @@ describe('Dashboard presentation sub-schemas', () => {
293295
});
294296
});
295297

298+
/**
299+
* #4614 — the date-range preset vocabulary, and the `defaultValue` check it
300+
* makes possible.
301+
*
302+
* Before this, `dateRange.defaultRange` was an enum (a typo there was already an
303+
* author-time error) while a `globalFilters` entry of `type: 'date'` accepted
304+
* any `string | number | boolean` unchecked. A misspelled preset therefore
305+
* failed silently and late — the renderer cannot lift it to a range, falls
306+
* through to "a bare string means equality on that day", and emits a condition
307+
* no row matches. The dashboard reads 0 everywhere and looks deliberately empty.
308+
*/
309+
describe('date-range preset vocabulary (#4614)', () => {
310+
const dateFilter = (defaultValue: unknown) =>
311+
GlobalFilterSchema.parse({ field: 'created_at', type: 'date', defaultValue });
312+
313+
it('is a closed vocabulary — counts pinned so a silent add/drop is loud', () => {
314+
// ADR-0122 receipt convention: assert the count, not just membership, so a
315+
// name appearing or vanishing cannot ride in under a passing test.
316+
expect(DATE_RANGE_PRESETS).toHaveLength(13);
317+
expect(DATE_RANGE_DEFAULT_RANGES).toHaveLength(14);
318+
319+
// `custom` names no window — it is a `defaultRange` sentinel only.
320+
expect(DATE_RANGE_PRESETS).not.toContain('custom');
321+
expect(DATE_RANGE_DEFAULT_RANGES).toContain('custom');
322+
expect(new Set(DATE_RANGE_PRESETS).size).toBe(DATE_RANGE_PRESETS.length);
323+
});
324+
325+
it('`dateRange.defaultRange` accepts exactly what it accepted before the extraction', () => {
326+
// The vocabulary moved out of this enum into a shared constant; this is the
327+
// assertion that the move was value-preserving.
328+
for (const range of DATE_RANGE_DEFAULT_RANGES) {
329+
const d = DashboardSchema.parse({
330+
name: 'dash_x', label: 'D', dateRange: { field: 'created_at', defaultRange: range },
331+
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'] }],
332+
});
333+
expect(d.dateRange?.defaultRange).toBe(range);
334+
}
335+
expect(() => DashboardSchema.parse({
336+
name: 'dash_x', label: 'D', dateRange: { defaultRange: 'last_7_dayz' },
337+
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'] }],
338+
})).toThrow();
339+
});
340+
341+
it('accepts every preset name as a date filter default', () => {
342+
for (const preset of DATE_RANGE_PRESETS) {
343+
expect(dateFilter(preset).defaultValue).toBe(preset);
344+
}
345+
});
346+
347+
it('accepts an ISO date — day, or day with an instant', () => {
348+
expect(dateFilter('2026-01-15').defaultValue).toBe('2026-01-15');
349+
expect(dateFilter('2026-01-15T08:30:00Z').defaultValue).toBe('2026-01-15T08:30:00Z');
350+
});
351+
352+
it('accepts a KNOWN date-macro token, wrapped either way', () => {
353+
expect(dateFilter('{today}').defaultValue).toBe('{today}');
354+
expect(dateFilter('${30_days_ago}').defaultValue).toBe('${30_days_ago}');
355+
// The macro vocabulary is asked, not restated — an unknown token is exactly
356+
// the typo this guard exists to catch.
357+
expect(() => dateFilter('{yesteryear}')).toThrow();
358+
});
359+
360+
it('REJECTS a misspelled preset name', () => {
361+
// The regression this issue is about. `last_7_dayz` reaches a query as
362+
// `created_at = 'last_7_dayz'` and the backend answers 200 OK with a zero.
363+
expect(() => dateFilter('last_7_dayz')).toThrow();
364+
expect(() => dateFilter('last-7-days')).toThrow();
365+
expect(() => dateFilter('Last 7 Days')).toThrow();
366+
expect(() => dateFilter('lastweek')).toThrow();
367+
});
368+
369+
it('REJECTS `custom` — a sentinel with no bounds of its own', () => {
370+
// Legal as `dateRange.defaultRange`, meaningless as a bare filter value:
371+
// there is no from/to for it to hand over.
372+
expect(() => dateFilter('custom')).toThrow();
373+
});
374+
375+
it('REJECTS a non-string default on a date filter', () => {
376+
expect(() => dateFilter(0)).toThrow();
377+
expect(() => dateFilter(true)).toThrow();
378+
});
379+
380+
it('names the offending value and all three legal spellings', () => {
381+
// House rule: a strict gate ships with text an author can act on. Without
382+
// the value quoted back, a dashboard with several date filters gives no clue
383+
// WHICH one is wrong.
384+
let message = '';
385+
try { dateFilter('last_7_dayz'); } catch (e) { message = String(e); }
386+
387+
expect(message).toContain('last_7_dayz');
388+
expect(message).toContain('last_7_days'); // the preset list, i.e. the fix
389+
expect(message).toContain('2026-01-15'); // the ISO form
390+
expect(message).toContain('{30_days_ago}'); // the macro form
391+
});
392+
393+
it('leaves every OTHER filter type untouched', () => {
394+
// A `select` filter's options are the author's own vocabulary — a value that
395+
// happens to look like a preset name is none of this check's business.
396+
expect(GlobalFilterSchema.parse({
397+
field: 'time_period', type: 'select', defaultValue: 'this_quarter',
398+
}).defaultValue).toBe('this_quarter');
399+
expect(GlobalFilterSchema.parse({
400+
field: 'period', type: 'select', defaultValue: 'last_7_dayz',
401+
}).defaultValue).toBe('last_7_dayz');
402+
expect(GlobalFilterSchema.parse({ field: 'q', type: 'text', defaultValue: 'today' }).defaultValue).toBe('today');
403+
expect(GlobalFilterSchema.parse({ field: 'n', type: 'number', defaultValue: 7 }).defaultValue).toBe(7);
404+
// A date filter with no default is still perfectly legal.
405+
expect(GlobalFilterSchema.parse({ field: 'created_at', type: 'date' }).defaultValue).toBeUndefined();
406+
});
407+
408+
it('does not break the shipped System Overview dashboard', () => {
409+
// packages/platform-objects/.../system_overview.dashboard.ts — the only
410+
// date-filter default in the tree, and a legal one. Pinned here so the
411+
// strictness cannot regress a real, shipped declaration.
412+
expect(dateFilter('last_7_days').defaultValue).toBe('last_7_days');
413+
});
414+
});
415+
296416
// ============================================================================
297417
// [#4876] `widgets[].responsive` is RETIRED — mirrors #3896's `view.responsive`
298418
// ============================================================================

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { strictObject } from '../shared/strict-object';
77
import { FilterConditionSchema } from '../data/filter.zod';
88
import { DateGranularity } from '../data/query.zod';
9+
import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod';
910
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
1011
import { ActionType } from './action.zod';
1112
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
@@ -634,6 +635,69 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
634635
// hatch for renderer-specific extras.
635636
.strict());
636637

638+
/**
639+
* Dashboard date-range presets — the named windows a dashboard date filter may
640+
* select, in the display order the filter bar offers them.
641+
*
642+
* **This is the vocabulary's single source of truth (#4614).** It used to exist
643+
* three times: inline in `dateRange.defaultRange` below, as `PRESET_RANGES` in
644+
* objectui's `dashboard-filters` (the module that maps each name to its
645+
* date-macro bounds), and as a hand-written table in
646+
* `content/docs/ui/dashboards.mdx`. Three copies of one enum drift in the
647+
* direction nobody notices: a name the renderer knows but the schema does not is
648+
* rejected from metadata that would have rendered, and a name the schema knows
649+
* but the renderer does not validates clean and then resolves to nothing.
650+
*
651+
* Each preset resolves to a pair of date-macro token bounds at query time (see
652+
* `DATE_MACRO_TOKENS` in `../data/date-macros.zod`). That is why the two
653+
* vocabularies live one import apart and neither restates the other's grammar.
654+
*/
655+
export const DATE_RANGE_PRESETS = [
656+
'today', 'yesterday',
657+
'this_week', 'last_week',
658+
'this_month', 'last_month',
659+
'this_quarter', 'last_quarter',
660+
'this_year', 'last_year',
661+
'last_7_days', 'last_30_days', 'last_90_days',
662+
] as const;
663+
664+
export type DateRangePreset = (typeof DATE_RANGE_PRESETS)[number];
665+
666+
/**
667+
* What `dashboard.dateRange.defaultRange` accepts: every preset, plus the
668+
* `custom` sentinel.
669+
*
670+
* `custom` is deliberately NOT a member of {@link DATE_RANGE_PRESETS} — it names
671+
* no window. It means "open the from/to picker with no preset applied", so it
672+
* carries no bounds and resolves to no range. That distinction is load-bearing
673+
* in both directions: `defaultRange: 'custom'` is a legitimate dashboard
674+
* declaration, while a `globalFilters` date filter defaulting to `'custom'` is
675+
* not — a bare filter value gives the sentinel no from/to to hand over. The
676+
* `superRefine` on {@link GlobalFilterSchema} therefore checks the presets
677+
* alone, and this list exists so `defaultRange`'s accepted set is left exactly
678+
* as it was by the extraction.
679+
*/
680+
export const DATE_RANGE_DEFAULT_RANGES = [...DATE_RANGE_PRESETS, 'custom'] as const;
681+
682+
export type DateRangeDefaultRange = (typeof DATE_RANGE_DEFAULT_RANGES)[number];
683+
684+
/**
685+
* ISO calendar date, optionally carrying a time part — `2026-01-15`,
686+
* `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse`, which also
687+
* accepts locale prose (`March 5, 2026`) and bare years (`2026`); neither is a
688+
* value a backend compares a date column against usefully.
689+
*
690+
* Mirrors the accepted set of objectui's `isUsableDateString`, so this schema
691+
* never rejects a spelling the renderer resolves correctly.
692+
*/
693+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;
694+
695+
/** True for `'{today}'` / `'${30_days_ago}'` — a wrapped, KNOWN macro token. */
696+
function isDateMacroPlaceholder(value: string): boolean {
697+
const m = value.match(DATE_MACRO_WRAPPED_RE);
698+
return !!m && isDateMacroToken(m[1]);
699+
}
700+
637701
/**
638702
* Dynamic options binding for global filters.
639703
* Allows dropdown options to be fetched from an object at runtime.
@@ -709,6 +773,46 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({
709773

710774
/** Widget IDs to apply this filter to (when scope is widget) */
711775
targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to'),
776+
}).superRefine((filter, ctx) => {
777+
// #4614 — a date filter's `defaultValue` is checked against the vocabulary
778+
// that can actually resolve it.
779+
//
780+
// Why this filter type and not the built-in `dateRange`: `dateRange`'s
781+
// `defaultRange` has always been an enum, so a typo there was already an
782+
// author-time error. A `globalFilters` entry of `type: 'date'` was the
783+
// asymmetric half — `defaultValue` is `string | number | boolean`, so a bare
784+
// preset name is the ONLY spelling available, and nothing checked it. An
785+
// unknown name then failed SILENTLY and late: the renderer cannot lift it to a
786+
// range, falls through to "a bare string date means equality on that day", and
787+
// emits `created_at = 'last_7_dayz'` — a condition no row matches, which the
788+
// backend answers `200 OK` with a zero. Every tile reads 0 and the filter bar
789+
// shows "All time", so the dashboard looks deliberately empty rather than
790+
// misconfigured. That is the failure this moves to parse time.
791+
if (filter.type !== 'date' || filter.defaultValue === undefined) return;
792+
793+
const value = filter.defaultValue;
794+
if (
795+
typeof value === 'string' &&
796+
((DATE_RANGE_PRESETS as readonly string[]).includes(value) ||
797+
isDateMacroPlaceholder(value) ||
798+
ISO_DATE_RE.test(value))
799+
) {
800+
return;
801+
}
802+
803+
ctx.addIssue({
804+
code: 'custom',
805+
path: ['defaultValue'],
806+
message:
807+
`${JSON.stringify(value)} is not a value a \`type: 'date'\` filter can resolve. ` +
808+
'Use one of three spellings: a preset name (' +
809+
DATE_RANGE_PRESETS.join(', ') +
810+
'); an ISO date such as `2026-01-15` or `2026-01-15T08:30:00Z`, meaning that ' +
811+
'day exactly; or a date-macro token such as `{today}` or `{30_days_ago}` ' +
812+
'(the full vocabulary is `DATE_MACRO_TOKENS` in `@objectstack/spec/data`). ' +
813+
"`custom` is not among them — it is a `dateRange.defaultRange` sentinel that " +
814+
'carries no bounds of its own.',
815+
});
712816
}));
713817

714818
/**
@@ -790,7 +894,7 @@ export const DashboardSchema = lazySchema(() => strictObject({
790894
aliases: { dateField: 'field', fieldName: 'field', preset: 'defaultRange', range: 'defaultRange', default: 'defaultRange', allowCustom: 'allowCustomRange', custom: 'allowCustomRange' },
791895
}, {
792896
field: z.string().optional().describe('Default date field name for time-based filtering'),
793-
defaultRange: z.enum(['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month', 'this_quarter', 'last_quarter', 'this_year', 'last_year', 'last_7_days', 'last_30_days', 'last_90_days', 'custom']).default('this_month').describe('Default date range preset'),
897+
defaultRange: z.enum(DATE_RANGE_DEFAULT_RANGES).default('this_month').describe('Default date range preset'),
794898
allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'),
795899
}).optional().describe('Global dashboard date range filter configuration'),
796900

skills/objectstack-ui/references/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle.
2323

2424
## Transitive dependencies
2525

26+
- `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes
2627
- `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Activity-timeline UI config enums.
2728
- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum
2829
- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification

0 commit comments

Comments
 (0)