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
19 changes: 19 additions & 0 deletions .changeset/23170-rolling-presets-close-on-today.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': patch
'@objectstack/core': patch
'@objectstack/lint': patch
'@objectstack/verify': patch
---

fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm

Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has)

The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1.

- `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken.
- The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`.
- `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before.
- The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`.

If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows.
2 changes: 1 addition & 1 deletion .changeset/pre/15953-verify-dashboard-window-family.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/verify': minor
'@objectstack/cli': minor
Expand All @@ -12,7 +12,7 @@
Where each half of the promise is read from:

- the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field;
- a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens;
- a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}', $lte: '{today}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens;
- the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive.

Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2032,8 +2032,8 @@ os verify --dashboard-windows # Also prove each dashboard date
door refuses it), and read back. The widget is then asked through
`POST /analytics/dataset/query` as the signed-in admin with no window and
under each of the 13 presets, before and after the fixtures. A preset is
handed as the spec's `{date-macro}` window for it (`{ $gte: '{7_days_ago}' }`
for `last_7_days`, `{ $gte: '{month_start}', $lte: '{month_end}' }` for
handed as the spec's `{date-macro}` window for it
(`{ $gte: '{7_days_ago}', $lte: '{today}' }` for `last_7_days`, `{ $gte: '{month_start}', $lte: '{month_end}' }` for
`this_month`), ANDed with the widget's filter, and the server resolves the
tokens; the ground truth resolves the same window with the platform's
resolver, on UTC calendar days unless the caller's context names a
Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/utils/analytics-date-range-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export const ANALYTICS_DATE_RANGE_NOT_A_WINDOW: readonly (readonly unknown[])[]
[null, null],
];

/** The three presets whose upper bound is NOW rather than a calendar boundary. */
/** The three rolling presets: their window runs through today and stops before tomorrow begins. */
const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days'];

interface ThrownEnvelope {
Expand Down Expand Up @@ -186,7 +186,8 @@ async function attempt(
*
* @param face - the backend under test.
* @param options - the reference instant and timezone; the caller freezes the
* clock so the rolling presets (whose bound is NOW) are comparable at all.
* clock so every preset, each anchored on that instant's calendar day, is
* comparable at all.
*/
export async function analyticsDateRangeConformanceFindings(
face: AnalyticsDateRangeFace,
Expand Down Expand Up @@ -217,7 +218,7 @@ export async function analyticsDateRangeConformanceFindings(
if (w.endExclusive !== expected.endExclusive) {
say(
`compares '${preset}''s upper bound ${w.endExclusive ? 'exclusively' : 'inclusively'}, `
+ `but a ${ROLLING.includes(preset) ? 'rolling window ends at NOW and REACHES its bound' : 'calendar window stops BEFORE its end instant'}`,
+ `but a ${ROLLING.includes(preset) ? 'rolling window stops BEFORE tomorrow begins, as today does' : 'calendar window stops BEFORE its end instant'}`,
);
}
// The fallback's shape, named directly: its two bounds were the preset's
Expand Down
102 changes: 60 additions & 42 deletions packages/core/src/utils/analytics-date-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
/** A frozen reference instant, deliberately mid-week, mid-month, mid-quarter. */
const NOW = new Date('2026-09-09T12:34:56.789Z');

/** The rolling family — the only presets whose upper bound is NOW. */
/** The rolling family — closed on today, like every calendar preset. */
const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'last_90_days'];

const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
Expand Down Expand Up @@ -132,17 +132,52 @@ describe('#16322 — the START of every window is the token the spec already pre
});
});

describe('#16322 — the upper bound is half-open for a calendar window, and NOW for a rolling one', () => {
it('the ten calendar presets are endExclusive, the three rolling ones are not', () => {
describe('#16322 — the upper bound is half-open for every preset, the rolling ones included', () => {
it('all thirteen presets are endExclusive', () => {
for (const preset of DATE_RANGE_PRESETS) {
const w = resolveAnalyticsDateRangePreset(preset, { now: NOW });
expect(w.endExclusive, preset).toBe(!ROLLING.includes(preset));
expect(w.endExclusive, preset).toBe(true);
}
});

it('a rolling window ends at the reference instant exactly', () => {
it("a rolling window stops where `today` stops — before tomorrow begins, never at the reference instant", () => {
// The spec closes the rolling windows on `{today}`, the same last day
// `today` names, so the two must share one end instant in every zone.
for (const tz of SWEEP_ZONES) {
const today = resolveAnalyticsDateRangePreset('today', { now: NOW, timezone: tz });
for (const preset of ROLLING) {
const w = resolveAnalyticsDateRangePreset(preset, { now: NOW, timezone: tz });
expect(w.end, `${preset} @ ${tz ?? 'UTC'}`).toBe(today.end);
expect(w.end, `${preset} @ ${tz ?? 'UTC'}`).not.toBe(NOW.toISOString());
}
}
expect(resolveAnalyticsDateRangePreset('last_30_days', { now: NOW })).toEqual({
start: '2026-08-10T00:00:00.000Z',
end: '2026-09-10T00:00:00.000Z',
endExclusive: true,
});
});

it('a rolling window counts a row five days ago and NOT a row five days ahead', () => {
// The measurement that found the open arm, at this face: one row dated
// today−5 and one dated today+5. Counted the way each face compares a
// preset window — `>= start` and `< end` for an exclusive end.
const rows = ['2026-09-04T00:00:00.000Z', '2026-09-14T00:00:00.000Z']; // NOW's day − 5, + 5
for (const preset of ROLLING) {
expect(resolveAnalyticsDateRangePreset(preset, { now: NOW }).end).toBe(NOW.toISOString());
const w = resolveAnalyticsDateRangePreset(preset, { now: NOW });
const counted = rows.filter((r) => Date.parse(r) >= Date.parse(w.start) && Date.parse(r) < Date.parse(w.end));
expect(counted, preset).toEqual(['2026-09-04T00:00:00.000Z']);
}
});

it("a rolling window keeps the rest of today — a row later today than the reference instant is inside", () => {
// The extent the spec's `{today}` end means: the whole of today. An end
// at the reference instant dropped this row.
const laterToday = '2026-09-09T20:00:00.000Z';
expect(Date.parse(laterToday)).toBeGreaterThan(NOW.getTime());
for (const preset of ROLLING) {
const w = resolveAnalyticsDateRangePreset(preset, { now: NOW });
expect(Date.parse(laterToday), preset).toBeLessThan(Date.parse(w.end));
}
});

Expand Down Expand Up @@ -272,45 +307,37 @@ describe('#16322 — a string outside the vocabulary is REFUSED, not widened', (
});
});

describe('#17341 — the END of every calendar window is one CALENDAR day after the prescription', () => {
describe('#17341 — the END of every window is one CALENDAR day after the prescription', () => {
// The docblock above `PRESET_WINDOW_TOKENS` records WHY this table states
// its own ends instead of reading `DATE_RANGE_PRESET_MACRO_WINDOWS`: that
// table is written for `$between`, whose bare-day upper bound covers the
// whole day, so its end names the day BEFORE the one this table stops at.
//
// ⛔ That sentence carried a hand-typed count ("the eight period presets")
// and went false the moment the spec corrected `today` and `yesterday` to
// close on their own last day — a count is a census and a census goes
// stale silently. Nothing here counts: the two families are READ OFF the
// prescription, so an eleventh preset reds this file instead of rotting
// that sentence.
const PRESCRIBED_CLOSED = DATE_RANGE_PRESETS.filter(
(p) => DATE_RANGE_PRESET_MACRO_WINDOWS[p][1] !== null,
);
const PRESCRIBED_OPEN = DATE_RANGE_PRESETS.filter(
(p) => DATE_RANGE_PRESET_MACRO_WINDOWS[p][1] === null,
);
// ⛔ That sentence once carried a hand-typed count ("the eight period
// presets") and went false the moment the spec corrected `today` and
// `yesterday` to close on their own last day — a count is a census and a
// census goes stale silently. Nothing here counts: the cases iterate the
// prescription itself, so a fourteenth preset reds this file instead of
// rotting that sentence.

it('the split is DERIVED from the spec table, and it is the same one `endExclusive` draws', () => {
// Two partitions of one vocabulary, computed from opposite sides: the
// spec's open arm, and this module's rolling family. They must be the
// same set — if they ever diverge, one of the two docblock sentences
// that name a family is describing presets that are not in it.
expect(PRESCRIBED_CLOSED.length + PRESCRIBED_OPEN.length).toBe(DATE_RANGE_PRESETS.length);
expect([...PRESCRIBED_OPEN].sort()).toEqual([...ROLLING].sort());
for (const preset of PRESCRIBED_CLOSED) {
it('the spec closes every window, and every window here is endExclusive', () => {
// The spec retired its open arm: the rolling windows close on
// `{today}`. So no preset here may fall back to an inclusive end at the
// reference instant — every one names a day it stops before.
for (const preset of DATE_RANGE_PRESETS) {
const prescribedEnd: unknown = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1];
expect(typeof prescribedEnd, preset).toBe('string');
expect(resolveAnalyticsDateRangePreset(preset, { now: NOW }).endExclusive, preset).toBe(true);
}
// The control: without it a filter that selected NOTHING would pass
// every assertion below by vacuity.
expect(PRESCRIBED_CLOSED.length).toBeGreaterThan(0);
expect(PRESCRIBED_OPEN.length).toBeGreaterThan(0);
// The control: the sweep below must reach the rolling family, the
// windows this card closed.
for (const preset of ROLLING) expect(DATE_RANGE_PRESETS, preset).toContain(preset);
});

it.each([...PRESCRIBED_CLOSED])(
it.each([...DATE_RANGE_PRESETS])(
"%s stops before the day AFTER the prescription's inclusive last day",
(preset) => {
const prescribedEnd = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1]!.replace(/^\{|\}$/g, '');
const prescribedEnd = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1].replace(/^\{|\}$/g, '');
for (const now of SWEEP_INSTANTS) {
for (const tz of SWEEP_ZONES) {
const where = `${preset} @ ${tz ?? 'UTC'} @ ${now.toISOString()}`;
Expand Down Expand Up @@ -341,13 +368,4 @@ describe('#17341 — the END of every calendar window is one CALENDAR day after
expect(end - lastDayStart).toBe(25 * 60 * 60 * 1000);
expect(end - lastDayStart).not.toBe(24 * 60 * 60 * 1000);
});

it('the open-arm presets have no end to be earlier — `null` on BOTH sides', () => {
for (const preset of PRESCRIBED_OPEN) {
expect(DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1], preset).toBeNull();
const w = resolveAnalyticsDateRangePreset(preset, { now: NOW });
expect(w.endExclusive, preset).toBe(false);
expect(w.end, preset).toBe(NOW.toISOString());
}
});
});
Loading
Loading