From 7cd672d664320359f8eaaca067882837c697f003 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:32:50 +0000 Subject: [PATCH 1/3] wip(driver-memory): preset dateRange on a date field counts calendar days --- .../core/src/utils/analytics-date-range.ts | 90 +++++++++++- ...ry-analytics-date-range-date-field.test.ts | 138 ++++++++++++++++++ .../driver-memory/src/memory-analytics.ts | 25 +++- .../driver-memory/src/memory-driver.ts | 14 ++ .../service-analytics/src/dataset-executor.ts | 90 +----------- 5 files changed, 272 insertions(+), 85 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 09fc649fc7f..7901ad9f33b 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -83,7 +83,8 @@ import { analyticsDateRangeRefusalMessage, type DateRangePreset, } from '@objectstack/spec/data'; -import { zonedDateStartToUtcMs } from './datetime.js'; +import type { LoweredDateRangeWindow } from './analytics-date-range-conformance.js'; +import { bucketDateKey, zonedDateStartToUtcMs } from './datetime.js'; import { resolveFilterToken } from './filter-tokens.js'; /** @@ -280,3 +281,90 @@ export function resolveAnalyticsDateRangeString( if (!isDateRangePresetName(range)) throw analyticsDateRangeUnrecognizedError(range); return resolveAnalyticsDateRangePreset(range, options); } + +/** + * [#17973] The calendar days a resolved window covers in `timezone`, as the + * INCLUSIVE `[first, last]` pair. + * + * ⚠️ `endExclusive` is load-bearing: every preset reports the first instant + * AFTER the window (`this_month` ends at the next month's start), so the last + * day it covers is the one holding the instant before that bound. Reading that + * bound as inclusive makes every preset one day too long. + * + * [#18245] ⭐ `timezone` decides WHICH calendar. The bounds are INSTANTS, and + * every preset opens and closes at the reference zone's midnight + * ({@link tokenDayStart}), so reading them on the UTC calendar moves a boundary + * in opposite directions either side of the meridian. ⛔ That is a day-boundary + * projection, not an off-by-one: no constant makes both sides right. + * + * A bare `YYYY-MM-DD` bound is a calendar day already and is carried on the + * UTC proxy {@link zonedDateStartToUtcMs} yields for an unset zone. + * + * [#27272] Here, beside the lowering, for the reason the lowering is: the + * analytics faces of `driver-memory` and `@objectstack/service-analytics` both + * filter by it and cannot import each other. + */ +export function inclusiveCalendarDayWindow( + window: LoweredDateRangeWindow, + timezone?: string, +): [string, string] { + const endMs = windowBoundMs(window.end); + return [ + calendarDayOf(windowBoundMs(window.start), timezone), + calendarDayOf(window.endExclusive ? endMs - 1 : endMs, timezone), + ]; +} + +/** + * [#27172] The bound pair a lowered `dateRange` window filters one column by — + * the ONE spelling every analytics face shares (#27272: `driver-memory`'s cube + * face, and service-analytics' two strategies, drill and draft preview). + * + * ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date` + * column compares CALENDAR DAYS in the reference zone. + * + * - A resolved preset (`endExclusive`) on a `date` column is the calendar days + * it covers in `timezone` ({@link inclusiveCalendarDayWindow}), as the + * inclusive `{ $gte, $lte }` day pair. Q3 is 2026-07-01 through 2026-09-30 + * in any zone. + * - A resolved preset on any other column stays the instant pair it resolved + * to, stopping before its end: `{ $gte, $lt }`. + * - A caller's explicit `[a, b]` is handed back bound for bound, with its + * inclusive upper reading (#16179); each face gives a bare-day end its + * whole-day reading where the column needs one. + * + * ⛔ The preset's instants used to reach a `date` column as they are. A SQL + * `date` column reads an instant by its leading UTC day, so east of UTC the + * window slid one day early (#27172); `driver-memory` compared the instant to + * the stored bare day as TEXT, so from UTC westward it dropped the first day + * and counted the day after the last (#27272). + * + * @param dateColumn - Is the window's column declared `date`? A column whose + * type the caller cannot name answers `false` and keeps the instants. + */ +export function dateRangeWindowBounds( + window: LoweredDateRangeWindow, + timezone: string | undefined, + dateColumn: boolean, +): Record { + if (!window.endExclusive) return { $gte: window.start, $lte: window.end }; + if (!dateColumn) return { $gte: window.start, $lt: window.end }; + const [first, last] = inclusiveCalendarDayWindow(window, timezone); + return { $gte: first, $lte: last }; +} + +/** The epoch ms one window bound denotes: a bare day on its UTC proxy, else the instant. */ +function windowBoundMs(bound: string): number { + const ms = bound.length === 10 ? zonedDateStartToUtcMs(bound) : Date.parse(bound); + if (Number.isNaN(ms)) { + throw new TypeError(`[analytics-date-range] invalid date in a dateRange window: "${bound}"`); + } + return ms; +} + +/** The calendar day the instant at `ms` falls on in `timezone`: {@link bucketDateKey} at `'day'`. */ +function calendarDayOf(ms: number, timezone?: string): string { + // `ms` is always a finite epoch {@link windowBoundMs} just checked, so the + // labeller answers a key; `String` only narrows its `null` arm away. + return String(bucketDateKey(ms, 'day', timezone)); +} diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts new file mode 100644 index 00000000000..13653130089 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27272 — a preset `dateRange` on a `date` field counts the calendar days the + * period covers in the reference zone, on this driver's cube face too. + * + * ADR-0053's one window rule: a `date` field compares CALENDAR DAYS and a + * `datetime` field compares INSTANTS. Q3 is 2026-07-01 through 2026-09-30 in + * any zone. The rule is `@objectstack/core`'s `dateRangeWindowBounds`, the one + * spelling service-analytics' faces share (#27172). + * + * ⛔ Before the repair the face compared the preset's resolved instants (the + * zone's midnight, `2026-07-01T07:00:00.000Z` in Los Angeles) against the + * stored bare day as TEXT. West of UTC `'2026-07-01' < '2026-07-01T07:…'`, so + * `this_quarter` dropped Jul 1 and counted Oct 1. East of UTC the same text + * order happened to land on the right days. + * + * The declaration decides, as everywhere in this driver: a field declared + * `date` through `syncSchema` takes the calendar-day rule; a field whose type + * the driver was never told keeps the instant pair (`dateColumn: false`), as + * `dateRangeWindowBounds` does for a column whose type the caller cannot name. + */ + +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data'; + +const CUBE: Cube = { + name: 'deals', + title: 'Deals', + sql: 'deals', + measures: { + count: { label: 'Count', type: 'count', sql: 'id' }, + }, + dimensions: { + probe: { label: 'Probe', type: 'string', sql: 'probe' }, + closeDate: { label: 'Close Date', type: 'time', sql: 'close_date' }, + closedAt: { label: 'Closed At', type: 'time', sql: 'closed_at' }, + }, + public: true, +}; + +/** The card's four rows: one day either side of Q3, and Q3's own first and last day. */ +const DAYS = ['2026-06-30', '2026-07-01', '2026-09-30', '2026-10-01']; +const Q3 = ['2026-07-01', '2026-09-30']; + +afterEach(() => { + vi.useRealTimers(); +}); + +async function serviceOver(declare: boolean, rows: Record[]): Promise { + const driver = new InMemoryDriver({ initialData: { deals: rows } }); + await driver.connect(); + if (declare) { + await driver.syncSchema('deals', { + name: 'deals', + fields: { + probe: { type: 'text' }, + close_date: { type: 'date' }, + closed_at: { type: 'datetime' }, + }, + }); + } + return new MemoryAnalyticsService({ driver, cubes: [CUBE] }); +} + +async function probes( + service: MemoryAnalyticsService, + dimension: 'deals.closeDate' | 'deals.closedAt', + timezone: string, + dateRange: AnalyticsDateRange = 'this_quarter', +): Promise { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-09T12:00:00Z')); + const query: AnalyticsQuery = AnalyticsQuerySchema.parse({ + cube: 'deals', + measures: ['deals.count'], + dimensions: ['deals.probe'], + timeDimensions: [{ dimension, dateRange }], + timezone, + }); + const result = await service.query(query); + return result.rows.map((row) => String(row['deals.probe'])).sort(); +} + +describe('#27272 — a preset dateRange on a declared date field counts calendar days', () => { + const dateRows = DAYS.map((day, i) => ({ id: i + 1, probe: day, close_date: day })); + + for (const zone of ['America/Los_Angeles', 'Asia/Shanghai', 'UTC']) { + it(`${zone}: this_quarter selects Jul 1 through Sep 30`, async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', zone)).toEqual(Q3); + }); + } + + it('Los Angeles: this_month on Sep 9 selects Sep 30 and not Oct 1', async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', 'this_month')).toEqual(['2026-09-30']); + }); + + it('an explicit window keeps its inclusive bare-day reading, untouched', async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', ['2026-07-01', '2026-09-30'])).toEqual(Q3); + }); +}); + +describe('#27272 — a datetime field keeps comparing instants', () => { + // Los Angeles' Q3 opens at 2026-07-01T07:00Z and closes before 2026-10-01T07:00Z. + const instants = [ + '2026-07-01T06:59:59.000Z', // Jun 30, 23:59:59 local — outside + '2026-07-01T07:00:00.000Z', // Jul 1, 00:00 local — inside + '2026-10-01T06:59:59.000Z', // Sep 30, 23:59:59 local — inside + '2026-10-01T07:00:00.000Z', // Oct 1, 00:00 local — outside + ]; + const rows = instants.map((iso, i) => ({ id: i + 1, probe: iso, closed_at: iso })); + + it('Los Angeles: this_quarter on a declared datetime is the zone-midnight instant pair', async () => { + const service = await serviceOver(true, rows); + expect(await probes(service, 'deals.closedAt', 'America/Los_Angeles')).toEqual([ + '2026-07-01T07:00:00.000Z', + '2026-10-01T06:59:59.000Z', + ]); + }); +}); + +describe('#27272 — a field whose type was never declared keeps the instant pair', () => { + it('Los Angeles: an undeclared column is compared as the instants the preset resolved to', async () => { + const rows = ['2026-07-01T06:59:59.000Z', '2026-07-01T07:00:00.000Z'].map((iso, i) => ({ + id: i + 1, + probe: iso, + close_date: iso, + })); + const service = await serviceOver(false, rows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles')).toEqual(['2026-07-01T07:00:00.000Z']); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index 66b171589ce..a0d8cdeee81 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -30,6 +30,10 @@ import { // the ONE refusal for a string outside it, shared with the SQL analytics // path so the two backends cannot answer one input differently again. resolveAnalyticsDateRangeString, + // [#27272] ADR-0053's one window rule — a `date` column compares the + // calendar days the period covers in the reference zone — shared with + // service-analytics' faces (#27172) for the same reason as the lowering. + dateRangeWindowBounds, // [#17596] The ONE constructor for that envelope, reused for the ARRAY arm's // own refusal: the shared conformance kit reads `code` + `status`, so the // pair must have a single origin across every face. ⛔ Only the SENTENCE is @@ -1196,7 +1200,11 @@ export class MemoryAnalyticsService implements IAnalyticsService { // the aggregation with NO time predicate emitted at all. const resolved: ResolvedDateRange = Array.isArray(timeDim.dateRange) ? { bounds: explicitDateRangeWindow(timeDim.dateRange), endExclusive: false } - : this.parseDateRangeString(timeDim.dateRange, query.timezone); + : this.parseDateRangeString( + timeDim.dateRange, + query.timezone, + this.driver.isDeclaredDateField(this.extractTableName(cube.sql), fieldPath), + ); const range = resolved.bounds; // The window matches BOTH stored forms of a datetime value — the @@ -2243,13 +2251,26 @@ export class MemoryAnalyticsService implements IAnalyticsService { * explicit `[a, b]` array arm never arrives here at all: it is discriminated * at the call site and keeps its published `$lte` reading. * + * [#27272] On a column the driver holds as declared `date` (`dateColumn`) + * the window is instead the calendar days it covers, with an inclusive end. + * * @throws the ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope for * a string outside `DATE_RANGE_PRESETS` — the same code, status and * wording the schema door and the SQL analytics path answer with, since * all three call one constructor. */ - private parseDateRangeString(range: string, timezone?: string): ResolvedDateRange { + private parseDateRangeString(range: string, timezone: string | undefined, dateColumn: boolean): ResolvedDateRange { const window = resolveAnalyticsDateRangeString(range, { timezone }); + if (dateColumn) { + // [#27272] A declared `date` column holds bare days, and the preset's + // instants (the zone's midnight) compared to them as TEXT dropped the + // first day and counted the day after the last from UTC westward. ADR-0053's + // one rule, through its one spelling: the calendar days the period covers + // in the reference zone, as the inclusive day pair — which the bound + // construction then reads exactly as a caller's bare-day `[a, b]`. + const days = dateRangeWindowBounds(window, timezone, true); + return { bounds: [days.$gte, days.$lte], endExclusive: false }; + } return { bounds: [window.start, window.end], endExclusive: window.endExclusive }; } diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index d6609e52f3e..161820eb595 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -2359,6 +2359,20 @@ export class InMemoryDriver implements IDataDriver { return this.toStorageForm(object, field, value); } + /** + * [#27272] Is `field` on `object` declared `date`? For the analytics (cube) + * face's `dateRange` window, which compares a `date` column by CALENDAR DAYS + * and every other column by instants (ADR-0053, `dateRangeWindowBounds`). + * + * Keyed on the DECLARED kind ({@link temporalFields}, populated by + * {@link syncSchema}) for the reason {@link filterComparandStorageForm} is: + * only the driver holds it. An object never declared answers `false` — the + * driver does not guess types from values. + */ + isDeclaredDateField(object: string | undefined, field: string): boolean { + return this.temporalKind(object, field) === 'date'; + } + /** * [#5374] The pattern a `$contains` / `$notContains` comparand becomes — the * substring rule itself, for the analytics (cube) face. diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 41872f43db2..dac48bb1ccf 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -13,7 +13,9 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { bucketDateKey, bucketKeyToCalendarRange, + dateRangeWindowBounds, filterTokenContextFrom, + inclusiveCalendarDayWindow, resolveAnalyticsDateRangeString, resolveFilterTokens, wallClockToUtcMs, @@ -658,88 +660,12 @@ export function lowerDatasetCompareDateRange( return { start, end, endExclusive: false }; } -/** - * [#17973] The calendar days a lowered window covers, as the INCLUSIVE - * `[first, last]` pair every piece of `compareTo` math in this module takes. - * - * {@link shiftRange} measures `previousPeriod`'s length as a count of whole - * days between two day-starts, and {@link alignedCompareBucketKey} reads - * `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: 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 - * - * The bounds arriving here are INSTANTS, and the ten calendar presets open and - * close at the reference zone's midnight — `analytics-date-range.ts` renders - * both through `zonedDateStartToUtcMs` precisely so they do. Reading those - * instants on the UTC calendar therefore moves a boundary in every zone whose - * midnight is not UTC's, and it moves it in OPPOSITE directions either side of - * the meridian. MEASURED on `e0d05538c`, `this_month` + `previousYear` frozen - * at 2026-09-09: `Asia/Shanghai` opened at `2025-08-31` (its September midnight - * is the previous UTC day) and `America/New_York` closed at `2025-10-01` (its - * October midnight is the next UTC day) — each 31 days against a 30-day - * September, each still an ordinary `200`. - * - * ⛔ That is a day-boundary projection, ⛔ not an off-by-one: no constant makes - * both sides right, which is why the caller threads the SAME zone - * {@link DatasetExecutor.buildQuery} resolves the primary pass in. - */ -function inclusiveCalendarDayWindow( - window: LoweredDateRangeWindow, - timezone?: string, -): [string, string] { - const endMs = boundInstantMs(window.end); - return [ - calendarDayAt(boundInstantMs(window.start), timezone), - calendarDayAt(window.endExclusive ? endMs - 1 : endMs, timezone), - ]; -} - -/** - * [#27172] The bound pair a lowered `dateRange` window filters one column by — - * the ONE spelling the count (both strategies, the draft preview) and the drill - * (`drill-window.ts`) share. - * - * ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date` - * column compares CALENDAR DAYS in the reference zone. - * - * - A resolved preset (`endExclusive`) on a `date` column is the calendar days - * it covers in `timezone`, through this module's whole-day rule - * {@link inclusiveCalendarDayWindow} — the projection `compareTo` already - * shifts — as the inclusive `{ $gte, $lte }` day pair. - * - A resolved preset on any other column stays the instant pair it resolved - * to, stopping before its end: `{ $gte, $lt }`. - * - A caller's explicit `[a, b]` is handed back bound for bound, with its - * inclusive upper reading (#16179); the shared lowering gives a bare-day end - * its whole-day reading where the column needs one. - * - * ⛔ The preset's instants used to reach a `date` column as they are, and the - * column reads an instant by its leading UTC day (`temporalStorageForm`). A - * zone east of UTC opens at midnight on the PREVIOUS UTC day, so the window - * slid one day early: Shanghai's `this_quarter` counted Jun 30 and dropped - * Sep 30, on both strategies. West of UTC the leading day happens to be the - * zone's own, which is why only eastern tenants saw it. - * - * @param dateColumn - Is the window's column declared `date`? A column whose - * type the caller cannot name answers `false` and keeps the instants. - */ -export function dateRangeWindowBounds( - window: LoweredDateRangeWindow, - timezone: string | undefined, - dateColumn: boolean, -): Record { - if (!window.endExclusive) return { $gte: window.start, $lte: window.end }; - if (!dateColumn) return { $gte: window.start, $lt: window.end }; - const [first, last] = inclusiveCalendarDayWindow(window, timezone); - return { $gte: first, $lte: last }; -} +// [#17973] [#27172] [#27272] The calendar days a lowered window covers +// (`inclusiveCalendarDayWindow`) and the bound pair a window filters one column +// by (`dateRangeWindowBounds`) live in `@objectstack/core`, beside the +// lowering: `driver-memory`'s cube face filters by the same rule and cannot +// import this package. Re-exported so this package's faces keep one import. +export { dateRangeWindowBounds }; /** * Resolve which time dimension a `compareTo` shifts (#5011). From 46faf53dc11fabd8fabcd3440eff104382b6aa0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:34:34 +0000 Subject: [PATCH 2/3] wip2 --- .../src/utils/analytics-date-range.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index 2c58fec5929..c8fbaec8403 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -42,6 +42,7 @@ import { resolveAnalyticsDateRangePreset, resolveAnalyticsDateRangeString, analyticsDateRangeUnrecognizedError, + dateRangeWindowBounds, } from './analytics-date-range.js'; /** A frozen reference instant, deliberately mid-week, mid-month, mid-quarter. */ @@ -369,3 +370,27 @@ describe('#17341 — the END of every window is one CALENDAR day after the presc expect(end - lastDayStart).not.toBe(24 * 60 * 60 * 1000); }); }); + +describe('dateRangeWindowBounds — ADR-0053\'s one window rule (#27172, #27272)', () => { + const now = new Date('2026-09-09T12:00:00Z'); + + for (const timezone of ['America/Los_Angeles', 'UTC', 'Asia/Shanghai']) { + it(`${timezone}: this_quarter on a date column is Jul 1 through Sep 30`, () => { + const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone }); + expect(dateRangeWindowBounds(window, timezone, true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' }); + }); + } + + it('a preset on any other column keeps its instants and stops before its end', () => { + const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone: 'America/Los_Angeles' }); + expect(dateRangeWindowBounds(window, 'America/Los_Angeles', false)).toEqual({ + $gte: '2026-07-01T07:00:00.000Z', + $lt: '2026-10-01T07:00:00.000Z', + }); + }); + + it('a caller\'s explicit window is handed back bound for bound, inclusive', () => { + const window = { start: '2026-07-01', end: '2026-09-30', endExclusive: false }; + expect(dateRangeWindowBounds(window, 'America/Los_Angeles', true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' }); + }); +}); From 4ddf1d4adbccf21612ded5755ac0850165c608fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:42:47 +0000 Subject: [PATCH 3/3] fix(driver-memory): a preset dateRange on a date field counts the reference zone's calendar days Move ADR-0053's one window rule (dateRangeWindowBounds and its calendar-day projection) into @objectstack/core beside the dateRange lowering, so the in-memory cube face and service-analytics share one spelling. The memory face asks the driver whether the field is declared date and, if so, filters by the calendar days the preset covers in the reference zone. Claude-Session: https://claude.ai/code/session_01Utp6CbomWvWL9K2F1XBS6p Co-authored-by: Claude --- ...272-memory-preset-date-field-calendar-days.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/27272-memory-preset-date-field-calendar-days.md diff --git a/.changeset/27272-memory-preset-date-field-calendar-days.md b/.changeset/27272-memory-preset-date-field-calendar-days.md new file mode 100644 index 00000000000..e595b132a8b --- /dev/null +++ b/.changeset/27272-memory-preset-date-field-calendar-days.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-memory': patch +'@objectstack/core': patch +'@objectstack/service-analytics': patch +--- + +fix(driver-memory): a preset `dateRange` on a `date` field counts the reference time zone's calendar days on the in-memory analytics face + +Clause-②: yes (widening) +Metadata-protocol: none + +An analytics query on the in-memory driver with a preset `dateRange` (`this_quarter`, `this_month`, …) on a dimension whose field is declared `type: 'date'` counted the wrong calendar days from UTC westward. The face compared the preset's resolved instants (the zone's midnight, `2026-07-01T07:00:00.000Z` in `America/Los_Angeles`) against the stored bare day as text, so `this_quarter` (Q3 2026) dropped 2026-07-01 and counted 2026-10-01, in `America/Los_Angeles` and in `UTC` alike. + +Now a `date` field compares the calendar days the period covers in the reference zone (Q3 is 2026-07-01 through 2026-09-30 in any zone), and a `datetime` field keeps comparing instants: the same rule service-analytics' faces apply. An explicit `[a, b]` window is unchanged, and so is a field whose type the driver was never told through `syncSchema`. + +The rule now has one home, `@objectstack/core`: `dateRangeWindowBounds` and `inclusiveCalendarDayWindow` are exported from it, and `@objectstack/service-analytics` calls them instead of its own copy (no behaviour change there). `InMemoryDriver` gains `isDeclaredDateField(object, field)`, which answers from the declared schema.