Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/dashboard-date-filter-preset-vocab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/spec": minor
---

feat(spec): make the dashboard date-range preset names a single 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", so the dashboard looked deliberately empty rather than
misconfigured — the failure mode that costs the most time to diagnose, and the
one an AI author reads as a correct answer and builds on.

- **`DATE_RANGE_PRESETS`** (+ the `DateRangePreset` type) is new in
`@objectstack/spec/ui` and is now the vocabulary's single source of truth.
The thirteen names existed three times before this: inline in
`dateRange.defaultRange`, as `PRESET_RANGES` in objectui's
`dashboard-filters` (the module that maps each name to its date-macro
bounds), and as a hand-written table in the dashboard docs.
- **`DATE_RANGE_DEFAULT_RANGES`** (+ `DateRangeDefaultRange`) is the presets
plus the `custom` sentinel, and is what `dateRange.defaultRange` now reads.
`custom` is deliberately not a preset — it names no window, it opens the
picker — so it stays legal there and is rejected as a bare filter default,
which has no `from`/`to` for it to hand over. `defaultRange`'s accepted set
is otherwise unchanged by the extraction, and a test asserts that member for
member.
- **`GlobalFilterSchema` gained a `superRefine`**: on `type: 'date'`, a
declared `defaultValue` must be a preset name, an ISO date (`2026-01-15`,
optionally with an instant), or a known date-macro token (`{today}`,
`{30_days_ago}`). The macro half asks `isDateMacroToken` rather than
restating its grammar, so there is one token vocabulary and no second dialect
to drift. The rejection quotes the offending value back and lists all three
legal spellings, because a dashboard with several date filters otherwise
gives no clue which one is wrong. Every other filter type is untouched — a
`select` filter's values are the author's own vocabulary.

**Existing metadata is unaffected.** The tree's only date-filter default is
`system_overview.dashboard.ts`'s `last_7_days`, which is a valid preset and is
pinned by a test; a corpus scan of the three example apps and the docs found no
misspelled preset name, so no ADR-0087 conversion is required. The accepted set
is a strict superset of what objectui's renderer resolves today, so no
declaration that used to render can stop parsing.

The new exports and the `.describe()` on `defaultRange` are additive; the only
authorable behaviour that changes is that a value which previously parsed and
then silently resolved to nothing is now an author-time error.
33 changes: 33 additions & 0 deletions content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ dateRange: {
| `last_90_days` | Rolling 90 days |
| `custom` | User-defined range |

The thirteen named ranges are published as `DATE_RANGE_PRESETS` in
`@objectstack/spec/ui` — the vocabulary's single source of truth. `custom` is not
one of them: it selects no window, it opens the picker.

## Global Filters

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

### Date Filter Defaults

A `type: 'date'` filter's `defaultValue` must be a value the dashboard can
actually resolve to a window. Three spellings qualify:

| Spelling | Example | Means |
| :--- | :--- | :--- |
| Preset name | `last_7_days` | The preset range of that name (table above) |
| ISO date | `2026-01-15`, `2026-01-15T08:30:00Z` | That day exactly |
| Date macro | `{today}`, `{30_days_ago}` | Resolved at query time |

{/* os:check */}
```typescript
globalFilters: [
{ field: 'created_at', label: 'Date Range', type: 'date', defaultValue: 'last_7_days' },
]
```

Anything else is rejected at author time. This matters because the failure it
replaces was silent: an unrecognised name cannot be lifted to a range, so it fell
through to "a bare string date means equality on that day" and produced
`created_at = 'last_7_dayz'` — a condition no row matches, which the backend
answers `200 OK` with a zero. Every tile read 0 while the filter bar showed
"All time", so the dashboard looked deliberately empty rather than misconfigured.

`custom` is **not** accepted here. It is a `dateRange.defaultRange` sentinel
meaning "open the picker with no preset applied", and a bare filter value gives
it no `from`/`to` to hand over.

### Per-Widget Filter Bindings

By default a filter applies to its own `field` on every widget (the date
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
"ComponentProps (type)",
"ComponentPropsInput (type)",
"ComponentPropsMap (const)",
"DATE_RANGE_DEFAULT_RANGES (const)",
"DATE_RANGE_PRESETS (const)",
"Dashboard (type)",
"DashboardHeader (type)",
"DashboardHeaderAction (type)",
Expand All @@ -124,6 +126,8 @@
"DatasetMeasure (type)",
"DatasetMeasureSchema (const)",
"DatasetSchema (const)",
"DateRangeDefaultRange (type)",
"DateRangePreset (type)",
"DerivedMeasureOp (const)",
"DerivedMeasureOpValue (type)",
"ElementButtonPropsSchema (const)",
Expand Down
120 changes: 120 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
WidgetActionTypeSchema,
GlobalFilterSchema,
GlobalFilterOptionsFromSchema,
DATE_RANGE_PRESETS,
DATE_RANGE_DEFAULT_RANGES,
} from './dashboard.zod';

/**
Expand Down Expand Up @@ -293,6 +295,124 @@ describe('Dashboard presentation sub-schemas', () => {
});
});

/**
* #4614 — the date-range preset vocabulary, and the `defaultValue` check it
* makes possible.
*
* Before this, `dateRange.defaultRange` was an enum (a typo there was already an
* author-time error) while a `globalFilters` entry of `type: 'date'` accepted
* any `string | number | boolean` unchecked. A misspelled preset therefore
* failed silently and late — the renderer cannot lift it to a range, falls
* through to "a bare string means equality on that day", and emits a condition
* no row matches. The dashboard reads 0 everywhere and looks deliberately empty.
*/
describe('date-range preset vocabulary (#4614)', () => {
const dateFilter = (defaultValue: unknown) =>
GlobalFilterSchema.parse({ field: 'created_at', type: 'date', defaultValue });

it('is a closed vocabulary — counts pinned so a silent add/drop is loud', () => {
// ADR-0122 receipt convention: assert the count, not just membership, so a
// name appearing or vanishing cannot ride in under a passing test.
expect(DATE_RANGE_PRESETS).toHaveLength(13);
expect(DATE_RANGE_DEFAULT_RANGES).toHaveLength(14);

// `custom` names no window — it is a `defaultRange` sentinel only.
expect(DATE_RANGE_PRESETS).not.toContain('custom');
expect(DATE_RANGE_DEFAULT_RANGES).toContain('custom');
expect(new Set(DATE_RANGE_PRESETS).size).toBe(DATE_RANGE_PRESETS.length);
});

it('`dateRange.defaultRange` accepts exactly what it accepted before the extraction', () => {
// The vocabulary moved out of this enum into a shared constant; this is the
// assertion that the move was value-preserving.
for (const range of DATE_RANGE_DEFAULT_RANGES) {
const d = DashboardSchema.parse({
name: 'dash_x', label: 'D', dateRange: { field: 'created_at', defaultRange: range },
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'] }],
});
expect(d.dateRange?.defaultRange).toBe(range);
}
expect(() => DashboardSchema.parse({
name: 'dash_x', label: 'D', dateRange: { defaultRange: 'last_7_dayz' },
widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'] }],
})).toThrow();
});

it('accepts every preset name as a date filter default', () => {
for (const preset of DATE_RANGE_PRESETS) {
expect(dateFilter(preset).defaultValue).toBe(preset);
}
});

it('accepts an ISO date — day, or day with an instant', () => {
expect(dateFilter('2026-01-15').defaultValue).toBe('2026-01-15');
expect(dateFilter('2026-01-15T08:30:00Z').defaultValue).toBe('2026-01-15T08:30:00Z');
});

it('accepts a KNOWN date-macro token, wrapped either way', () => {
expect(dateFilter('{today}').defaultValue).toBe('{today}');
expect(dateFilter('${30_days_ago}').defaultValue).toBe('${30_days_ago}');
// The macro vocabulary is asked, not restated — an unknown token is exactly
// the typo this guard exists to catch.
expect(() => dateFilter('{yesteryear}')).toThrow();
});

it('REJECTS a misspelled preset name', () => {
// The regression this issue is about. `last_7_dayz` reaches a query as
// `created_at = 'last_7_dayz'` and the backend answers 200 OK with a zero.
expect(() => dateFilter('last_7_dayz')).toThrow();
expect(() => dateFilter('last-7-days')).toThrow();
expect(() => dateFilter('Last 7 Days')).toThrow();
expect(() => dateFilter('lastweek')).toThrow();
});

it('REJECTS `custom` — a sentinel with no bounds of its own', () => {
// Legal as `dateRange.defaultRange`, meaningless as a bare filter value:
// there is no from/to for it to hand over.
expect(() => dateFilter('custom')).toThrow();
});

it('REJECTS a non-string default on a date filter', () => {
expect(() => dateFilter(0)).toThrow();
expect(() => dateFilter(true)).toThrow();
});

it('names the offending value and all three legal spellings', () => {
// House rule: a strict gate ships with text an author can act on. Without
// the value quoted back, a dashboard with several date filters gives no clue
// WHICH one is wrong.
let message = '';
try { dateFilter('last_7_dayz'); } catch (e) { message = String(e); }

expect(message).toContain('last_7_dayz');
expect(message).toContain('last_7_days'); // the preset list, i.e. the fix
expect(message).toContain('2026-01-15'); // the ISO form
expect(message).toContain('{30_days_ago}'); // the macro form
});

it('leaves every OTHER filter type untouched', () => {
// A `select` filter's options are the author's own vocabulary — a value that
// happens to look like a preset name is none of this check's business.
expect(GlobalFilterSchema.parse({
field: 'time_period', type: 'select', defaultValue: 'this_quarter',
}).defaultValue).toBe('this_quarter');
expect(GlobalFilterSchema.parse({
field: 'period', type: 'select', defaultValue: 'last_7_dayz',
}).defaultValue).toBe('last_7_dayz');
expect(GlobalFilterSchema.parse({ field: 'q', type: 'text', defaultValue: 'today' }).defaultValue).toBe('today');
expect(GlobalFilterSchema.parse({ field: 'n', type: 'number', defaultValue: 7 }).defaultValue).toBe(7);
// A date filter with no default is still perfectly legal.
expect(GlobalFilterSchema.parse({ field: 'created_at', type: 'date' }).defaultValue).toBeUndefined();
});

it('does not break the shipped System Overview dashboard', () => {
// packages/platform-objects/.../system_overview.dashboard.ts — the only
// date-filter default in the tree, and a legal one. Pinned here so the
// strictness cannot regress a real, shipped declaration.
expect(dateFilter('last_7_days').defaultValue).toBe('last_7_days');
});
});

// ============================================================================
// [#4876] `widgets[].responsive` is RETIRED — mirrors #3896's `view.responsive`
// ============================================================================
Expand Down
106 changes: 105 additions & 1 deletion packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { strictObject } from '../shared/strict-object';
import { FilterConditionSchema } from '../data/filter.zod';
import { DateGranularity } from '../data/query.zod';
import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod';
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
import { ActionType } from './action.zod';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
Expand Down Expand Up @@ -634,6 +635,69 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
// hatch for renderer-specific extras.
.strict());

/**
* Dashboard date-range presets — the named windows a dashboard date filter may
* select, in the display order the filter bar offers them.
*
* **This is the vocabulary's single source of truth (#4614).** It used to exist
* three times: inline in `dateRange.defaultRange` below, as `PRESET_RANGES` in
* objectui's `dashboard-filters` (the module that maps each name to its
* date-macro bounds), and as a hand-written table in
* `content/docs/ui/dashboards.mdx`. Three copies of one enum drift in the
* direction nobody notices: a name the renderer knows but the schema does not is
* rejected from metadata that would have rendered, and a name the schema knows
* but the renderer does not validates clean and then resolves to nothing.
*
* Each preset resolves to a pair of date-macro token bounds at query time (see
* `DATE_MACRO_TOKENS` in `../data/date-macros.zod`). That is why the two
* vocabularies live one import apart and neither restates the other's grammar.
*/
export const DATE_RANGE_PRESETS = [
'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',
] as const;

export type DateRangePreset = (typeof DATE_RANGE_PRESETS)[number];

/**
* What `dashboard.dateRange.defaultRange` accepts: every preset, plus the
* `custom` sentinel.
*
* `custom` is deliberately NOT a member of {@link DATE_RANGE_PRESETS} — it names
* no window. It means "open the from/to picker with no preset applied", so it
* carries no bounds and resolves to no range. That distinction is load-bearing
* in both directions: `defaultRange: 'custom'` is a legitimate dashboard
* declaration, while a `globalFilters` date filter defaulting to `'custom'` is
* not — a bare filter value gives the sentinel no from/to to hand over. The
* `superRefine` on {@link GlobalFilterSchema} therefore checks the presets
* alone, and this list exists so `defaultRange`'s accepted set is left exactly
* as it was by the extraction.
*/
export const DATE_RANGE_DEFAULT_RANGES = [...DATE_RANGE_PRESETS, 'custom'] as const;

export type DateRangeDefaultRange = (typeof DATE_RANGE_DEFAULT_RANGES)[number];

/**
* ISO calendar date, optionally carrying a time part — `2026-01-15`,
* `2026-01-15T08:30:00Z`. Deliberately narrower than `Date.parse`, which also
* accepts locale prose (`March 5, 2026`) and bare years (`2026`); neither is a
* value a backend compares a date column against usefully.
*
* Mirrors the accepted set of objectui's `isUsableDateString`, so this schema
* never rejects a spelling the renderer resolves correctly.
*/
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/;

/** True for `'{today}'` / `'${30_days_ago}'` — a wrapped, KNOWN macro token. */
function isDateMacroPlaceholder(value: string): boolean {
const m = value.match(DATE_MACRO_WRAPPED_RE);
return !!m && isDateMacroToken(m[1]);
}

/**
* Dynamic options binding for global filters.
* Allows dropdown options to be fetched from an object at runtime.
Expand Down Expand Up @@ -709,6 +773,46 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({

/** Widget IDs to apply this filter to (when scope is widget) */
targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to'),
}).superRefine((filter, ctx) => {
// #4614 — a date filter's `defaultValue` is checked against the vocabulary
// that can actually resolve it.
//
// Why this filter type and not the built-in `dateRange`: `dateRange`'s
// `defaultRange` has always been an enum, so a typo there was already an
// author-time error. A `globalFilters` entry of `type: 'date'` was the
// asymmetric half — `defaultValue` is `string | number | boolean`, so a bare
// preset name is the ONLY spelling available, and nothing checked it. An
// unknown name then failed SILENTLY and late: the renderer cannot lift it to a
// range, falls through to "a bare string date means equality on that day", and
// emits `created_at = 'last_7_dayz'` — a condition no row matches, which the
// backend answers `200 OK` with a zero. Every tile reads 0 and the filter bar
// shows "All time", so the dashboard looks deliberately empty rather than
// misconfigured. That is the failure this moves to parse time.
if (filter.type !== 'date' || filter.defaultValue === undefined) return;

const value = filter.defaultValue;
if (
typeof value === 'string' &&
((DATE_RANGE_PRESETS as readonly string[]).includes(value) ||
isDateMacroPlaceholder(value) ||
ISO_DATE_RE.test(value))
) {
return;
}

ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message:
`${JSON.stringify(value)} is not a value a \`type: 'date'\` filter can resolve. ` +
'Use one of three spellings: a preset name (' +
DATE_RANGE_PRESETS.join(', ') +
'); an ISO date such as `2026-01-15` or `2026-01-15T08:30:00Z`, meaning that ' +
'day exactly; or a date-macro token such as `{today}` or `{30_days_ago}` ' +
'(the full vocabulary is `DATE_MACRO_TOKENS` in `@objectstack/spec/data`). ' +
"`custom` is not among them — it is a `dateRange.defaultRange` sentinel that " +
'carries no bounds of its own.',
});
}));

/**
Expand Down Expand Up @@ -790,7 +894,7 @@ export const DashboardSchema = lazySchema(() => strictObject({
aliases: { dateField: 'field', fieldName: 'field', preset: 'defaultRange', range: 'defaultRange', default: 'defaultRange', allowCustom: 'allowCustomRange', custom: 'allowCustomRange' },
}, {
field: z.string().optional().describe('Default date field name for time-based filtering'),
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'),
defaultRange: z.enum(DATE_RANGE_DEFAULT_RANGES).default('this_month').describe('Default date range preset'),
allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'),
}).optional().describe('Global dashboard date range filter configuration'),

Expand Down
1 change: 1 addition & 0 deletions skills/objectstack-ui/references/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle.

## Transitive dependencies

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