Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27172-preset-date-field-calendar-days.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>, Record<string, string[]> | null]> = [
const cases: Array<[string, Record<string, unknown>, Record<string, string[]>]> = [
[
'stage × this_quarter on the datetime field, Shanghai',
{ dimensions: ['stage'], timeDimensions: [{ dimension: 'closed', dateRange: 'this_quarter' }], timezone: SHANGHAI },
Expand All @@ -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'] },
],
];

Expand All @@ -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);
});
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
39 changes: 39 additions & 0 deletions packages/services/service-analytics/src/dataset-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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).
*
Expand Down
16 changes: 8 additions & 8 deletions packages/services/service-analytics/src/drill-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/services/service-analytics/src/preview-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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}).
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading