diff --git a/.changeset/23170-rolling-presets-close-on-today.md b/.changeset/23170-rolling-presets-close-on-today.md new file mode 100644 index 00000000000..482ad593187 --- /dev/null +++ b/.changeset/23170-rolling-presets-close-on-today.md @@ -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. diff --git a/.changeset/pre/15953-verify-dashboard-window-family.md b/.changeset/pre/15953-verify-dashboard-window-family.md index 144d744b534..770b9acb993 100644 --- a/.changeset/pre/15953-verify-dashboard-window-family.md +++ b/.changeset/pre/15953-verify-dashboard-window-family.md @@ -12,7 +12,7 @@ A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `ru 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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index d1f085ada7f..ac0953ae6f5 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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 diff --git a/packages/core/src/utils/analytics-date-range-conformance.ts b/packages/core/src/utils/analytics-date-range-conformance.ts index eb946692078..66a956e2854 100644 --- a/packages/core/src/utils/analytics-date-range-conformance.ts +++ b/packages/core/src/utils/analytics-date-range-conformance.ts @@ -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 { @@ -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, @@ -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 diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index 528976728f2..2c58fec5929 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -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$/; @@ -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)); } }); @@ -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()}`; @@ -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()); - } - }); }); diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 045249934f1..09fc649fc7f 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -56,13 +56,14 @@ * tomorrow. * * ⚠️ {@link ResolvedAnalyticsDateRange.endExclusive} is the flag that carries - * that reading to the comparison, and it is NOT constant across the - * vocabulary: - * - * - the ten CALENDAR presets end at a day boundary the window must not - * contain ⇒ `endExclusive: true`; - * - the three ROLLING `last_N_days` presets end at NOW, an instant the - * window REACHES ⇒ `endExclusive: false`. + * that reading to the comparison, and every preset this module resolves sets + * it: each of the thirteen ends at a day boundary the window must not contain + * ⇒ `endExclusive: true`. The three ROLLING `last_N_days` presets are no + * exception — they stop before TOMORROW begins, exactly as `today` does, + * because the spec's `DATE_RANGE_PRESET_MACRO_WINDOWS` closes them on + * `{today}` and this module must select the same EXTENT. An end at NOW would + * drop the rest of today's rows (a datetime later today) that the spec's + * window, the console's lowering and an author's `$between` all count. * * ⛔ And it is never `true` for a window a CALLER wrote out as an explicit * `[a, b]` array — that arm never reaches this file. `$lte` on a caller's @@ -98,8 +99,9 @@ export interface ResolvedAnalyticsDateRange { readonly end: string; /** * Is `end` the first instant AFTER the window rather than its last instant? - * `true` for the ten calendar presets, `false` for the three rolling ones, - * whose upper bound is NOW. + * `true` for every preset this module resolves — the three rolling ones + * included, which stop before tomorrow begins. A caller's explicit `[a, b]` + * window never comes here and keeps its inclusive reading at each face. */ readonly endExclusive: boolean; } @@ -108,32 +110,27 @@ export interface ResolvedAnalyticsDateRange { * The `{date-macro}` token pair each preset resolves to — `[start, endBefore]` * in the UNWRAPPED spelling {@link resolveFilterToken} takes. * - * `endBefore: null` means the window has no calendar upper bound and runs to - * the reference instant (the rolling `last_N_days` family), matching the - * `end: null` of `DATE_RANGE_PRESET_MACRO_WINDOWS`. - * * ⚠️ The end token names the day the window STOPS BEFORE, not its last day — - * `this_week` ends at `next_week_start`, not at `week_end`. That is why this + * `this_week` ends at `next_week_start`, not at `week_end`, and the rolling + * `last_N_days` family ends at `tomorrow`, not at `today`. That is why this * table states the ends itself instead of reading the spec's prescription * pair: `DATE_RANGE_PRESET_MACRO_WINDOWS` is written for `$between`, whose * bare-day upper bound is INCLUSIVE of that whole day, so its end names one - * CALENDAR day earlier than the token here — for EVERY calendar preset, with - * no remainder; the rolling ones are `null` on both sides and have no end to - * be earlier. ⛔ A uniform offset is still not a derivable one: every boundary + * CALENDAR day earlier than the token here — for EVERY preset, with no + * remainder. ⛔ A uniform offset is still not a derivable one: every boundary * here is a macro token the one resolver answers, so shifting the spec's day * by one would be calendar arithmetic performed in this file — the second * implementation the module header exists to refuse. ⛔ And it is a CALENDAR * day, never 86_400_000 ms: `Pacific/Chatham` ends DST on a Sunday, so * `this_week`'s own prescribed last day is 25 hours long there. * - * The STARTS agree exactly, and the test pins BOTH halves — deriving the - * calendar/rolling split from the spec table rather than counting presets in - * this sentence, which is how the count here came to read "the eight period - * presets" and stayed there after `today` and `yesterday` were corrected to - * close on their own last day (#17341). + * The STARTS agree exactly, and the test pins BOTH halves — iterating the spec + * table rather than counting presets in this sentence, which is how the count + * here came to read "the eight period presets" and stayed there after `today` + * and `yesterday` were corrected to close on their own last day (#17341). */ const PRESET_WINDOW_TOKENS: Readonly< - Record + Record > = { today: ['today', 'tomorrow'], yesterday: ['yesterday', 'today'], @@ -145,9 +142,9 @@ const PRESET_WINDOW_TOKENS: Readonly< last_quarter: ['last_quarter_start', 'quarter_start'], this_year: ['year_start', 'next_year_start'], last_year: ['last_year_start', 'year_start'], - last_7_days: ['7_days_ago', null], - last_30_days: ['30_days_ago', null], - last_90_days: ['90_days_ago', null], + last_7_days: ['7_days_ago', 'tomorrow'], + last_30_days: ['30_days_ago', 'tomorrow'], + last_90_days: ['90_days_ago', 'tomorrow'], }; /** Options every `dateRange` resolution reads. */ @@ -185,14 +182,11 @@ export function resolveAnalyticsDateRangePreset( ): ResolvedAnalyticsDateRange { const now = options.now ?? new Date(); const [startToken, endToken] = PRESET_WINDOW_TOKENS[preset]; - const start = tokenDayStart(startToken, now, options.timezone); - if (endToken === null) { - // The rolling family. The upper bound is the current INSTANT, which no - // zone moves — and it is a moment the window REACHES, so it stays - // INCLUSIVE (#16179 leaves this leg alone). - return { start, end: now.toISOString(), endExclusive: false }; - } - return { start, end: tokenDayStart(endToken, now, options.timezone), endExclusive: true }; + return { + start: tokenDayStart(startToken, now, options.timezone), + end: tokenDayStart(endToken, now, options.timezone), + endExclusive: true, + }; } /** diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts index 980d1a7067c..ef799fa8f3c 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-conformance.test.ts @@ -32,7 +32,7 @@ import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; -/** Frozen so the three rolling presets, whose bound is NOW, are comparable. */ +/** Frozen so the presets, every one anchored on NOW's calendar day, are comparable. */ const NOW = new Date('2026-09-09T12:34:56.789Z'); const CUBE: Cube = { diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts index ed6f64c0a52..c325c81408b 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -329,9 +329,10 @@ describe("#16042 — the rolling `last_N_days` window anchors on the zone's cale * Probes chosen to make the sixteen-hour difference VISIBLE as rows: two * sit inside the UTC window and outside the zone's, one sits outside both, * and three sit inside both. ⛔ None sits on the upper bound — that leg - * ends at NOW and is a separate question (`endExclusive` is false for the - * rolling family, `memory-analytics-date-range-token-end-exclusive.test.ts` - * owns the boundary reading). + * stops before tomorrow begins, as `today` does, and is a separate + * question (`endExclusive` is true for every preset; + * `memory-analytics-date-range-token-end-exclusive.test.ts` owns the + * boundary reading). */ const PROBES = [ '2026-08-29T23:59:59.999Z', // before both windows diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index d65eefef9e0..66b171589ce 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -2238,7 +2238,8 @@ export class MemoryAnalyticsService implements IAnalyticsService { * `'today'` still resolves to `[that zone's midnight, tomorrow's midnight)` * with `endExclusive: true` — #16179's repair, byte for byte, because the * shared resolver states the same window in the same tokens. The three - * rolling `last_N_days` presets end at NOW and stay INCLUSIVE. And the + * rolling `last_N_days` presets stop where `'today'` stops, at tomorrow's + * midnight, with `endExclusive: true` as well. And the * explicit `[a, b]` array arm never arrives here at all: it is discriminated * at the call site and keeps its published `$lte` reading. * diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 23fff5f0801..01ab0039431 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2296,8 +2296,9 @@ const FILTER_PRESET_COMPARAND_EXPLANATION: RuleExplanation = { '`$ne`, `$in` / `$nin` and their spellings — is refused only on a declared `date` / `datetime` ' + 'field, because a picklist column may store a value named like a preset. A bare value is ' + 'reported under the operator it lowers to, `$eq`.', - 'The fix is the preset\'s `{date-macro}` window, which the verdict names — a rolling preset is ' + - '`{ $gte: START }`, a calendar one `{ $between: [START, END] }` — or an ISO date. The schema ' + + 'The fix is the preset\'s `{date-macro}` window, which the verdict names — ' + + '`{ $between: [START, END] }`, its first day through its last, and a rolling preset\'s last day ' + + 'is `{today}` — or an ISO date. The schema ' + 'door refuses the Mongo-shape ordering positions too, with the whole explanation in its ' + 'refusal; this rule also reaches view filter rules and filter-array triples, which no condition ' + 'parse touches, and is the one door for the equality arm.', diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 6c3e7e60815..71238684b25 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -484,7 +484,7 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members // rest of the shared refusal is the id's `os explain` entry. expect(f.message).toBe( '"last_30_days" is a dashboard date-range PRESET name, not a filter value as a bare "$eq" ' - + "comparand; its window is { $gte: '{30_days_ago}' }", + + "comparand; its window is { $between: ['{30_days_ago}', '{today}'] }", ); expect(f.hint).toBe( 'Presets belong to the dashboard date-filter bar (dateRange.defaultRange, a ' diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index 4d06dce7f77..d3700e1f9d1 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -298,7 +298,7 @@ export function presetComparandVerdict(preset: DateRangePreset, operator: string const stop = refusal.indexOf('. '); const head = stop > 0 ? refusal.slice(0, stop) : refusal; const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; - const window = end === null ? `{ $gte: '${start}' }` : `{ $between: ['${start}', '${end}'] }`; + const window = `{ $between: ['${start}', '${end}'] }`; return `${head} as a bare "${operator}" comparand; its window is ${window}`; } diff --git a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts index ab16a0ad81a..f734d26f830 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts @@ -54,7 +54,7 @@ import { evaluateAnalyticsQueryOverRows, lowerPreviewDateRange } from '../previe import { compileDataset } from '../dataset-compiler.js'; import { DatasetExecutor, lowerDatasetCompareDateRange } from '../dataset-executor.js'; -/** Frozen so the three rolling presets, whose bound is NOW, are comparable. */ +/** Frozen so the presets, every one anchored on NOW's calendar day, are comparable. */ const NOW = new Date('2026-09-09T12:34:56.789Z'); const CTX = { tenantId: 'org_A' } as ExecutionContext; @@ -225,20 +225,24 @@ const shift = (iso: string, ms: number): string => new Date(Date.parse(iso) + ms describe('#16322 — the draft-preview evaluator APPLIES the window it reports', () => { // The frozen clock is the file-level `beforeEach` above — the rolling - // presets' upper bound is NOW, so it has to be the same NOW here. - it('a ROLLING preset selects exactly the shared resolver\'s window, upper bound REACHED', () => { - // `last_30_days` ends at NOW and includes it (#16179 leaves the rolling - // leg inclusive). Before the wiring this selected ZERO rows: the bounds - // were the literal name, and no ISO instant sorts inside + // presets' window runs through NOW's calendar day, so it has to be the + // same NOW here. + it('a ROLLING preset selects exactly the shared resolver\'s window, its last day REACHED', () => { + // `last_30_days` runs through the whole of today, the inclusive + // `{today}` end the spec's table prescribes; the shared resolver states + // that end as the instant tomorrow begins, exclusively. So the last + // instant of today is inside and tomorrow's first instant is not. + // Before the wiring this selected ZERO rows: the bounds were the + // literal name, and no ISO instant sorts inside // ['last_30_days', 'last_30_days~']. const w = resolveAnalyticsDateRangeString('last_30_days', { now: NOW }); - expect(w.endExclusive).toBe(false); + expect(w.endExclusive).toBe(true); expect(previewSelects('last_30_days', [ { id: 'before_start', created_at: shift(w.start, -1) }, { id: 'at_start', created_at: w.start }, + { id: 'last_instant_of_today', created_at: shift(w.end, -1) }, { id: 'at_end', created_at: w.end }, - { id: 'after_end', created_at: shift(w.end, 1) }, - ])).toEqual(['at_end', 'at_start']); + ])).toEqual(['at_start', 'last_instant_of_today']); }); it('a CALENDAR preset stops BEFORE its end instant', () => { @@ -328,8 +332,8 @@ async function comparePasses( } describe('#17973 — the dataset executor APPLIES the window it reports', () => { - // The frozen clock is the file-level `beforeEach`; the rolling presets' upper - // bound is NOW, so it has to be the same NOW here. + // The frozen clock is the file-level `beforeEach`; the rolling presets' + // window runs through NOW's calendar day, so it has to be the same NOW here. it('a CALENDAR preset is LOWERED, its exclusive end honoured, and the window SHIFTED', async () => { // The shared resolver's own answer for the frozen clock: September 2026, @@ -351,9 +355,11 @@ describe('#17973 — the dataset executor APPLIES the window it reports', () => it('a ROLLING preset reaches NOW\'s own day and `previousPeriod` shifts by the window\'s length', async () => { const w = resolveAnalyticsDateRangeString('last_30_days', { now: NOW }); - // Ends at NOW, a moment it REACHES (#16179 leaves the rolling leg inclusive). + // Runs through the whole of NOW's own day, the inclusive `{today}` end, + // stated in the shared resolver's spelling: the instant tomorrow begins, + // exclusively. expect([w.start, w.end, w.endExclusive]) - .toEqual(['2026-08-10T00:00:00.000Z', '2026-09-09T12:34:56.789Z', false]); + .toEqual(['2026-08-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z', true]); // 2026-08-10 through 2026-09-09 inclusive is 31 days; `previousPeriod` is // the equal-length window ending the day before this one starts. diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index d2d2ca57e1d..96aed917cdf 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -660,13 +660,12 @@ export function lowerDatasetCompareDateRange( * `currentRange[1]` as the LAST bucket the window contains — so both bounds * must name days the window really covers. * - * ⚠️ `endExclusive` is therefore load-bearing, not decoration: the ten calendar - * presets report the first instant AFTER the window (`this_month` ends at the - * next month's start), so the last day they cover is the one holding the - * instant before that bound. Reading their bound as inclusive would make every - * calendar preset one day too long and shift `previousPeriod` by a day. The - * three rolling presets end at NOW, a moment they REACH, so their bound is - * already the last day. + * ⚠️ `endExclusive` is therefore load-bearing, not decoration: every preset + * reports the first instant AFTER the window (`this_month` ends at the next + * month's start, and a rolling `last_N_days` window at tomorrow's start), so + * the last day it covers is the one holding the instant before that bound. + * Reading that bound as inclusive would make every preset one day too long and + * shift `previousPeriod` by a day. * * ## [#18245] ⭐ `timezone` decides WHICH calendar, and it is not optional here * diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 007ee988b49..7d015231e03 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -1317,9 +1317,9 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // until #5930 step 4. // // [#16322] A RESOLVED window states its own upper reading, and its ends - // are instants the lowering never widens: the ten calendar presets stop - // BEFORE their end instant (`$lt`), the three rolling ones end at NOW - // and reach it (`$lte`). ⛔ An explicit `[a, b]` a CALLER wrote keeps the + // are instants the lowering never widens: every preset stops BEFORE its + // end instant (`$lt`), the three rolling ones before tomorrow begins, as + // `today` does. ⛔ An explicit `[a, b]` a CALLER wrote keeps the // inclusive reading it has always had — the #16179 separation, on this // side too. const bounds = resolved?.endExclusive ? { $gte: start, $lt: end } : { $gte: start, $lte: end }; diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 5b4daf1e458..160a2c268da 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -2070,11 +2070,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { out.push({ member: td.dimension, field: this.resolveFieldName(cube, td.dimension, 'dimension'), - // A window this path RESOLVED states its own upper reading: the ten - // calendar presets stop BEFORE their end instant (`$lt`, so two - // adjacent windows cannot both count a row stamped on the boundary — - // #16179's memory-side repair, same rule on this side), while the - // three rolling ones end at NOW, a moment they reach. + // A window this path RESOLVED states its own upper reading: every + // preset stops BEFORE its end instant (`$lt`, so two adjacent windows + // cannot both count a row stamped on the boundary — #16179's + // memory-side repair, same rule on this side), the three rolling ones + // before tomorrow begins, as `today` does. bounds: window.endExclusive ? { $gte: window.start, $lt: window.end } : { $gte: window.start, $lte: window.end }, diff --git a/packages/spec/src/data/date-range-presets.test.ts b/packages/spec/src/data/date-range-presets.test.ts index 27f6c8a7541..719f2a45b33 100644 --- a/packages/spec/src/data/date-range-presets.test.ts +++ b/packages/spec/src/data/date-range-presets.test.ts @@ -49,7 +49,7 @@ describe('date-range preset vocabulary — one source of truth, read by both the // vocabulary rather than trusting the table. for (const preset of DATE_RANGE_PRESETS) { const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; - for (const bound of end === null ? [start] : [start, end]) { + for (const bound of [start, end]) { const m = bound.match(DATE_MACRO_WRAPPED_RE); expect(m, `${preset} bound ${bound} must be a wrapped macro`).toBeTruthy(); expect(isDateMacroToken(m![1]), `${preset} bound ${bound} must be a KNOWN macro`).toBe(true); @@ -57,10 +57,10 @@ describe('date-range preset vocabulary — one source of truth, read by both the } }); - it('the rolling last_N_days windows prescribe {N_days_ago} with no upper macro', () => { - expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_7_days).toEqual(['{7_days_ago}', null]); - expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_30_days).toEqual(['{30_days_ago}', null]); - expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_90_days).toEqual(['{90_days_ago}', null]); + it('the rolling last_N_days windows prescribe {N_days_ago} through {today}', () => { + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_7_days).toEqual(['{7_days_ago}', '{today}']); + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_30_days).toEqual(['{30_days_ago}', '{today}']); + expect(DATE_RANGE_PRESET_MACRO_WINDOWS.last_90_days).toEqual(['{90_days_ago}', '{today}']); }); it('the refusal message names the value, the operator, the macro fix and the ISO fallback', () => { @@ -78,6 +78,20 @@ describe('date-range preset vocabulary — one source of truth, read by both the expect(window).toContain('{week_start}'); expect(window).toContain('{week_end}'); }); + + it('the refusal prescribes the CLOSED window for a rolling preset — never a $gte-only one', () => { + // A `$gte`-only prescription has no upper bound, so on a column that + // carries future dates it counts every later day too. + for (const [preset, start] of [ + ['last_7_days', '{7_days_ago}'], + ['last_30_days', '{30_days_ago}'], + ['last_90_days', '{90_days_ago}'], + ] as const) { + const message = bareDateRangePresetComparandMessage(preset, '$gte'); + expect(message, preset).toContain(`e.g. { $between: ['${start}', '{today}'] } — or an ISO date`); + expect(message, preset).not.toContain(`{ $gte: '${start}' }`); + } + }); }); /** @@ -96,10 +110,13 @@ describe('date-range preset vocabulary — one source of truth, read by both the */ 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}. */ +/** What each token the table uses denotes on {@link REFERENCE_DAY}. */ const TOKEN_DAY_ON_REFERENCE: Readonly> = { '{today}': '2026-07-15', '{yesterday}': '2026-07-14', + '{7_days_ago}': '2026-07-08', + '{30_days_ago}': '2026-06-15', + '{90_days_ago}': '2026-04-16', '{week_start}': '2026-07-13', // Monday of that week '{week_end}': '2026-07-19', // Sunday of that week '{last_week_start}': '2026-07-06', @@ -120,8 +137,8 @@ const TOKEN_DAY_ON_REFERENCE: Readonly> = { /** * 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. + * `[firstDay, lastDay]`. A ROLLING preset runs from `N` days ago through the + * whole of today — today is its last day, exactly as it is `today`'s. * * 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 @@ -129,7 +146,7 @@ const TOKEN_DAY_ON_REFERENCE: Readonly> = { * becomes visible instead of silent. */ const EXPECTED_WINDOW_DAYS: Readonly< - Record + Record > = { today: ['2026-07-15', '2026-07-15'], yesterday: ['2026-07-14', '2026-07-14'], @@ -141,9 +158,9 @@ const EXPECTED_WINDOW_DAYS: Readonly< 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, + last_7_days: ['2026-07-08', '2026-07-15'], + last_30_days: ['2026-06-15', '2026-07-15'], + last_90_days: ['2026-04-16', '2026-07-15'], }; const DAY_MS = 86_400_000; @@ -171,28 +188,25 @@ describe('the prescribed window covers exactly the days the preset names', () => 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']); + it('there is no open arm — every entry closes, the rolling family on {today}', () => { + // A `$gte`-only window selects every day AFTER it on a column that carries + // future dates, which is why no entry may leave its end out — the three + // rolling `last_N_days` windows included. 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); + const end: unknown = DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1]; + expect(typeof end, `${preset}: every window names its last day`).toBe('string'); + } + for (const preset of ['last_7_days', 'last_30_days', 'last_90_days'] as const) { + expect(DATE_RANGE_PRESET_MACRO_WINDOWS[preset][1], preset).toBe(DATE_RANGE_PRESET_MACRO_WINDOWS.today[1]); } }); - it('every closed entry names its FIRST day as start and its LAST day as end', () => { + it('every 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!]; + 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]); @@ -207,16 +221,19 @@ describe('the prescribed window covers exactly the days the preset names', () => // 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); + 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); + // The rolling windows run from `N` days ago through the whole of today. + expect(prescribedDayCount('2026-07-08', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_7_days[1]])).toBe(8); + expect(prescribedDayCount('2026-06-15', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_30_days[1]])).toBe(31); + expect(prescribedDayCount('2026-04-16', TOKEN_DAY_ON_REFERENCE[DATE_RANGE_PRESET_MACRO_WINDOWS.last_90_days[1]])).toBe(91); - // And the full census, so a period preset cannot drift either. + // And the full census, so no preset can 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]!; + 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`, @@ -224,6 +241,22 @@ describe('the prescribed window covers exactly the days the preset names', () => } }); + it('a rolling window counts a row five days ago and NOT a row five days ahead', () => { + // The measurement that found the open arm: one row dated today−5 and one + // dated today+5 on a `date` column. The console's lowering and the + // analytics preset arm count 1; a `$gte`-only window counts 2, because + // nothing stops it at today. Judged by the platform's own bare-day rule + // (`>= start` and `< nextUtcCalendarDay(end)`), as `$between` compiles it. + const rows = ['2026-07-10', '2026-07-20']; // REFERENCE_DAY − 5, REFERENCE_DAY + 5 + for (const preset of ['last_7_days', 'last_30_days', 'last_90_days'] as const) { + const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; + const from = midnightUtc(TOKEN_DAY_ON_REFERENCE[start]); + const stopsBefore = midnightUtc(nextUtcCalendarDay(TOKEN_DAY_ON_REFERENCE[end]) as string); + const counted = rows.filter((day) => midnightUtc(day) >= from && midnightUtc(day) < stopsBefore); + expect(counted, preset).toEqual(['2026-07-10']); + } + }); + it('the refusal prescribes a one-day window for a one-day preset', () => { const yday = bareDateRangePresetComparandMessage('yesterday', '$gte'); expect(yday).toContain("{ $between: ['{yesterday}', '{yesterday}'] }"); diff --git a/packages/spec/src/data/date-range-presets.ts b/packages/spec/src/data/date-range-presets.ts index 1d86a749413..02f902f2ff6 100644 --- a/packages/spec/src/data/date-range-presets.ts +++ b/packages/spec/src/data/date-range-presets.ts @@ -91,12 +91,15 @@ export function isDateRangePresetName(value: unknown): value is DateRangePreset * 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. + * **Every entry closes — there is no open arm.** A CALENDAR preset (one that + * names a period, `today` included) closes on its own last day, and the three + * ROLLING `last_N_days` windows close on `{today}`: the window runs from + * `{N_days_ago}` through the whole of today, the same last day `today` itself + * names. The reason is one reason for both families: `$gte` alone also + * selects every day AFTER the window on a column that carries future dates (a + * close date, a due date), so a `$gte`-only prescription for "last 30 days" + * would count next month's rows too. ⛔ So `end` is never absent: a reader + * that branches on a missing end is reading a shape this table no longer has. * * ## Why the table exists: PRESCRIPTION * @@ -115,7 +118,7 @@ export function isDateRangePresetName(value: unknown): value is DateRangePreset * spelling nor the convention can drift silently. */ export const DATE_RANGE_PRESET_MACRO_WINDOWS: Readonly< - Record + Record > = { today: ['{today}', '{today}'], yesterday: ['{yesterday}', '{yesterday}'], @@ -127,9 +130,9 @@ export const DATE_RANGE_PRESET_MACRO_WINDOWS: Readonly< last_quarter: ['{last_quarter_start}', '{last_quarter_end}'], this_year: ['{year_start}', '{year_end}'], last_year: ['{last_year_start}', '{last_year_end}'], - last_7_days: ['{7_days_ago}', null], - last_30_days: ['{30_days_ago}', null], - last_90_days: ['{90_days_ago}', null], + last_7_days: ['{7_days_ago}', '{today}'], + last_30_days: ['{30_days_ago}', '{today}'], + last_90_days: ['{90_days_ago}', '{today}'], }; /** @@ -158,10 +161,10 @@ export function bareDateRangePresetComparandMessage( preset: DateRangePreset, operator: string, ): string { + // Every preset's window is closed, so the prescription is always the + // two-bound `$between` from its first day through its last. const [start, end] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; - const window = end === null - ? `{ $gte: '${start}' }` - : `{ $between: ['${start}', '${end}'] }`; + const window = `{ $between: ['${start}', '${end}'] }`; return ( `"${preset}" is a dashboard date-range PRESET name, not a filter value. It is only ` + `understood by the dashboard date-filter positions (dateRange.defaultRange, a date ` diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts index 7d23527821c..5c3e2395545 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts @@ -28,7 +28,7 @@ export const entry: SemanticMigration = { + 'flow CRUD node\'s config.filter). The lint is likewise what refuses a preset in an ' + 'ordering filter triple wherever its walk meets one', replacement: - 'the date-macro window the preset already means — { $gte: "{30_days_ago}" } for ' + 'the date-macro window the preset already means — { $between: ["{30_days_ago}", "{today}"] } for ' + 'last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on ' + '(the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in ' + '@objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset ' diff --git a/packages/verify/README.md b/packages/verify/README.md index e25900eba2e..e472dbe5778 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -158,7 +158,7 @@ unauthored) where the widget declares no binding, and a widget with the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field. Each preset of `DATE_RANGE_PRESETS` is handed to `POST /analytics/dataset/query` as its `{date-macro}` window from the spec -(`DATE_RANGE_PRESET_MACRO_WINDOWS`, e.g. `{ $gte: '{7_days_ago}' }`), ANDed +(`DATE_RANGE_PRESET_MACRO_WINDOWS`, e.g. `{ $gte: '{7_days_ago}', $lte: '{today}' }`), ANDed with the widget's own filter as `runtimeFilter`, as the signed-in admin; the server resolves the tokens, and the ground truth resolves the same window with `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone diff --git a/packages/verify/src/dashboard-window.test.ts b/packages/verify/src/dashboard-window.test.ts index 024724c1dd5..6af008e498c 100644 --- a/packages/verify/src/dashboard-window.test.ts +++ b/packages/verify/src/dashboard-window.test.ts @@ -187,7 +187,7 @@ describe('runDashboardWindowProofs — the fixture app', () => { expect(report.timezone).toBe('UTC'); expect(report.presets.map((p) => p.preset)).toEqual([...DATE_RANGE_PRESETS]); const byPreset = new Map(report.presets.map((p) => [p.preset, p])); - expect(byPreset.get('last_7_days')!.condition).toEqual({ $gte: '{7_days_ago}' }); + expect(byPreset.get('last_7_days')!.condition).toEqual({ $gte: '{7_days_ago}', $lte: '{today}' }); expect(byPreset.get('this_month')!.condition).toEqual({ $gte: '{month_start}', $lte: '{month_end}' }); for (const p of report.presets) expect(p.resolved.$gte).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); diff --git a/packages/verify/src/dashboard-window.ts b/packages/verify/src/dashboard-window.ts index 36251c048f3..7d7f759d5d9 100644 --- a/packages/verify/src/dashboard-window.ts +++ b/packages/verify/src/dashboard-window.ts @@ -19,8 +19,8 @@ // `DatasetSchema`), never as authored. // - WHAT a preset's window is: `DATE_RANGE_PRESET_MACRO_WINDOWS` // (`@objectstack/spec/data`), the `{date-macro}` pair each preset of -// `DATE_RANGE_PRESETS` names — its first and last day, inclusive, the last -// open for the three rolling `last_N_days` presets. It is handed to the +// `DATE_RANGE_PRESETS` names — its first and last day, inclusive; the +// three rolling `last_N_days` presets end on `{today}`. It is handed to the // door as written, `{ $gte: start, $lte: end }`, ANDed with the widget's // own filter (`{ $and: [filter, window] }`, the shipped console's merge): // ⛔ never lowered to dates here. The server resolves the tokens. @@ -551,7 +551,7 @@ function fixtureDays(presets: DashboardWindowPreset[], today: string): string[] return [...days].sort().reverse(); } -/** Is `day` inside a resolved window? The table's convention: first and last day inclusive, the rolling end open. */ +/** Is `day` inside a resolved window? The table's convention: first and last day inclusive (every preset names its last day). */ const selects = (resolved: Record, day: string): boolean => day >= String(resolved.$gte) && (resolved.$lte === undefined || day <= String(resolved.$lte));