diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index abeb7affe5..9851dd32cb 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -37,7 +37,7 @@ import { type DateRangePreset, } from '@objectstack/spec/data'; import { resolveFilterToken } from './filter-tokens.js'; -import { zonedDateStartToUtcMs } from './datetime.js'; +import { zonedDateStartToUtcMs, nextUtcCalendarDay } from './datetime.js'; import { resolveAnalyticsDateRangePreset, resolveAnalyticsDateRangeString, @@ -52,6 +52,30 @@ const ROLLING: readonly DateRangePreset[] = ['last_7_days', 'last_30_days', 'las const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +/** + * Reference instants the END pin sweeps, chosen so a wrong shift cannot hide in + * a tidy month: two DST transition days (a 23- and a 25-hour calendar day in + * the zones below), both year boundaries, a quarter/month boundary, and a + * February. + */ +const SWEEP_INSTANTS = [ + '2026-09-09T12:34:56.789Z', + '2026-01-01T00:30:00.000Z', + '2026-02-28T10:00:00.000Z', + '2026-03-08T08:00:00.000Z', // America/New_York starts DST — a 23-hour day + '2026-03-31T23:00:00.000Z', // Pacific/Chatham's DST-end week, and a quarter edge + '2026-04-05T02:00:00.000Z', // Pacific/Chatham ends DST — a 25-hour day + '2026-11-01T05:30:00.000Z', // America/New_York ends DST — a 25-hour day + '2026-12-31T23:59:00.000Z', +].map((iso) => new Date(iso)); + +const SWEEP_ZONES: ReadonlyArray = [ + undefined, + 'Asia/Shanghai', + 'America/New_York', + 'Pacific/Chatham', +]; + describe('#16322 — every declared preset resolves to a real window', () => { it('resolves all thirteen, and none of them is the name of the preset', () => { for (const preset of DATE_RANGE_PRESETS) { @@ -240,3 +264,81 @@ describe('#16322 — a string outside the vocabulary is REFUSED, not widened', ( .toEqual(resolveAnalyticsDateRangePreset('today', { now: NOW })); }); }); + +describe('#17341 — the END of every calendar 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, + ); + + 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) { + 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); + }); + + it.each([...PRESCRIBED_CLOSED])( + "%s stops before the day AFTER the prescription's inclusive last day", + (preset) => { + 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()}`; + const lastDay = String(resolveFilterToken(prescribedEnd, { now, timezone: tz })); + const stopsBefore = nextUtcCalendarDay(lastDay); + expect(stopsBefore, where).not.toBeNull(); + const expected = new Date(zonedDateStartToUtcMs(stopsBefore!, tz)).toISOString(); + expect(resolveAnalyticsDateRangePreset(preset, { now, timezone: tz }).end, where) + .toBe(expected); + } + } + }, + ); + + it('a CALENDAR day, not 86_400_000 ms — the DST cell that tells the two apart', () => { + // Pacific/Chatham leaves DST on the first Sunday of April, so + // 2026-04-05 is 25 hours long there — and it is exactly the day + // `this_week`'s prescription names as its last. A "+ one day" written + // in milliseconds lands an hour inside the window, silently, and every + // other assertion in this file still passes. + const now = new Date('2026-04-05T02:00:00.000Z'); + const tz = 'Pacific/Chatham'; + const lastDay = String(resolveFilterToken('week_end', { now, timezone: tz })); + const lastDayStart = zonedDateStartToUtcMs(lastDay, tz); + const end = Date.parse(resolveAnalyticsDateRangePreset('this_week', { now, timezone: tz }).end); + 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 ceec30e7a1..4f6d4829ca 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -116,9 +116,21 @@ export interface ResolvedAnalyticsDateRange { * `this_week` ends at `next_week_start`, not at `week_end`. 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 ends are one day - * earlier for the eight period presets. The STARTS agree exactly, and the test - * pins that they do. + * 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 + * 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). */ const PRESET_WINDOW_TOKENS: Readonly< Record