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
14 changes: 14 additions & 0 deletions .changeset/29116-dashboard-preset-windows-from-spec.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, { from: string; to: string }> = {
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<typeof vi.spyOn>;
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();
}
});
});
22 changes: 10 additions & 12 deletions ui/packages/core/src/utils/__tests__/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
});
});

Expand Down Expand Up @@ -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}',
});
});

Expand Down Expand Up @@ -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}');
Expand Down Expand Up @@ -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<string, …>` (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<DateRangePreset, …>`, 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 () => {
Expand Down
81 changes: 32 additions & 49 deletions ui/packages/core/src/utils/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<DateRangePreset, …>` 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<DateRangePreset, …>` 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<DateRangePreset, …>`
* pin bought (objectui#4167) now sit on the spec's declaration, which is typed
* `Record<DateRangePreset, …>` 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<DateRangePreset, { from?: string; to?: string }>;

/**
* 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<string, …>` and taken the pin with it.
*/
function presetBounds(preset: string): { from?: string; to?: string } | undefined {
return (PRESET_RANGES as Record<string, { from?: string; to?: string }>)[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 };
}

/**
Expand All @@ -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 };

Expand All @@ -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;
Expand Down Expand Up @@ -396,7 +379,7 @@ function liftLegacyFilterDeclaration<T>(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;
}

/**
Expand Down
Loading