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
16 changes: 16 additions & 0 deletions .changeset/date-range-preset-window-extent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014)

`DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against.

- **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`.
- **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`.

Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows.

The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading.

No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves.
156 changes: 156 additions & 0 deletions packages/spec/src/data/date-range-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
DATE_RANGE_PRESET_MACRO_WINDOWS,
bareDateRangePresetComparandMessage,
isDateRangePresetName,
type DateRangePreset,
} from './date-range-presets';
import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from './date-macros.zod';
import { nextUtcCalendarDay } from './calendar-day';

describe('date-range preset vocabulary (#4614, re-homed by #8793)', () => {
it('declares exactly the thirteen shipped preset names, in filter-bar order', () => {
Expand Down Expand Up @@ -78,6 +80,160 @@ describe('date-range preset vocabulary (#4614, re-homed by #8793)', () => {
});
});

/**
* A frozen reference day for the extent pins below — Wednesday 2026-07-15.
*
* Nothing here calls a resolver: the two tables state, independently, the two
* halves the prescription table has to connect. {@link TOKEN_DAY_ON_REFERENCE}
* says what each MACRO TOKEN denotes (read off `DATE_MACRO_DESCRIPTIONS` in
* `./date-macros.zod.ts` — "Monday 00:00 of this week", "Last day of this
* month", "Start of yesterday"). {@link EXPECTED_WINDOW_DAYS} says what each
* PRESET NAME denotes, which is decided by the name and by nothing else. The
* pin then asks whether `DATE_RANGE_PRESET_MACRO_WINDOWS` joins them, which is
* the only question it exists to answer — and the question a membership check
* over the token vocabulary cannot reach, because both a right and a wrong end
* token are perfectly good members (#17014).
*/
const REFERENCE_DAY = '2026-07-15'; // a Wednesday, mid-week / mid-month / mid-quarter

/** What each token used by a CLOSED entry denotes on {@link REFERENCE_DAY}. */
const TOKEN_DAY_ON_REFERENCE: Readonly<Record<string, string>> = {
'{today}': '2026-07-15',
'{yesterday}': '2026-07-14',
'{week_start}': '2026-07-13', // Monday of that week
'{week_end}': '2026-07-19', // Sunday of that week
'{last_week_start}': '2026-07-06',
'{last_week_end}': '2026-07-12',
'{month_start}': '2026-07-01',
'{month_end}': '2026-07-31',
'{last_month_start}': '2026-06-01',
'{last_month_end}': '2026-06-30',
'{quarter_start}': '2026-07-01',
'{quarter_end}': '2026-09-30',
'{last_quarter_start}': '2026-04-01',
'{last_quarter_end}': '2026-06-30',
'{year_start}': '2026-01-01',
'{year_end}': '2026-12-31',
'{last_year_start}': '2025-01-01',
'{last_year_end}': '2025-12-31',
};

/**
* The window each preset NAME denotes on {@link REFERENCE_DAY}, as
* `[firstDay, lastDay]` — or `null` for a ROLLING preset, whose upper bound is
* the resolver's clock rather than any calendar day.
*
* Every declared preset must appear here (the census is asserted below), so a
* preset added later cannot reach `main` without its author stating the days
* it covers — which is the point at which picking the wrong end convention
* becomes visible instead of silent.
*/
const EXPECTED_WINDOW_DAYS: Readonly<
Record<DateRangePreset, readonly [first: string, last: string] | null>
> = {
today: ['2026-07-15', '2026-07-15'],
yesterday: ['2026-07-14', '2026-07-14'],
this_week: ['2026-07-13', '2026-07-19'],
last_week: ['2026-07-06', '2026-07-12'],
this_month: ['2026-07-01', '2026-07-31'],
last_month: ['2026-06-01', '2026-06-30'],
this_quarter: ['2026-07-01', '2026-09-30'],
last_quarter: ['2026-04-01', '2026-06-30'],
this_year: ['2026-01-01', '2026-12-31'],
last_year: ['2025-01-01', '2025-12-31'],
last_7_days: null,
last_30_days: null,
last_90_days: null,
};

const DAY_MS = 86_400_000;
const midnightUtc = (day: string): number => Date.parse(`${day}T00:00:00.000Z`);

/**
* How many whole days the prescribed `$between` pair actually selects, under
* the platform's own bare-day bound rule: `>= start 00:00` and, because a
* bare-day upper bound means "through that whole day",
* `< nextUtcCalendarDay(end) 00:00` (ADR-0053 D-D, `./calendar-day.ts`).
*/
function prescribedDayCount(startDay: string, endDay: string): number {
const exclusiveEnd = nextUtcCalendarDay(endDay);
expect(exclusiveEnd, `${endDay} must be a real calendar day`).not.toBeNull();
return (midnightUtc(exclusiveEnd!) - midnightUtc(startDay)) / DAY_MS;
}

describe('the prescribed window covers exactly the days the preset names (#17014)', () => {
it('states an expected window for every declared preset, and for nothing else', () => {
// The fence: a preset added to the vocabulary without a stated extent
// fails here rather than silently inheriting whichever end convention its
// author happened to reach for.
expect(Object.keys(EXPECTED_WINDOW_DAYS).sort()).toEqual([...DATE_RANGE_PRESETS].sort());
});

it('the open arm is exactly the three ROLLING last_N_days windows', () => {
// `end: null` is not a free choice — it says "this window has no calendar
// upper bound at all". A preset that names a period always closes, `today`
// included: `{ $gte: '{today}' }` also selects every day AFTER today on a
// column that carries future dates.
const open = DATE_RANGE_PRESETS.filter((p) => DATE_RANGE_PRESET_MACRO_WINDOWS[p][1] === null);
expect(open).toEqual(['last_7_days', 'last_30_days', 'last_90_days']);
for (const preset of DATE_RANGE_PRESETS) {
expect(
DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1] === null,
`${preset}: the open arm and the rolling family must be the same set`,
).toBe(EXPECTED_WINDOW_DAYS[preset] === null);
}
});

it('every closed entry names its FIRST day as start and its LAST day as end', () => {
for (const preset of DATE_RANGE_PRESETS) {
const expected = EXPECTED_WINDOW_DAYS[preset];
if (expected === null) continue;
const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset];
const startDay = TOKEN_DAY_ON_REFERENCE[start];
const endDay = TOKEN_DAY_ON_REFERENCE[end!];
expect(startDay, `${preset}: ${start} needs a denotation in TOKEN_DAY_ON_REFERENCE`).toBeTruthy();
expect(endDay, `${preset}: ${end} needs a denotation in TOKEN_DAY_ON_REFERENCE`).toBeTruthy();
expect(startDay, `${preset}: start must be the window's FIRST day`).toBe(expected[0]);
// ⭐ The convention, and the whole defect: an end naming the day AFTER
// the window resolves one day too wide, silently, on every backend.
expect(endDay, `${preset}: end must be the window's LAST day, never the day after`).toBe(expected[1]);
}
});

it('resolves the one-day presets to ONE day and the week presets to seven', () => {
// Stated as concrete counts rather than as a re-derivation of the table,
// because the defect was exactly a count: `['{yesterday}', '{today}']`
// prescribed `>= yesterday 00:00 AND < tomorrow 00:00` — two days for a
// one-day preset.
expect(prescribedDayCount('2026-07-14', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.yesterday[1]!])).toBe(1);
expect(prescribedDayCount('2026-07-15', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.today[1]!])).toBe(1);
expect(prescribedDayCount('2026-07-13', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.this_week[1]!])).toBe(7);
expect(prescribedDayCount('2026-07-06', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_week[1]!])).toBe(7);

// And the full census, so a period preset cannot drift either.
for (const preset of DATE_RANGE_PRESETS) {
const expected = EXPECTED_WINDOW_DAYS[preset];
if (expected === null) continue;
const end = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1]!;
expect(
prescribedDayCount(expected[0], TOKEN_DAY_ON_REFERENCE[end]),
`${preset}: prescribed extent must equal the named window`,
).toBe(prescribedDayCount(expected[0], expected[1]));
}
});

it('the refusal prescribes a one-day window for a one-day preset', () => {
const yday = bareDateRangePresetComparandMessage('yesterday', '$gte');
expect(yday).toContain("{ $between: ['{yesterday}', '{yesterday}'] }");
// ⛔ The day AFTER the window must not appear in a prescription for it.
expect(yday).not.toContain("'{today}'");

const today = bareDateRangePresetComparandMessage('today', '$lte');
expect(today).toContain("{ $between: ['{today}', '{today}'] }");
expect(today).not.toContain("'{tomorrow}'");
});
});

describe('ui re-export stays the same declaration', () => {
it('ui/dashboard.zod re-exports this vocabulary by reference', async () => {
const ui = await import('../ui/dashboard.zod');
Expand Down
57 changes: 46 additions & 11 deletions packages/spec/src/data/date-range-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,59 @@ export function isDateRangePresetName(value: unknown): value is DateRangePreset

/**
* The `{date-macro}` window each preset resolves to — `[start, end]`, in the
* WRAPPED spelling a filter author writes; `end: null` means "now" (the
* rolling `last_N_days` windows have no upper macro — the resolver's clock is
* the bound).
* WRAPPED spelling a filter author writes.
*
* ## The convention, binding on every entry (#17014)
*
* **`start` names the FIRST calendar day the window contains; `end` names its
* LAST. Inclusive — never the day the window stops before.**
*
* That is the reading that makes the pair correct as the `$between` this table
* is written for. `$between` is `$gte min` ∧ `$lte max`, and a bare-day upper
* bound means "through that whole day", compiled half-open to
* `< nextUtcCalendarDay(max)` — the platform-wide rule stated in
* `./calendar-day.ts` (ADR-0053 D-D, #3777). So an inclusive last-day end
* covers its day to the final instant and stops there:
* `['{yesterday}', '{yesterday}']` is exactly yesterday, one day — and the
* degenerate single-day range is a shape `./temporal-conformance.ts` already
* pins across every driver.
*
* ⛔ The OTHER convention — an end naming the day the window STOPS BEFORE —
* is equally real and is used deliberately one package over
* (`@objectstack/core`'s `analytics-date-range.ts` states its ends that way
* and says so). It must never appear here. Both spellings parse, resolve, run
* and return rows; only the EXTENT differs, so nothing downstream can catch an
* entry that picked the wrong one, and a reader of a mixed table cannot tell
* which reading any single row intends.
*
* `end: null` is the open arm, and it is exactly the three ROLLING
* `last_N_days` windows: those have no calendar upper bound at all, so the
* resolver's clock is the bound and the prescription takes the `$gte`-only
* form. ⛔ A CALENDAR preset — one that names a period, `today` included —
* always closes on its own last day, because `$gte` alone also selects every
* day AFTER the window on a column that carries future dates.
*
* ## Why the table exists: PRESCRIPTION
*
* When a preset name is refused as a bare filter comparand (#8793), the
* refusal must name the spelling that works — that is the difference between
* a dead end and a one-edit fix, especially for an AI author whose correction
* loop only sees the error text. An author handed a prescription whose EXTENT
* is wrong gets a filter that parses, runs and returns rows over the wrong
* window, with no second error to correct against — which is why the
* convention above is a rule here and not a preference.
*
* This exists for one purpose: PRESCRIPTION. When a preset name is refused as
* a bare filter comparand (#8793), the refusal must name the spelling that
* works — that is the difference between a dead end and a one-edit fix,
* especially for an AI author whose correction loop only sees the error text.
* It is deliberately NOT a resolver: the console's own preset-to-bounds
* lowering (objectui `dashboard-filters`) stays the executable mapping, and
* `date-range-presets.test.ts` pins every token here as a member of the macro
* vocabulary so the two cannot drift silently.
* `date-range-presets.test.ts` pins both the macro-vocabulary membership of
* every token here AND the resolved EXTENT of every window, so neither the
* spelling nor the convention can drift silently.
*/
export const DATE_RANGE_PRESET_MACRO_WINDOWS: Readonly<
Record<DateRangePreset, readonly [start: string, end: string | null]>
> = {
today: ['{today}', null],
yesterday: ['{yesterday}', '{today}'],
today: ['{today}', '{today}'],
yesterday: ['{yesterday}', '{yesterday}'],
this_week: ['{week_start}', '{week_end}'],
last_week: ['{last_week_start}', '{last_week_end}'],
this_month: ['{month_start}', '{month_end}'],
Expand Down
Loading