From dccf8549aa7db2da9b089719130d15e52503f348 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:25:07 +0000 Subject: [PATCH 1/3] wip(service-analytics): a preset dateRange on a date field compares the zone's calendar days Claude-Session: https://claude.ai/code/session_01Utp6Caqg6E9ggEvoUcYsbq Co-authored-by: Claude --- .../__tests__/dataset-drill-window.test.ts | 47 +++++++++++++++---- .../service-analytics/src/dataset-executor.ts | 39 +++++++++++++++ .../service-analytics/src/drill-window.ts | 16 +++---- .../src/preview-evaluator.ts | 6 ++- .../src/strategies/filter-normalizer.ts | 21 +++++++++ .../src/strategies/native-sql-strategy.ts | 16 ++++++- .../src/strategies/objectql-strategy.ts | 24 +++++++--- 7 files changed, 143 insertions(+), 26 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts b/packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts index 8db807be507..7b6a4ce24f9 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts @@ -32,6 +32,7 @@ import { installDefaultOrganization } from './default-organization.testkit.js'; const NOW = new Date('2026-09-09T12:34:56.789Z'); const CTX = { tenantId: 'org_A' } as ExecutionContext; const SHANGHAI = 'Asia/Shanghai'; +const LOS_ANGELES = 'America/Los_Angeles'; /** Q3 2026 in Shanghai: its July and October midnights, as UTC instants. */ const Q3_SHANGHAI = { gte: '2026-06-30T16:00:00.000Z', lt: '2026-09-30T16:00:00.000Z' }; @@ -233,6 +234,21 @@ describe('#27074 — a windowed dataset answer reports the window in drillRanges expect(result.drillRanges).toEqual([{ close_date: { field: 'close_date', gte: '2026-07-01', lt: '2026-10-01' } }]); }); + it('[#27172] a date field counts and drills the zone\'s calendar days, east and west of UTC', async () => { + for (const timezone of [SHANGHAI, LOS_ANGELES]) { + const seen: unknown[] = []; + const result = await service([{ stage: 'won', cnt: 3 }], 'date', seen).queryDataset( + pipeline, + { dimensions: ['stage'], measures: ['cnt'], timeDimensions: [{ dimension: 'close_date', dateRange: 'this_quarter' }], timezone }, + CTX, + ); + // The days the query counts: Q3 is Jul 1 through Sep 30 in any zone. + expect(closeDateBounds(seen[0]), timezone).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' }); + // …and the drill lists the same days. + expect(result.drillRanges, timezone).toEqual([{ close_date: { field: 'close_date', gte: '2026-07-01', lt: '2026-10-01' } }]); + } + }); + it('UTC resolves a datetime window at UTC midnight', async () => { const result = await service([{ stage: 'won', cnt: 3 }], 'datetime').queryDataset( pipeline, @@ -366,8 +382,7 @@ describe('#27074 — on a real engine, each row drill lists exactly the records return rows.map((r) => r.id).sort(); } - /** `null`: parity only, the ids the query itself counts being another card's question. */ - const cases: Array<[string, Record, Record | null]> = [ + const cases: Array<[string, Record, Record]> = [ [ 'stage × this_quarter on the datetime field, Shanghai', { dimensions: ['stage'], timeDimensions: [{ dimension: 'closed', dateRange: 'this_quarter' }], timezone: SHANGHAI }, @@ -393,14 +408,28 @@ describe('#27074 — on a real engine, each row drill lists exactly the records { won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] }, ], [ - // The query compares a `date` column with the preset's Shanghai-midnight - // instants read as their leading UTC day (`temporalStorageForm`), so it - // counts June 30 and not September 30 (measured, both strategies). The - // drill reports that same storage-form window, so it lists what the row - // counted; which days the query should count is not this card's. + // [#27172] A `date` column compares the zone's calendar days: Q3 is + // Jul 1 through Sep 30 in Shanghai as in UTC. It used to compare the + // preset's Shanghai-midnight instants read as their leading UTC day, so + // it counted d1 (Jun 30) and dropped d4 (Sep 30), on both strategies. 'stage × this_quarter on the date field, Shanghai', { dimensions: ['stage'], timeDimensions: [{ dimension: 'closed_on', dateRange: 'this_quarter' }], timezone: SHANGHAI }, - null, + { won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] }, + ], + [ + // West of UTC the zone's midnight falls on its own UTC day, so the + // calendar days were already right there; pinned so the fix keeps them. + 'stage × this_quarter on the date field, Los Angeles', + { dimensions: ['stage'], timeDimensions: [{ dimension: 'closed_on', dateRange: 'this_quarter' }], timezone: LOS_ANGELES }, + { won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] }, + ], + [ + // A `datetime` column keeps comparing instants: Los Angeles' Q3 opens at + // 2026-07-01T07:00Z, so d2 (Jun 30 09:00 there) is out and d5 (Sep 30 + // 09:00 there) is in. + 'stage × this_quarter on the datetime field, Los Angeles', + { dimensions: ['stage'], timeDimensions: [{ dimension: 'closed', dateRange: 'this_quarter' }], timezone: LOS_ANGELES }, + { won: ['d3', 'd6', 'd8'], lost: ['d10', 'd4', 'd5'] }, ], ]; @@ -417,7 +446,7 @@ describe('#27074 — on a real engine, each row drill lists exactly the records } expect(Object.keys(counted).length).toBeGreaterThan(0); expect(Object.fromEntries(Object.entries(listed).map(([k, ids]) => [k, ids.length]))).toEqual(counted); - if (expected) expect(listed).toEqual(expected); + expect(listed).toEqual(expected); }); } } diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 201a3ec38ef..41872f43db2 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -702,6 +702,45 @@ function inclusiveCalendarDayWindow( ]; } +/** + * [#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 }; +} + /** * Resolve which time dimension a `compareTo` shifts (#5011). * diff --git a/packages/services/service-analytics/src/drill-window.ts b/packages/services/service-analytics/src/drill-window.ts index 60da29a95b4..74efc87a53d 100644 --- a/packages/services/service-analytics/src/drill-window.ts +++ b/packages/services/service-analytics/src/drill-window.ts @@ -25,10 +25,12 @@ * the shared `dateRange` conformance kit: a preset through * `resolveAnalyticsDateRangeString` at the reference time zone, an explicit * `[a, b]` through `explicitDateRangeWindow`, untouched. - * 2. The `{ $gte, $lt }` / `{ $gte, $lte }` pair both strategies build from - * that window, through {@link normalizeDateRangeWindow}, the shared - * lowering. Its whole-day rule turns a bare-day `$lte` into `$lt` the next - * day. It is applied as for a `datetime` column on every column, which + * 2. The pair both strategies build from that window, + * {@link dateRangeWindowBounds}: a resolved preset's instants, or on a + * `date` column the calendar days it covers in the reference zone (#27172); + * an explicit window's own bounds. Then {@link normalizeDateRangeWindow}, + * the shared lowering. Its whole-day rule turns a bare-day `$lte` into + * `$lt` the next day. It is applied as for a `datetime` column on every column, which * `calendar-day.ts` records is order-equivalent to `<= day` on a `date` * column, so one `[gte, lt)` shape serves both. * 3. `temporalStorageForm`, the rule the engine reads every temporal @@ -42,7 +44,7 @@ */ import { temporalStorageForm } from '@objectstack/core'; -import { lowerDatasetCompareDateRange } from './dataset-executor.js'; +import { dateRangeWindowBounds, lowerDatasetCompareDateRange } from './dataset-executor.js'; import { normalizeDateRangeWindow, type NormalizedFilterNode } from './strategies/filter-normalizer.js'; /** How a drill bound is spelled: the storage form of the window's column. */ @@ -74,9 +76,7 @@ export function drillWindowBounds( form: DrillBoundForm, ): DrillWindowBounds | undefined { const window = lowerDatasetCompareDateRange(dateRange, timezone); - const bounds = window.endExclusive - ? { $gte: window.start, $lt: window.end } - : { $gte: window.start, $lte: window.end }; + const bounds = dateRangeWindowBounds(window, timezone, form === 'date'); const node = normalizeDateRangeWindow(MEMBER, bounds, WHOLE_DAY); const leaves: NormalizedFilterNode[] = node?.kind === 'and' ? node.children : node ? [node] : []; let gte: unknown; diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 894e77b3a80..bea04d2c204 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -41,6 +41,7 @@ import { type TemporalComparandKind, } from '@objectstack/core'; import { explicitDateRangeWindow } from './date-range-array-arm.js'; +import { dateRangeWindowBounds } from './dataset-executor.js'; import { DATASET_DURATION_UNIT_SECONDS } from '@objectstack/spec/ui'; import { durationMeasureIn } from './duration-measure-sql.js'; import type { DurationMeasureSpec } from './strategies/types.js'; @@ -826,7 +827,10 @@ export function evaluateAnalyticsQueryOverRows( // widens. This face kept its own copy of the rule here until #5930 step // 4, with a `'~'`-suffix reading of a full-timestamp end ("inclusive of // that instant's own sub-values") that no other face gives. - const bounds = endExclusive ? { $gte: start, $lt: end } : { $gte: start, $lte: end }; + // [#27172] On a column declared `date`, a resolved preset is the calendar + // days it covers in the reference zone — the pair `dateRangeWindowBounds` + // spells for the published faces, so the draft counts the published days. + const bounds = dateRangeWindowBounds({ start, end, endExclusive }, query.timezone, declaredType?.(field) === 'date'); const window = previewStorageComparands(lowerFilterCondition({ [field]: bounds }, lowering), kindOf); filtered = filtered.filter((r) => matchesWhere(previewStorageRow(r, kindOf), window)); } diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index f024a65f146..b017b889f71 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -2331,6 +2331,27 @@ export function declaredDatetimeLowering( }; } +/** + * [#27172] Is the column a `timeDimensions[].dateRange` member binds against + * declared `date`? Asked of the same hook and the same target as + * {@link declaredDatetimeLowering}, for `dateRangeWindowBounds` + * (`dataset-executor.ts`): a resolved preset filters a `date` column by the + * reference zone's calendar days, and every other column by its instants. + * A column the host cannot name a type for answers `false`, which keeps the + * instants it was filtered by before. + */ +export function declaredDateColumn( + ctx: StrategyContext, + target: (member: string) => { object: string; field: string }, +): (member: string) => boolean { + const declared = (ctx as DatasetScopedStrategyContext).declaredFieldType; + if (typeof declared !== 'function') return () => false; + return (member) => { + const { object, field } = target(member); + return declared.call(ctx, object, field) === 'date'; + }; +} + /** * [ADR-0053 D-D1 item 8, amended — #5930 step 4] A `timeDimensions[].dateRange` * window as the tree the strategies compile: the `{ $gte, $lte }` pair (or the 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 7de7823462d..2a89108a911 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -6,6 +6,7 @@ import { AggregationMetricType, NUMERIC_VALUE_TYPES, type AggregationFunction } import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext, DurationMeasureSpec } from './types.js'; import { declaredDatetimeLowering, + declaredDateColumn, findNestedRelationCondition, invalidFilterError, lowerAnalyticsWhere, @@ -51,6 +52,7 @@ import { scopedMeasureFilter } from '../scoped-measure-key.js'; import { isJsonStoredShape } from '../contains-membership-sql.js'; import { expandEmptyOperator } from '@objectstack/spec/data'; import { resolveAnalyticsDateRangeString } from '@objectstack/core'; +import { dateRangeWindowBounds } from '../dataset-executor.js'; // [#20889] What each aggregate function ANSWERS, and the `'number'` presenter — // the rule `driver-sql`'s own `aggregate()` applies, defined once in core. import { AGGREGATE_ANSWER_KIND, presentAsNumber } from '@objectstack/core'; @@ -1334,6 +1336,12 @@ export class NativeSQLStrategy implements AnalyticsStrategy { (member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf), 'type-blind', ); + // [#27172] Which `dateRange` member binds a `date` column, asked of the + // SAME target (`dateRangeWindowBounds`). + const dateColumn = declaredDateColumn( + ctx, + (member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf), + ); // [#21376, #21426] The comparand verdicts' member reader (both arms read // it), asked of the SAME target, and applied at the same three filter // positions, before each is normalized ({@link judgedComparands}). @@ -1450,7 +1458,13 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // `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 }; + // + // [#27172] On a `date` column a resolved window is the calendar days + // it covers in the reference zone, the pair `dateRangeWindowBounds` + // spells for every face; its instants slid a day early east of UTC. + const bounds = resolved + ? dateRangeWindowBounds(resolved, query.timezone, dateColumn(td.dimension)) + : { $gte: start, $lte: end }; const windowSql = this.compileFilterNode( normalizeDateRangeWindow(td.dimension, bounds, lowering), cube, diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 403733ac2b0..d9cce6dbb10 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -9,6 +9,7 @@ import { StandardErrorCode } from '@objectstack/spec/api'; import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext } from './types.js'; import { declaredDatetimeLowering, + declaredDateColumn, invalidFilterError, lowerAnalyticsWhere, NO_DATETIME_COLUMNS, @@ -35,7 +36,7 @@ import { invalidMemberError } from '../dataset-refusal.js'; import { projectedDimensions } from '../order-key-door.js'; // [#21316] The package's one row comparator and window, shared with the // dataset door's post-pass (#3588) — this face orders by it, never by a copy. -import { applyOrdering, applyWindow } from '../dataset-executor.js'; +import { applyOrdering, applyWindow, dateRangeWindowBounds } from '../dataset-executor.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor, type AnalyticsSqlDialect } from '../text-match-sql.js'; import { resolveAnalyticsDateRangeString } from '@objectstack/core'; @@ -327,7 +328,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // #3650 — and the time-dimension WINDOWS, through the SAME merge, so a // `dateRange` and a caller `where` bound on one field compose instead of // clobbering each other. - for (const { field, bounds } of this.dateRangeBounds(cube, query)) { + const dateColumn = declaredDateColumn( + ctx, + (member) => this.resolveStorageTarget(cube, member, objectName, relationshipReferenceOf(ctx)), + ); + for (const { field, bounds } of this.dateRangeBounds(cube, query, dateColumn)) { const extra = this.mergeFilterOperand(filter, field, bounds); if (extra) conjuncts.push(extra); } @@ -766,7 +771,11 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // the inclusive `<=` the engine runs there. This echo kept its own // type-blind copy of the rule, which rendered `< day+1` on every column, // until #5930 step 4. - for (const { member, bounds } of this.dateRangeBounds(cube, query)) { + const echoDateColumn = declaredDateColumn( + ctx, + (member) => this.resolveStorageTarget(cube, member, this.extractObjectName(cube), relationshipReferenceOf(ctx)), + ); + for (const { member, bounds } of this.dateRangeBounds(cube, query, echoDateColumn)) { const windowSql = this.renderFilterNodeSql(normalizeDateRangeWindow(member, bounds, echoLowering), cube, params, ctx); if (windowSql) whereParts.push(windowSql); } @@ -2071,6 +2080,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { private dateRangeBounds( cube: Cube, query: AnalyticsQuery, + dateColumn: (member: string) => boolean, ): Array<{ member: string; field: string; bounds: Record }> { const out: Array<{ member: string; field: string; bounds: Record }> = []; for (const td of query.timeDimensions ?? []) { @@ -2089,10 +2099,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // 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 }, + // before tomorrow begins, as `today` does. [#27172] On a `date` + // column it is the calendar days it covers in the reference zone, + // the pair `dateRangeWindowBounds` spells for every face. + bounds: dateRangeWindowBounds(window, query.timezone, dateColumn(td.dimension)), }); continue; } From 75b713dd1b7f492e6e5125b8cb6a797e4e8f96f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:29:28 +0000 Subject: [PATCH 2/3] wip(service-analytics): pin the draft preview's preset window on a date field Claude-Session: https://claude.ai/code/session_01Utp6Caqg6E9ggEvoUcYsbq Co-authored-by: Claude --- .../preview-preset-date-field.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/preview-preset-date-field.test.ts diff --git a/packages/services/service-analytics/src/__tests__/preview-preset-date-field.test.ts b/packages/services/service-analytics/src/__tests__/preview-preset-date-field.test.ts new file mode 100644 index 00000000000..bd61a27c711 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-preset-date-field.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27172] The draft preview counts a preset `dateRange` on a `date` field by + * the reference zone's calendar days, as the published faces do + * (`dateRangeWindowBounds`); a `datetime` field keeps comparing instants. + * + * MEASURED on `origin/main` `4d3a077f`, before the change: Shanghai's + * `this_quarter` on the `date` field selected Jun 30 and dropped Sep 30 — the + * preset's Shanghai-midnight instants read by their leading UTC day. The + * published-face half (both strategies, count and drill on one window) is + * `dataset-drill-window.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Cube } from '@objectstack/spec/data'; +import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js'; + +/** Frozen: Wednesday 2026-09-09 in UTC, Shanghai and Los Angeles, inside Q3. */ +const NOW = new Date('2026-09-09T12:34:56.789Z'); + +const CUBE = { + name: 'deals_ds', + sql: 'deal', + dimensions: { + id: { type: 'string', sql: 'id' }, + close_on: { type: 'time', sql: 'close_on' }, + closed_at: { type: 'time', sql: 'closed_at' }, + }, + measures: { count: { type: 'count', sql: '*' } }, +} as unknown as Cube; + +const DECLARED = (field: string): string | undefined => + field === 'close_on' ? 'date' : field === 'closed_at' ? 'datetime' : undefined; + +/** Either side of each edge of Q3 2026. */ +const ROWS = [ + { id: 'jun30', close_on: '2026-06-30', closed_at: '2026-06-30T15:59:59.000Z' }, // Shanghai Jun 30 23:59 + { id: 'jul01', close_on: '2026-07-01', closed_at: '2026-06-30T16:00:00.000Z' }, // Shanghai Jul 1 00:00 + { id: 'sep30', close_on: '2026-09-30', closed_at: '2026-09-30T15:59:59.000Z' }, // Shanghai Sep 30 23:59 + { id: 'oct01', close_on: '2026-10-01', closed_at: '2026-09-30T16:00:00.000Z' }, // Shanghai Oct 1 00:00 +]; + +function idsIn(dimension: 'close_on' | 'closed_at', timezone: string): string[] { + const result = evaluateAnalyticsQueryOverRows( + { + cube: 'deals_ds', + measures: ['count'], + dimensions: ['id'], + timeDimensions: [{ dimension, dateRange: 'this_quarter' }], + timezone, + }, + CUBE, + ROWS, + DECLARED, + ); + return result.rows.map((r) => String(r.id)).sort(); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('#27172 — the draft preview counts a preset on a date field by the zone\'s calendar days', () => { + it('a date field selects Jul 1 through Sep 30, east and west of UTC', () => { + for (const timezone of ['Asia/Shanghai', 'America/Los_Angeles', 'UTC']) { + expect(idsIn('close_on', timezone), timezone).toEqual(['jul01', 'sep30']); + } + }); + + it('a datetime field keeps comparing the zone\'s midnight instants', () => { + expect(idsIn('closed_at', 'Asia/Shanghai')).toEqual(['jul01', 'sep30']); + // Los Angeles' Q3 opens at 2026-07-01T07:00Z, after both June instants, + // and both September instants are still its Sep 30. + expect(idsIn('closed_at', 'America/Los_Angeles')).toEqual(['oct01', 'sep30']); + }); +}); From c9135bdbf62f41190fde5d3f7a99f2f1bfeb76cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:33:26 +0000 Subject: [PATCH 3/3] chore(changeset): service-analytics patch for the preset window on a date field Claude-Session: https://claude.ai/code/session_01Utp6Caqg6E9ggEvoUcYsbq Co-authored-by: Claude --- .changeset/27172-preset-date-field-calendar-days.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/27172-preset-date-field-calendar-days.md diff --git a/.changeset/27172-preset-date-field-calendar-days.md b/.changeset/27172-preset-date-field-calendar-days.md new file mode 100644 index 00000000000..f1f47be2be8 --- /dev/null +++ b/.changeset/27172-preset-date-field-calendar-days.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): a preset `dateRange` on a `date` field counts the reference time zone's calendar days, east of UTC too + +Clause-②: no +Metadata-protocol: none + +A dataset or analytics query with a preset `dateRange` (`this_quarter`, `last_30_days`, …) on a dimension whose field is `type: 'date'` counted the wrong calendar days under a reference time zone east of UTC. The preset resolves to the zone's midnight instants, and a `date` column read each instant by its leading UTC day, so the window slid one day early: in `Asia/Shanghai`, `this_quarter` (Q3 2026) counted 2026-06-30 and dropped 2026-09-30. West of UTC the days were already right. + +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 fix applies on both query strategies, in the drill ranges a windowed answer reports (so a drill still lists exactly what the row counted), and in the draft preview. An explicit `[a, b]` window is unchanged, and so is a field whose type the host cannot name.