Skip to content

Commit 80aef80

Browse files
os-billclaude
andauthored
fix(spec): the one-day date-range presets prescribe a one-day window, and the table states its end-token convention (#17338)
* fix(spec): prescribe a one-day window for the one-day date-range presets `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. Two of its thirteen entries prescribed a window wider than the preset they name. `yesterday` spelled its end as `{today}` — 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, two days for a one-day preset. `today` took the open `$gte`-only arm, so its prescription had no upper bound at all and selected every day after today on a column carrying future dates. This package's own conformance corpus already measures the gap: with the fixture clock on 2026-07-28, `$between ['{today}', '{today}']` selects c_open/d_mid/e_late and is annotated "the today preset degenerates to a single day", while `$gte '{today}'` additionally selects f_next and g_eom. 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 resolver. The convention itself 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, end names its last, inclusive, never the day it stops before; `end: null` is the open arm and is 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. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): date-range preset window extent Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 143c715 commit 80aef80

3 files changed

Lines changed: 218 additions & 11 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014)
6+
7+
`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.
8+
9+
- **`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}']`.
10+
- **`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}']`.
11+
12+
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.
13+
14+
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.
15+
16+
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.

packages/spec/src/data/date-range-presets.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
DATE_RANGE_PRESET_MACRO_WINDOWS,
77
bareDateRangePresetComparandMessage,
88
isDateRangePresetName,
9+
type DateRangePreset,
910
} from './date-range-presets';
1011
import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from './date-macros.zod';
12+
import { nextUtcCalendarDay } from './calendar-day';
1113

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

83+
/**
84+
* A frozen reference day for the extent pins below — Wednesday 2026-07-15.
85+
*
86+
* Nothing here calls a resolver: the two tables state, independently, the two
87+
* halves the prescription table has to connect. {@link TOKEN_DAY_ON_REFERENCE}
88+
* says what each MACRO TOKEN denotes (read off `DATE_MACRO_DESCRIPTIONS` in
89+
* `./date-macros.zod.ts` — "Monday 00:00 of this week", "Last day of this
90+
* month", "Start of yesterday"). {@link EXPECTED_WINDOW_DAYS} says what each
91+
* PRESET NAME denotes, which is decided by the name and by nothing else. The
92+
* pin then asks whether `DATE_RANGE_PRESET_MACRO_WINDOWS` joins them, which is
93+
* the only question it exists to answer — and the question a membership check
94+
* over the token vocabulary cannot reach, because both a right and a wrong end
95+
* token are perfectly good members (#17014).
96+
*/
97+
const REFERENCE_DAY = '2026-07-15'; // a Wednesday, mid-week / mid-month / mid-quarter
98+
99+
/** What each token used by a CLOSED entry denotes on {@link REFERENCE_DAY}. */
100+
const TOKEN_DAY_ON_REFERENCE: Readonly<Record<string, string>> = {
101+
'{today}': '2026-07-15',
102+
'{yesterday}': '2026-07-14',
103+
'{week_start}': '2026-07-13', // Monday of that week
104+
'{week_end}': '2026-07-19', // Sunday of that week
105+
'{last_week_start}': '2026-07-06',
106+
'{last_week_end}': '2026-07-12',
107+
'{month_start}': '2026-07-01',
108+
'{month_end}': '2026-07-31',
109+
'{last_month_start}': '2026-06-01',
110+
'{last_month_end}': '2026-06-30',
111+
'{quarter_start}': '2026-07-01',
112+
'{quarter_end}': '2026-09-30',
113+
'{last_quarter_start}': '2026-04-01',
114+
'{last_quarter_end}': '2026-06-30',
115+
'{year_start}': '2026-01-01',
116+
'{year_end}': '2026-12-31',
117+
'{last_year_start}': '2025-01-01',
118+
'{last_year_end}': '2025-12-31',
119+
};
120+
121+
/**
122+
* The window each preset NAME denotes on {@link REFERENCE_DAY}, as
123+
* `[firstDay, lastDay]` — or `null` for a ROLLING preset, whose upper bound is
124+
* the resolver's clock rather than any calendar day.
125+
*
126+
* Every declared preset must appear here (the census is asserted below), so a
127+
* preset added later cannot reach `main` without its author stating the days
128+
* it covers — which is the point at which picking the wrong end convention
129+
* becomes visible instead of silent.
130+
*/
131+
const EXPECTED_WINDOW_DAYS: Readonly<
132+
Record<DateRangePreset, readonly [first: string, last: string] | null>
133+
> = {
134+
today: ['2026-07-15', '2026-07-15'],
135+
yesterday: ['2026-07-14', '2026-07-14'],
136+
this_week: ['2026-07-13', '2026-07-19'],
137+
last_week: ['2026-07-06', '2026-07-12'],
138+
this_month: ['2026-07-01', '2026-07-31'],
139+
last_month: ['2026-06-01', '2026-06-30'],
140+
this_quarter: ['2026-07-01', '2026-09-30'],
141+
last_quarter: ['2026-04-01', '2026-06-30'],
142+
this_year: ['2026-01-01', '2026-12-31'],
143+
last_year: ['2025-01-01', '2025-12-31'],
144+
last_7_days: null,
145+
last_30_days: null,
146+
last_90_days: null,
147+
};
148+
149+
const DAY_MS = 86_400_000;
150+
const midnightUtc = (day: string): number => Date.parse(`${day}T00:00:00.000Z`);
151+
152+
/**
153+
* How many whole days the prescribed `$between` pair actually selects, under
154+
* the platform's own bare-day bound rule: `>= start 00:00` and, because a
155+
* bare-day upper bound means "through that whole day",
156+
* `< nextUtcCalendarDay(end) 00:00` (ADR-0053 D-D, `./calendar-day.ts`).
157+
*/
158+
function prescribedDayCount(startDay: string, endDay: string): number {
159+
const exclusiveEnd = nextUtcCalendarDay(endDay);
160+
expect(exclusiveEnd, `${endDay} must be a real calendar day`).not.toBeNull();
161+
return (midnightUtc(exclusiveEnd!) - midnightUtc(startDay)) / DAY_MS;
162+
}
163+
164+
describe('the prescribed window covers exactly the days the preset names (#17014)', () => {
165+
it('states an expected window for every declared preset, and for nothing else', () => {
166+
// The fence: a preset added to the vocabulary without a stated extent
167+
// fails here rather than silently inheriting whichever end convention its
168+
// author happened to reach for.
169+
expect(Object.keys(EXPECTED_WINDOW_DAYS).sort()).toEqual([...DATE_RANGE_PRESETS].sort());
170+
});
171+
172+
it('the open arm is exactly the three ROLLING last_N_days windows', () => {
173+
// `end: null` is not a free choice — it says "this window has no calendar
174+
// upper bound at all". A preset that names a period always closes, `today`
175+
// included: `{ $gte: '{today}' }` also selects every day AFTER today on a
176+
// column that carries future dates.
177+
const open = DATE_RANGE_PRESETS.filter((p) => DATE_RANGE_PRESET_MACRO_WINDOWS[p][1] === null);
178+
expect(open).toEqual(['last_7_days', 'last_30_days', 'last_90_days']);
179+
for (const preset of DATE_RANGE_PRESETS) {
180+
expect(
181+
DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1] === null,
182+
`${preset}: the open arm and the rolling family must be the same set`,
183+
).toBe(EXPECTED_WINDOW_DAYS[preset] === null);
184+
}
185+
});
186+
187+
it('every closed entry names its FIRST day as start and its LAST day as end', () => {
188+
for (const preset of DATE_RANGE_PRESETS) {
189+
const expected = EXPECTED_WINDOW_DAYS[preset];
190+
if (expected === null) continue;
191+
const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset];
192+
const startDay = TOKEN_DAY_ON_REFERENCE[start];
193+
const endDay = TOKEN_DAY_ON_REFERENCE[end!];
194+
expect(startDay, `${preset}: ${start} needs a denotation in TOKEN_DAY_ON_REFERENCE`).toBeTruthy();
195+
expect(endDay, `${preset}: ${end} needs a denotation in TOKEN_DAY_ON_REFERENCE`).toBeTruthy();
196+
expect(startDay, `${preset}: start must be the window's FIRST day`).toBe(expected[0]);
197+
// ⭐ The convention, and the whole defect: an end naming the day AFTER
198+
// the window resolves one day too wide, silently, on every backend.
199+
expect(endDay, `${preset}: end must be the window's LAST day, never the day after`).toBe(expected[1]);
200+
}
201+
});
202+
203+
it('resolves the one-day presets to ONE day and the week presets to seven', () => {
204+
// Stated as concrete counts rather than as a re-derivation of the table,
205+
// because the defect was exactly a count: `['{yesterday}', '{today}']`
206+
// prescribed `>= yesterday 00:00 AND < tomorrow 00:00` — two days for a
207+
// one-day preset.
208+
expect(prescribedDayCount('2026-07-14', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.yesterday[1]!])).toBe(1);
209+
expect(prescribedDayCount('2026-07-15', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.today[1]!])).toBe(1);
210+
expect(prescribedDayCount('2026-07-13', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.this_week[1]!])).toBe(7);
211+
expect(prescribedDayCount('2026-07-06', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_week[1]!])).toBe(7);
212+
213+
// And the full census, so a period preset cannot drift either.
214+
for (const preset of DATE_RANGE_PRESETS) {
215+
const expected = EXPECTED_WINDOW_DAYS[preset];
216+
if (expected === null) continue;
217+
const end = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1]!;
218+
expect(
219+
prescribedDayCount(expected[0], TOKEN_DAY_ON_REFERENCE[end]),
220+
`${preset}: prescribed extent must equal the named window`,
221+
).toBe(prescribedDayCount(expected[0], expected[1]));
222+
}
223+
});
224+
225+
it('the refusal prescribes a one-day window for a one-day preset', () => {
226+
const yday = bareDateRangePresetComparandMessage('yesterday', '$gte');
227+
expect(yday).toContain("{ $between: ['{yesterday}', '{yesterday}'] }");
228+
// ⛔ The day AFTER the window must not appear in a prescription for it.
229+
expect(yday).not.toContain("'{today}'");
230+
231+
const today = bareDateRangePresetComparandMessage('today', '$lte');
232+
expect(today).toContain("{ $between: ['{today}', '{today}'] }");
233+
expect(today).not.toContain("'{tomorrow}'");
234+
});
235+
});
236+
81237
describe('ui re-export stays the same declaration', () => {
82238
it('ui/dashboard.zod re-exports this vocabulary by reference', async () => {
83239
const ui = await import('../ui/dashboard.zod');

packages/spec/src/data/date-range-presets.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,59 @@ export function isDateRangePresetName(value: unknown): value is DateRangePreset
6666

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

0 commit comments

Comments
 (0)