From 84f0791ae781dc5c7ee5bab222a8f413e966a386 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:20:28 +0000 Subject: [PATCH 1/2] feat(spec): make the dashboard date-range preset names one vocabulary and check a date filter's defaultValue against it (#4614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — 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 = {...}` - 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. --- .../dashboard-date-filter-preset-vocab.md | 53 ++++++++ content/docs/ui/dashboards.mdx | 33 +++++ packages/spec/api-surface/ui.json | 4 + packages/spec/src/ui/dashboard.test.ts | 120 ++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 106 +++++++++++++++- skills/objectstack-ui/references/_index.md | 1 + 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 .changeset/dashboard-date-filter-preset-vocab.md diff --git a/.changeset/dashboard-date-filter-preset-vocab.md b/.changeset/dashboard-date-filter-preset-vocab.md new file mode 100644 index 0000000000..0cd9a149d0 --- /dev/null +++ b/.changeset/dashboard-date-filter-preset-vocab.md @@ -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. diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 2c681160c5..08164f6dfd 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -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: @@ -358,6 +362,35 @@ under as a dashboard-level variable (readable in widget expressions as `page.`) 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 diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index c7dd6566e4..f6debfa47e 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -99,6 +99,8 @@ "ComponentProps (type)", "ComponentPropsInput (type)", "ComponentPropsMap (const)", + "DATE_RANGE_DEFAULT_RANGES (const)", + "DATE_RANGE_PRESETS (const)", "Dashboard (type)", "DashboardHeader (type)", "DashboardHeaderAction (type)", @@ -121,6 +123,8 @@ "DatasetMeasure (type)", "DatasetMeasureSchema (const)", "DatasetSchema (const)", + "DateRangeDefaultRange (type)", + "DateRangePreset (type)", "DerivedMeasureOp (const)", "DerivedMeasureOpValue (type)", "ElementButtonPropsSchema (const)", diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 395fa84b5a..b5cf9ce080 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -11,6 +11,8 @@ import { WidgetActionTypeSchema, GlobalFilterSchema, GlobalFilterOptionsFromSchema, + DATE_RANGE_PRESETS, + DATE_RANGE_DEFAULT_RANGES, } from './dashboard.zod'; /** @@ -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` // ============================================================================ diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 55c725c472..8eaeb01491 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -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'; @@ -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. @@ -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.', + }); })); /** @@ -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'), diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index a4b50dd540..5edc00bb54 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -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 From 7e23415c64928a42e10910753d1bd8ad66f9d8d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:08:16 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface/ui.json after merging origin/main (#4614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01F8pDLTt6UHXUYY9YE7T1cA --- packages/spec/api-surface/ui.json | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index f6debfa47e..3974530a23 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -25,7 +25,7 @@ "ActionSchema (const)", "ActionSession (type)", "ActionSessionSchema (const)", - "ActionType (const)", + "ActionType (type)", "AddRecordConfig (type)", "AddRecordConfigParsed (type)", "AddRecordConfigSchema (const)", @@ -44,8 +44,10 @@ "AriaPropsSchema (const)", "BorderRadius (type)", "BorderRadiusSchema (const)", + "BreakpointColumnMap (type)", "BreakpointColumnMapSchema (const)", "BreakpointName (type)", + "BreakpointOrderMap (type)", "BreakpointOrderMapSchema (const)", "BulkActionDef (type)", "BulkActionDefParsed (type)", @@ -57,6 +59,7 @@ "BulkActionParam (type)", "BulkActionParamSchema (const)", "CHART_AGGREGATE_COMPARISON_SUFFIX (const)", + "CalendarConfig (type)", "CalendarConfigSchema (const)", "ChartAggregate (type)", "ChartAggregateFunction (type)", @@ -134,7 +137,9 @@ "ElementFormPropsSchema (const)", "ElementImagePropsSchema (const)", "ElementMetadataViewerPropsSchema (const)", + "ElementNumberProps (type)", "ElementNumberPropsSchema (const)", + "ElementRecordPickerProps (type)", "ElementRecordPickerPropsSchema (const)", "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", @@ -157,7 +162,9 @@ "GalleryConfig (type)", "GalleryConfigParsed (type)", "GalleryConfigSchema (const)", + "GanttConfig (type)", "GanttConfigSchema (const)", + "GanttQuickFilter (type)", "GanttQuickFilterSchema (const)", "GlobalFilter (type)", "GlobalFilterOptionsFrom (type)", @@ -186,6 +193,7 @@ "InterfacePageConfigSchema (const)", "JoinedReportBlock (type)", "JoinedReportBlockSchema (const)", + "KanbanConfig (type)", "KanbanConfigSchema (const)", "ListChartConfig (type)", "ListChartConfigParsed (type)", @@ -208,6 +216,7 @@ "NavigationItem (type)", "NavigationItemInput (type)", "NavigationItemSchema (const)", + "NavigationMode (type)", "NavigationModeSchema (const)", "NotificationPosition (type)", "NotificationPositionSchema (const)", @@ -227,7 +236,7 @@ "PageComponent (type)", "PageComponentParsed (type)", "PageComponentSchema (const)", - "PageComponentType (const)", + "PageComponentType (type)", "PageContainerProps (type)", "PageHeaderProps (const)", "PageNavItem (type)", @@ -258,9 +267,9 @@ "RecordActivityProps (const)", "RecordChatterProps (const)", "RecordDetailsProps (const)", - "RecordHighlightsField (const)", + "RecordHighlightsField (type)", "RecordHighlightsProps (const)", - "RecordPathProps (const)", + "RecordPathProps (type)", "RecordRelatedListProps (const)", "Report (type)", "ReportChart (type)", @@ -274,7 +283,7 @@ "ReportSort (type)", "ReportSortParsed (type)", "ReportSortSchema (const)", - "ReportType (const)", + "ReportType (type)", "ResolvedActionParam (interface)", "ResponsiveConfig (type)", "ResponsiveConfigSchema (const)", @@ -302,6 +311,7 @@ "TimelineConfig (type)", "TimelineConfigParsed (type)", "TimelineConfigSchema (const)", + "TreeConfig (type)", "TreeConfigSchema (const)", "Typography (type)", "TypographySchema (const)", @@ -329,6 +339,7 @@ "ViewFilterRuleParsed (type)", "ViewFilterRuleSchema (const)", "ViewItem (type)", + "ViewItemName (type)", "ViewItemNameSchema (const)", "ViewItemSchema (const)", "ViewItemWire (type)",