diff --git a/.changeset/29116-dashboard-preset-windows-from-spec.md b/.changeset/29116-dashboard-preset-windows-from-spec.md new file mode 100644 index 00000000000..99dc46a6af0 --- /dev/null +++ b/.changeset/29116-dashboard-preset-windows-from-spec.md @@ -0,0 +1,14 @@ +--- +'@object-ui/core': patch +--- + +refactor(core): dashboard date presets read their bounds from the spec's window table + +Clause-②: no +Metadata-protocol: none + +`dashboard-filters` no longer keeps its own preset-to-bounds table. Each of the thirteen dashboard date presets now lowers to the window `@objectstack/spec` declares in `DATE_RANGE_PRESET_MACRO_WINDOWS`, so the preset list and its windows live in one place and cannot drift. + +The emitted condition spells the current period with the bare macro aliases the spec uses (`{month_start}` instead of `{current_month_start}`, and the same for week, quarter and year). Both spellings name the same day, so a dashboard returns the same rows, and a compare-to widget keeps the same comparison window and trend label. A stored dashboard that writes the `{current_*}` tokens itself, as a custom range or as a date default, still filters as before. + +Preset membership is now the spec's exact `isDateRangePresetName` check. A stored preset name that matched only an inherited object member (such as `constructor`) was dropped without a word; it is now dropped with the usual unknown-preset warning. diff --git a/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts b/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts new file mode 100644 index 00000000000..4a5a4e67568 --- /dev/null +++ b/ui/packages/core/src/utils/__tests__/dashboard-filters.presetWindows-29116.test.ts @@ -0,0 +1,199 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Dashboard date presets read the spec's window table, not a local copy. + * + * `dashboard-filters` used to keep its own preset → bounds table + * (`PRESET_RANGES`) holding the spec's thirteen windows in the `{current_*}` + * period spelling. It now lowers every preset through + * `DATE_RANGE_PRESET_MACRO_WINDOWS`, which spells the current period with the + * bare aliases (`{month_start}`). These pins hold three things: + * + * 1. every preset emits exactly the spec's window, so there is one table; + * 2. the new spelling resolves to the same calendar days as the retired one + * on every preset, on reference dates that straddle week, month, quarter + * and year boundaries, so the rows a dashboard returns are unchanged, and + * so is the comparison window and trend label a metric widget derives + * from the same condition; + * 3. a stored dashboard that carries the `{current_*}` spelling itself, as a + * custom range or as a bare macro default, still filters. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DATE_RANGE_PRESET_MACRO_WINDOWS } from '@objectstack/spec/data'; +import { + buildFilterCondition, + resolveDashboardFilterDefs, + resetDashboardFilterWarnings, + DATE_RANGE_FILTER_NAME, + DATE_RANGE_PRESETS, + type DashboardFilterDef, +} from '../dashboard-filters'; +import { resolveDateMacros } from '../date-macros'; +import { shiftFilterByCompareTo, compareToTrendLabelKey } from '../compare-to'; + +const dateDef: DashboardFilterDef = { + name: DATE_RANGE_FILTER_NAME, + field: 'created_at', + type: 'dateRange', +}; + +/** + * The retired local table, verbatim from `dashboard-filters.ts` at main + * 39951d2a — the behaviour this change must keep. + */ +const RETIRED_PRESET_RANGES: Record = { + today: { from: '{today}', to: '{today}' }, + yesterday: { from: '{yesterday}', to: '{yesterday}' }, + this_week: { from: '{current_week_start}', to: '{current_week_end}' }, + last_week: { from: '{last_week_start}', to: '{last_week_end}' }, + this_month: { from: '{current_month_start}', to: '{current_month_end}' }, + last_month: { from: '{last_month_start}', to: '{last_month_end}' }, + this_quarter: { from: '{current_quarter_start}', to: '{current_quarter_end}' }, + last_quarter: { from: '{last_quarter_start}', to: '{last_quarter_end}' }, + this_year: { from: '{current_year_start}', to: '{current_year_end}' }, + last_year: { from: '{last_year_start}', to: '{last_year_end}' }, + last_7_days: { from: '{7_days_ago}', to: '{today}' }, + last_30_days: { from: '{30_days_ago}', to: '{today}' }, + last_90_days: { from: '{90_days_ago}', to: '{today}' }, +}; + +/** + * Reference instants (local time, which is what `resolveDateMacros` reads): + * a mid-quarter Wednesday, a Sunday that closes a week, a Monday that opens + * one, the last day of a quarter, the first day of a year, and a leap day. + */ +const REFERENCE_NOWS = [ + new Date(2026, 6, 15, 10, 30), + new Date(2026, 6, 19, 23, 59), + new Date(2026, 6, 20, 0, 0), + new Date(2026, 8, 30, 12, 0), + new Date(2027, 0, 1, 0, 0), + new Date(2028, 1, 29, 8, 0), +]; + +describe('dashboard date presets lower through the spec window table', () => { + let warn: ReturnType; + beforeEach(() => { + resetDashboardFilterWarnings(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + it('covers the same thirteen presets the retired table did', () => { + expect([...DATE_RANGE_PRESETS].sort()).toEqual(Object.keys(RETIRED_PRESET_RANGES).sort()); + expect(DATE_RANGE_PRESETS).toHaveLength(13); + }); + + it.each([...DATE_RANGE_PRESETS])('emits the spec window for %s', (preset) => { + const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; + expect(buildFilterCondition(dateDef, { preset })).toEqual({ $gte: start, $lte: end }); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([...DATE_RANGE_PRESETS])( + '%s resolves to the same days under the retired {current_*} spelling', + (preset) => { + const retired = RETIRED_PRESET_RANGES[preset]; + const retiredCondition = { $gte: retired.from, $lte: retired.to }; + const condition = buildFilterCondition(dateDef, { preset }) as { $gte: string; $lte: string }; + for (const now of REFERENCE_NOWS) { + const resolved = resolveDateMacros(condition, now); + // A real date on both bounds, not a token passed through unresolved. + expect(resolved.$gte, `${preset} @ ${now.toString()}`).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(resolved.$lte, `${preset} @ ${now.toString()}`).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(resolved, `${preset} @ ${now.toString()}`).toEqual(resolveDateMacros(retiredCondition, now)); + } + }, + ); + + it.each([...DATE_RANGE_PRESETS])( + '%s keeps its previous-period window and trend label for a compareTo widget', + (preset) => { + const retired = RETIRED_PRESET_RANGES[preset]; + const retiredFilter = { created_at: { $gte: retired.from, $lte: retired.to } }; + const filter = { created_at: buildFilterCondition(dateDef, { preset }) }; + for (const now of REFERENCE_NOWS) { + for (const kind of ['previousPeriod', 'previousYear'] as const) { + expect(shiftFilterByCompareTo(filter, { kind }, now), `${preset} ${kind} @ ${now.toString()}`).toEqual( + shiftFilterByCompareTo(retiredFilter, { kind }, now), + ); + } + } + expect(compareToTrendLabelKey({ kind: 'previousPeriod' }, filter)).toBe( + compareToTrendLabelKey({ kind: 'previousPeriod' }, retiredFilter), + ); + }, + ); + + it('an unknown preset name still misses, prototype member names included', () => { + for (const preset of ['last_60_days', 'This_Month', 'constructor', 'toString', '__proto__']) { + warn.mockClear(); + expect(buildFilterCondition(dateDef, { preset }), preset).toBeUndefined(); + expect(warn, preset).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0]), preset).toContain(`unknown date range preset "${preset}"`); + } + }); + + it('a non-preset date default is not lifted to a preset, prototype member names included', () => { + for (const defaultValue of ['last_60_days', 'constructor', 'toString']) { + const [def] = resolveDashboardFilterDefs({ + globalFilters: [{ name: 'when', field: 'created_at', type: 'date', defaultValue }], + } as any); + expect(def.defaultValue, defaultValue).toBe(defaultValue); + } + const [def] = resolveDashboardFilterDefs({ + globalFilters: [{ name: 'when', field: 'created_at', type: 'date', defaultValue: 'this_quarter' }], + } as any); + expect(def.defaultValue).toEqual({ preset: 'this_quarter' }); + }); +}); + +describe('a stored dashboard written in the {current_*} spelling still filters', () => { + beforeEach(() => resetDashboardFilterWarnings()); + + it('a custom range bounded by {current_*} tokens selects the same days as the preset', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const stored = { from: '{current_month_start}', to: '{current_month_end}' }; + const condition = buildFilterCondition(dateDef, stored); + expect(condition).toEqual({ $gte: '{current_month_start}', $lte: '{current_month_end}' }); + for (const now of REFERENCE_NOWS) { + expect(resolveDateMacros(condition, now)).toEqual( + resolveDateMacros(buildFilterCondition(dateDef, { preset: 'this_month' }), now), + ); + } + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('a date global filter defaulting to a {current_*} token keeps it and passes it through', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [def] = resolveDashboardFilterDefs({ + globalFilters: [ + { name: 'opened', field: 'created_at', type: 'date', defaultValue: '{current_quarter_start}' }, + ], + } as any); + expect(def.defaultValue).toBe('{current_quarter_start}'); + const condition = buildFilterCondition(def, def.defaultValue); + expect(condition).toBe('{current_quarter_start}'); + const now = new Date(2026, 6, 15, 10, 30); + expect(resolveDateMacros(condition, now)).toBe('2026-07-01'); + expect(resolveDateMacros('{quarter_start}', now)).toBe('2026-07-01'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts index bb8f1bd5aff..e6c1adcc92b 100644 --- a/ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -100,8 +100,8 @@ describe('resolveDashboardFilterDefs', () => { }); // The filter it seeds is a real bound, not an empty value. expect(buildFilterCondition(defs[0], defs[0].defaultValue)).toEqual({ - $gte: '{current_month_start}', - $lte: '{current_month_end}', + $gte: '{month_start}', + $lte: '{month_end}', }); }); @@ -233,8 +233,8 @@ describe('buildFilterCondition', () => { $lte: '{today}', }); expect(buildFilterCondition(dateDef, { preset: 'this_month' })).toEqual({ - $gte: '{current_month_start}', - $lte: '{current_month_end}', + $gte: '{month_start}', + $lte: '{month_end}', }); }); @@ -311,7 +311,7 @@ describe('buildFilterCondition', () => { it('keeps a date-macro token as an equality, with no warning', () => { // Macro tokens stay symbolic in the condition and are resolved at query - // time by resolveDateMacros — the same vocabulary PRESET_RANGES emits. + // time by resolveDateMacros — the same vocabulary preset bounds emit. withWarn((warn) => { expect(buildFilterCondition(dateDef, '{today}')).toBe('{today}'); expect(buildFilterCondition(dateDef, '{7_days_ago}')).toBe('{7_days_ago}'); @@ -493,13 +493,11 @@ describe('mergeFilters', () => { * list cannot make. So the first test asks `toBe`, deliberately. * * The second is the other half of the burn-down and the one with teeth at - * runtime: the spec owns which presets EXIST, this module owns what each one - * RESOLVES TO, and nothing but the `satisfies` in `dashboard-filters.ts` ties - * them together. That is a compile-time pin, so it is erased here — this test - * is what makes the same claim visible in a suite run, and what would catch a - * future edit that re-annotated the table as `Record` (a string index - * signature satisfies every literal key, so the `satisfies` would go vacuously - * green while the bounds table quietly went missing entries). + * runtime: the spec owns which presets EXIST and, since the local bounds table + * retired, what each one RESOLVES TO (`DATE_RANGE_PRESET_MACRO_WINDOWS`). The + * spec types that table `Record`, a compile-time pin that + * is erased here — this test is what makes the same claim visible in a suite + * run: every offered preset lowers to a condition. */ describe('DATE_RANGE_PRESETS is the spec\'s list, not a copy of it', () => { it('is the spec\'s own array by REFERENCE, not an equal one', async () => { diff --git a/ui/packages/core/src/utils/dashboard-filters.ts b/ui/packages/core/src/utils/dashboard-filters.ts index 4ad4ce26863..32e7cfca6c1 100644 --- a/ui/packages/core/src/utils/dashboard-filters.ts +++ b/ui/packages/core/src/utils/dashboard-filters.ts @@ -22,7 +22,8 @@ import type { DashboardComponentSchema, DashboardWidgetSchema, I18nLabel, PageVariable } from '@object-ui/types'; import { liftLegacyGlobalFilterDefault } from '@object-ui/types'; -import { DATE_RANGE_PRESETS, DashboardSchema as SpecDashboardSchema, type DateRangePreset } from '@objectstack/spec/ui'; +import { DATE_RANGE_PRESET_MACRO_WINDOWS, isDateRangePresetName } from '@objectstack/spec/data'; +import { DATE_RANGE_PRESETS, DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; /** Reserved filter name for the dashboard's built-in date range. */ @@ -146,49 +147,32 @@ export interface DateRangeValue { } /** - * Date-range presets → date-macro token bounds. Tokens stay symbolic in the - * generated condition; every widget renderer resolves them at query time via - * `resolveDateMacros`, exactly like hand-authored widget filters. - * - * `satisfies Record` is the load-bearing half of - * objectui#4167, not decoration. `DATE_RANGE_PRESETS` below is now the spec's - * list rather than `Object.keys` of this table, so the two could otherwise - * drift in the one direction the spec's own comment on that const names as the - * failure mode: a preset the SCHEMA knows and this table has no bounds for - * "validates clean and then resolves to nothing" — it would reach the filter - * bar's dropdown, be selected, and produce no range. The `satisfies` makes that - * a compile error at the moment the spec adds a preset (missing key), and makes - * a local invention a compile error too (excess key). The annotation form - * `const PRESET_RANGES: Record` would NOT do this: a string - * index signature satisfies every literal key, so the check passes vacuously. + * Date-range preset NAME → its date-macro token bounds, read from the spec's + * own window table, `DATE_RANGE_PRESET_MACRO_WINDOWS`. Tokens stay symbolic in + * the generated condition; every widget renderer resolves them at query time + * via `resolveDateMacros`, exactly like hand-authored widget filters. + * + * There is no local bounds table. This module used to carry one + * (`PRESET_RANGES`) holding the same thirteen windows as the spec's, spelled + * with the `{current_*}` period tokens where the spec writes the bare aliases + * (`{month_start}`); both spellings name the same day through + * `DATE_MACRO_ALIAS_TOKENS`, so the copy protected nothing and could only + * drift. The guarantees that table's `satisfies Record` + * pin bought (objectui#4167) now sit on the spec's declaration, which is typed + * `Record` over a literal union: a preset the spec adds + * without a window is a compile error THERE, and no local name can be invented + * here because there is no local table to add one to. + * + * The runtime receives whatever a stored dashboard carries, so a name that is + * not a preset must miss (and warn, see `buildFilterCondition`) rather than + * fail to compile. Membership is the spec's exact predicate, + * `isDateRangePresetName` — never `in` or a bare index, which would also find + * `Object.prototype` members (`constructor`, `toString`) on a plain object. */ -const PRESET_RANGES = { - today: { from: '{today}', to: '{today}' }, - yesterday: { from: '{yesterday}', to: '{yesterday}' }, - this_week: { from: '{current_week_start}', to: '{current_week_end}' }, - last_week: { from: '{last_week_start}', to: '{last_week_end}' }, - this_month: { from: '{current_month_start}', to: '{current_month_end}' }, - last_month: { from: '{last_month_start}', to: '{last_month_end}' }, - this_quarter: { from: '{current_quarter_start}', to: '{current_quarter_end}' }, - last_quarter: { from: '{last_quarter_start}', to: '{last_quarter_end}' }, - this_year: { from: '{current_year_start}', to: '{current_year_end}' }, - last_year: { from: '{last_year_start}', to: '{last_year_end}' }, - last_7_days: { from: '{7_days_ago}', to: '{today}' }, - last_30_days: { from: '{30_days_ago}', to: '{today}' }, - last_90_days: { from: '{90_days_ago}', to: '{today}' }, -} satisfies Record; - -/** - * Bounds for a preset NAME that may not be one. - * - * The runtime receives whatever a stored dashboard carries, so an unrecognised - * name must warn (see `buildFilterCondition`) rather than fail to compile. That - * read is confined here so the `satisfies` pin above stays the single place - * deciding which names exist — an unguarded `PRESET_RANGES[someString]` would - * have forced the table back to a `Record` and taken the pin with it. - */ -function presetBounds(preset: string): { from?: string; to?: string } | undefined { - return (PRESET_RANGES as Record)[preset]; +function presetBounds(preset: string): { from: string; to: string } | undefined { + if (!isDateRangePresetName(preset)) return undefined; + const [from, to] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; + return { from, to }; } /** @@ -206,10 +190,9 @@ function presetBounds(preset: string): { from?: string; to?: string } | undefine * already identical in content AND display order — the copy had nothing to * protect. * - * What stays local is the BOUNDS table above, which is the other half of - * objectstack-ai/objectstack#4614's design ("the two vocabularies live one import apart and neither - * restates the other's grammar"): the spec owns which presets exist, this - * module owns what each one resolves to in date-macro tokens. + * The BOUNDS each name resolves to are the spec's too (`presetBounds` above + * reads `DATE_RANGE_PRESET_MACRO_WINDOWS`), so this module restates neither the + * preset list nor its windows. */ export { DATE_RANGE_PRESETS }; @@ -228,7 +211,7 @@ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+(?:Z|[+-]\d{2}:?\d{2})?)?$/ * - a **date-macro token** (`{today}`, `${current_month_start}`, * `{7_days_ago}`) — it stays symbolic in the generated condition and is * resolved at query time by `resolveDateMacros`, exactly like the bounds - * `PRESET_RANGES` emits. The check asks that resolver itself instead of + * `presetBounds` emits. The check asks that resolver itself instead of * restating its grammar here: one token vocabulary, no second dialect to * drift — and a token it does not know is precisely the typo this guard * exists to catch; @@ -396,7 +379,7 @@ function liftLegacyFilterDeclaration(filter: T): T { function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: unknown): unknown { if (type !== 'date' && type !== 'dateRange') return defaultValue; if (typeof defaultValue !== 'string') return defaultValue; - return defaultValue in PRESET_RANGES ? { preset: defaultValue } : defaultValue; + return isDateRangePresetName(defaultValue) ? { preset: defaultValue } : defaultValue; } /**