Skip to content

Commit 5b5cff1

Browse files
fix(service-analytics): a preset dateRange on a date field counts the reference zone's calendar days, east of UTC too (#27271)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27172 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:报表的预设日期范围(如“本季度”)落在 `date` 类型字段上时,按参考时区的日历日计数;`datetime` 字段仍按时刻比较。两条查询策略、钻取范围、草稿预览共用同一处取界。 - 为什么改:东八区等 UTC 以东的租户,“本季度”会多算 6 月 30 日、漏掉 9 月 30 日,报表数字静默出错(西半球恰好正确)。 - 风险与回滚:只影响“预设范围 × 已声明为 date 的字段”;显式起止日期、datetime 字段、类型未知字段行为不变。回滚即还原本 PR。 - 先看:`packages/services/service-analytics/src/dataset-executor.ts`(新增 `dateRangeWindowBounds`,复用既有 `inclusiveCalendarDayWindow`)、`strategies/native-sql-strategy.ts` 与 `strategies/objectql-strategy.ts` 的预设分支、`drill-window.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/dataset-drill-window.test.ts`,预期 26 条全过,其中上海 date 字段 this_quarter 两条策略都只列 7/1–9/30 的记录。 - 需要你做的:无,席位落地。 ## What changes A preset `dateRange` (`this_quarter`, `last_30_days`, …) resolves to the reference zone's midnight instants. On a `date` column those instants were compared as they are, and the column reads an instant by its leading UTC day (`temporalStorageForm`), so east of UTC the window slid one day early: in `Asia/Shanghai`, `this_quarter` counted 2026-06-30 and dropped 2026-09-30, on both strategies (measured red below). West of UTC the leading UTC day happens to be the zone's own day. The triage rule (6465987107): a `date` field compares calendar days in the reference zone (ADR-0053), a `datetime` compares instants, and the executor's existing whole-day rule is used, not a new resolver. - **The existing rule, reused.** `inclusiveCalendarDayWindow` in `packages/services/service-analytics/src/dataset-executor.ts` already projects a lowered window onto the calendar days it covers in the reference zone (through `bucketDateKey`, the exact inverse of the `zonedDateStartToUtcMs` the preset resolver renders bounds with); `compareTo` shifts exactly that projection. No new resolver and no new calendar arithmetic. - **One spelling for every face.** New `dateRangeWindowBounds(window, timezone, dateColumn)` beside it: a resolved preset on a `date` column becomes the inclusive day pair `{ $gte: first, $lte: last }`; on any other column it stays `{ $gte, $lt }` on the instants; an explicit `[a, b]` stays `{ $gte, $lte }` bound for bound. It is called by `NativeSQLStrategy` (compile), `ObjectQLStrategy` (execute and the SQL echo), `drillWindowBounds` (so drill and count stay on one lowering, as #27169 set up) and the draft preview's `evaluateAnalyticsQueryOverRows`. - **Which column is `date`.** New `declaredDateColumn(ctx, target)` in `strategies/filter-normalizer.ts`, asked of the same `declaredFieldType` hook and the same storage target as `declaredDatetimeLowering`. A column whose type the host cannot name answers `false` and keeps the instants it was filtered by before. - The `[gte, lt)` drill bound for a `date` window now reads `2026-07-01` .. `2026-10-01` in Shanghai, the same days the count uses. Bounded in-place fix, declared: the draft preview face (`preview-evaluator.ts`, inside the claim's file surface) had the same defect, measured red on `4d3a077f` (Shanghai counted Jun 30 and dropped Sep 30); it takes the same helper, pinned by its own test. ## Tests - `dataset-drill-window.test.ts`: the real-engine case "stage × this_quarter on the date field, Shanghai" now asserts the ids (`won: d2 d3 d6 d8`, `lost: d10 d4`) instead of parity only, on both strategies; added Los Angeles × `date` (west, unchanged days) and Los Angeles × `datetime` (instants: d2 out, d5 in), each also checking the drill lists exactly what the row counted. A unit case pins the engine bounds (`{ $gte: '2026-07-01', $lte: '2026-09-30' }`) and the drill range for Shanghai and Los Angeles. - New `preview-preset-date-field.test.ts`: the draft preview on a `date` field selects Jul 1 through Sep 30 in Shanghai, Los Angeles and UTC; a `datetime` field keeps the zone's instants. ## Acceptance notes - `driver-memory`'s cube face (`MemoryAnalyticsService`) is outside this claim's file surface and has the mirror-image defect: measured in-process on this branch, `this_quarter` on a `date` field (declared or not) answers Jul 1 and Sep 30 in Shanghai (right, by text-prefix ordering of a bare day against the instant) but Sep 30 and Oct 1 in Los Angeles (drops Jul 1, counts Oct 1). Reported to the seat for its own card; not touched here. - An explicit `[a, b]` window keeps the engine's UTC calendar-day reading on a `datetime` column (ADR-0053 D-D), unchanged and still pinned. - A field whose type the host cannot name keeps the instants (the drill already omits its window under a non-UTC zone). ## Verification All at head `c9135bdb` on this branch, base `origin/main` `4d3a077f`. <details><summary>Red first, on base sources (fix restored to origin/main 4d3a077, tests from this branch)</summary> ``` dataset-drill-window.test.ts: Tests 3 failed | 23 passed (26) x [#27172] a date field counts and drills the zone's calendar days (Asia/Shanghai): received { $gte: '2026-06-30T16:00:00.000Z', $lt: '2026-09-30T16:00:00.000Z' } x stage x this_quarter on the date field, Shanghai (native) won +d1, lost -d4 x stage x this_quarter on the date field, Shanghai (objectql) won +d1, lost -d4 preview-preset-date-field.test.ts (preview-evaluator.ts at 4d3a077): Tests 1 failed | 1 passed Asia/Shanghai: expected [jul01, sep30], received [jul01, jun30] ``` </details> <details><summary>Green at c9135bd</summary> ``` pnpm --filter @objectstack/service-analytics test Test Files 195 passed (195) · Tests 4705 passed | 282 skipped VERDICT command-exit 0 pnpm --filter @objectstack/service-analytics typecheck VERDICT command-exit 0 eslint --no-inline-config on the 8 touched files exit 0 ``` </details> <details><summary>Ablation, once (scripts/ablation-replace.mjs; restored to the HEAD blob 41872f4, git diff HEAD empty)</summary> Mutation in `dateRangeWindowBounds`: `if (!dateColumn) return { $gte: window.start, $lt: window.end };` became `if (dateColumn || !dateColumn) …` (the date arm never taken). Anchor 1 to 0, blob 41872f4 to 6b76ccd50391. ``` Tests 4 failed | 24 passed (28) x a date field selects Jul 1 through Sep 30, east and west of UTC (preview) x [#27172] a date field counts and drills the zone's calendar days, east and west of UTC x stage x this_quarter on the date field, Shanghai (native) x stage x this_quarter on the date field, Shanghai (objectql) ``` The Los Angeles date and datetime cases stay green under the mutation, as expected: west of UTC the leading UTC day was already the zone's day. </details> <details><summary>Derived gates (dispatch-gates --commands, 60 families) at c9135bd</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `check:dual-build-cjs-loads` exit 3 is PREREQUISITE NOT MET (it reads every package's dist; only this package's dependency closure was built): NOT MEASURED, CI's. `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone (its pinned fixture commit was unreachable); after fetching that commit it exits 0, as recorded. Reconciliation, `dispatch-gates --ran`: 60 derived, 59 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Caqg6E9ggEvoUcYsbq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 38bbc2f commit 5b5cff1

9 files changed

Lines changed: 236 additions & 26 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a preset `dateRange` on a `date` field counts the reference time zone's calendar days, east of UTC too
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.

‎packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { installDefaultOrganization } from './default-organization.testkit.js';
3232
const NOW = new Date('2026-09-09T12:34:56.789Z');
3333
const CTX = { tenantId: 'org_A' } as ExecutionContext;
3434
const SHANGHAI = 'Asia/Shanghai';
35+
const LOS_ANGELES = 'America/Los_Angeles';
3536
/** Q3 2026 in Shanghai: its July and October midnights, as UTC instants. */
3637
const Q3_SHANGHAI = { gte: '2026-06-30T16:00:00.000Z', lt: '2026-09-30T16:00:00.000Z' };
3738

@@ -233,6 +234,21 @@ describe('#27074 — a windowed dataset answer reports the window in drillRanges
233234
expect(result.drillRanges).toEqual([{ close_date: { field: 'close_date', gte: '2026-07-01', lt: '2026-10-01' } }]);
234235
});
235236

237+
it('[#27172] a date field counts and drills the zone\'s calendar days, east and west of UTC', async () => {
238+
for (const timezone of [SHANGHAI, LOS_ANGELES]) {
239+
const seen: unknown[] = [];
240+
const result = await service([{ stage: 'won', cnt: 3 }], 'date', seen).queryDataset(
241+
pipeline,
242+
{ dimensions: ['stage'], measures: ['cnt'], timeDimensions: [{ dimension: 'close_date', dateRange: 'this_quarter' }], timezone },
243+
CTX,
244+
);
245+
// The days the query counts: Q3 is Jul 1 through Sep 30 in any zone.
246+
expect(closeDateBounds(seen[0]), timezone).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' });
247+
// …and the drill lists the same days.
248+
expect(result.drillRanges, timezone).toEqual([{ close_date: { field: 'close_date', gte: '2026-07-01', lt: '2026-10-01' } }]);
249+
}
250+
});
251+
236252
it('UTC resolves a datetime window at UTC midnight', async () => {
237253
const result = await service([{ stage: 'won', cnt: 3 }], 'datetime').queryDataset(
238254
pipeline,
@@ -366,8 +382,7 @@ describe('#27074 — on a real engine, each row drill lists exactly the records
366382
return rows.map((r) => r.id).sort();
367383
}
368384

369-
/** `null`: parity only, the ids the query itself counts being another card's question. */
370-
const cases: Array<[string, Record<string, unknown>, Record<string, string[]> | null]> = [
385+
const cases: Array<[string, Record<string, unknown>, Record<string, string[]>]> = [
371386
[
372387
'stage × this_quarter on the datetime field, Shanghai',
373388
{ 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
393408
{ won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] },
394409
],
395410
[
396-
// The query compares a `date` column with the preset's Shanghai-midnight
397-
// instants read as their leading UTC day (`temporalStorageForm`), so it
398-
// counts June 30 and not September 30 (measured, both strategies). The
399-
// drill reports that same storage-form window, so it lists what the row
400-
// counted; which days the query should count is not this card's.
411+
// [#27172] A `date` column compares the zone's calendar days: Q3 is
412+
// Jul 1 through Sep 30 in Shanghai as in UTC. It used to compare the
413+
// preset's Shanghai-midnight instants read as their leading UTC day, so
414+
// it counted d1 (Jun 30) and dropped d4 (Sep 30), on both strategies.
401415
'stage × this_quarter on the date field, Shanghai',
402416
{ dimensions: ['stage'], timeDimensions: [{ dimension: 'closed_on', dateRange: 'this_quarter' }], timezone: SHANGHAI },
403-
null,
417+
{ won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] },
418+
],
419+
[
420+
// West of UTC the zone's midnight falls on its own UTC day, so the
421+
// calendar days were already right there; pinned so the fix keeps them.
422+
'stage × this_quarter on the date field, Los Angeles',
423+
{ dimensions: ['stage'], timeDimensions: [{ dimension: 'closed_on', dateRange: 'this_quarter' }], timezone: LOS_ANGELES },
424+
{ won: ['d2', 'd3', 'd6', 'd8'], lost: ['d10', 'd4'] },
425+
],
426+
[
427+
// A `datetime` column keeps comparing instants: Los Angeles' Q3 opens at
428+
// 2026-07-01T07:00Z, so d2 (Jun 30 09:00 there) is out and d5 (Sep 30
429+
// 09:00 there) is in.
430+
'stage × this_quarter on the datetime field, Los Angeles',
431+
{ dimensions: ['stage'], timeDimensions: [{ dimension: 'closed', dateRange: 'this_quarter' }], timezone: LOS_ANGELES },
432+
{ won: ['d3', 'd6', 'd8'], lost: ['d10', 'd4', 'd5'] },
404433
],
405434
];
406435

@@ -417,7 +446,7 @@ describe('#27074 — on a real engine, each row drill lists exactly the records
417446
}
418447
expect(Object.keys(counted).length).toBeGreaterThan(0);
419448
expect(Object.fromEntries(Object.entries(listed).map(([k, ids]) => [k, ids.length]))).toEqual(counted);
420-
if (expected) expect(listed).toEqual(expected);
449+
expect(listed).toEqual(expected);
421450
});
422451
}
423452
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27172] The draft preview counts a preset `dateRange` on a `date` field by
5+
* the reference zone's calendar days, as the published faces do
6+
* (`dateRangeWindowBounds`); a `datetime` field keeps comparing instants.
7+
*
8+
* MEASURED on `origin/main` `4d3a077f`, before the change: Shanghai's
9+
* `this_quarter` on the `date` field selected Jun 30 and dropped Sep 30 — the
10+
* preset's Shanghai-midnight instants read by their leading UTC day. The
11+
* published-face half (both strategies, count and drill on one window) is
12+
* `dataset-drill-window.test.ts`.
13+
*/
14+
15+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
16+
import type { Cube } from '@objectstack/spec/data';
17+
import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js';
18+
19+
/** Frozen: Wednesday 2026-09-09 in UTC, Shanghai and Los Angeles, inside Q3. */
20+
const NOW = new Date('2026-09-09T12:34:56.789Z');
21+
22+
const CUBE = {
23+
name: 'deals_ds',
24+
sql: 'deal',
25+
dimensions: {
26+
id: { type: 'string', sql: 'id' },
27+
close_on: { type: 'time', sql: 'close_on' },
28+
closed_at: { type: 'time', sql: 'closed_at' },
29+
},
30+
measures: { count: { type: 'count', sql: '*' } },
31+
} as unknown as Cube;
32+
33+
const DECLARED = (field: string): string | undefined =>
34+
field === 'close_on' ? 'date' : field === 'closed_at' ? 'datetime' : undefined;
35+
36+
/** Either side of each edge of Q3 2026. */
37+
const ROWS = [
38+
{ id: 'jun30', close_on: '2026-06-30', closed_at: '2026-06-30T15:59:59.000Z' }, // Shanghai Jun 30 23:59
39+
{ id: 'jul01', close_on: '2026-07-01', closed_at: '2026-06-30T16:00:00.000Z' }, // Shanghai Jul 1 00:00
40+
{ id: 'sep30', close_on: '2026-09-30', closed_at: '2026-09-30T15:59:59.000Z' }, // Shanghai Sep 30 23:59
41+
{ id: 'oct01', close_on: '2026-10-01', closed_at: '2026-09-30T16:00:00.000Z' }, // Shanghai Oct 1 00:00
42+
];
43+
44+
function idsIn(dimension: 'close_on' | 'closed_at', timezone: string): string[] {
45+
const result = evaluateAnalyticsQueryOverRows(
46+
{
47+
cube: 'deals_ds',
48+
measures: ['count'],
49+
dimensions: ['id'],
50+
timeDimensions: [{ dimension, dateRange: 'this_quarter' }],
51+
timezone,
52+
},
53+
CUBE,
54+
ROWS,
55+
DECLARED,
56+
);
57+
return result.rows.map((r) => String(r.id)).sort();
58+
}
59+
60+
beforeEach(() => {
61+
vi.useFakeTimers({ toFake: ['Date'] });
62+
vi.setSystemTime(NOW);
63+
});
64+
afterEach(() => {
65+
vi.useRealTimers();
66+
});
67+
68+
describe('#27172 — the draft preview counts a preset on a date field by the zone\'s calendar days', () => {
69+
it('a date field selects Jul 1 through Sep 30, east and west of UTC', () => {
70+
for (const timezone of ['Asia/Shanghai', 'America/Los_Angeles', 'UTC']) {
71+
expect(idsIn('close_on', timezone), timezone).toEqual(['jul01', 'sep30']);
72+
}
73+
});
74+
75+
it('a datetime field keeps comparing the zone\'s midnight instants', () => {
76+
expect(idsIn('closed_at', 'Asia/Shanghai')).toEqual(['jul01', 'sep30']);
77+
// Los Angeles' Q3 opens at 2026-07-01T07:00Z, after both June instants,
78+
// and both September instants are still its Sep 30.
79+
expect(idsIn('closed_at', 'America/Los_Angeles')).toEqual(['oct01', 'sep30']);
80+
});
81+
});

‎packages/services/service-analytics/src/dataset-executor.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,45 @@ function inclusiveCalendarDayWindow(
702702
];
703703
}
704704

705+
/**
706+
* [#27172] The bound pair a lowered `dateRange` window filters one column by —
707+
* the ONE spelling the count (both strategies, the draft preview) and the drill
708+
* (`drill-window.ts`) share.
709+
*
710+
* ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date`
711+
* column compares CALENDAR DAYS in the reference zone.
712+
*
713+
* - A resolved preset (`endExclusive`) on a `date` column is the calendar days
714+
* it covers in `timezone`, through this module's whole-day rule
715+
* {@link inclusiveCalendarDayWindow} — the projection `compareTo` already
716+
* shifts — as the inclusive `{ $gte, $lte }` day pair.
717+
* - A resolved preset on any other column stays the instant pair it resolved
718+
* to, stopping before its end: `{ $gte, $lt }`.
719+
* - A caller's explicit `[a, b]` is handed back bound for bound, with its
720+
* inclusive upper reading (#16179); the shared lowering gives a bare-day end
721+
* its whole-day reading where the column needs one.
722+
*
723+
* ⛔ The preset's instants used to reach a `date` column as they are, and the
724+
* column reads an instant by its leading UTC day (`temporalStorageForm`). A
725+
* zone east of UTC opens at midnight on the PREVIOUS UTC day, so the window
726+
* slid one day early: Shanghai's `this_quarter` counted Jun 30 and dropped
727+
* Sep 30, on both strategies. West of UTC the leading day happens to be the
728+
* zone's own, which is why only eastern tenants saw it.
729+
*
730+
* @param dateColumn - Is the window's column declared `date`? A column whose
731+
* type the caller cannot name answers `false` and keeps the instants.
732+
*/
733+
export function dateRangeWindowBounds(
734+
window: LoweredDateRangeWindow,
735+
timezone: string | undefined,
736+
dateColumn: boolean,
737+
): Record<string, string> {
738+
if (!window.endExclusive) return { $gte: window.start, $lte: window.end };
739+
if (!dateColumn) return { $gte: window.start, $lt: window.end };
740+
const [first, last] = inclusiveCalendarDayWindow(window, timezone);
741+
return { $gte: first, $lte: last };
742+
}
743+
705744
/**
706745
* Resolve which time dimension a `compareTo` shifts (#5011).
707746
*

‎packages/services/service-analytics/src/drill-window.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525
* the shared `dateRange` conformance kit: a preset through
2626
* `resolveAnalyticsDateRangeString` at the reference time zone, an explicit
2727
* `[a, b]` through `explicitDateRangeWindow`, untouched.
28-
* 2. The `{ $gte, $lt }` / `{ $gte, $lte }` pair both strategies build from
29-
* that window, through {@link normalizeDateRangeWindow}, the shared
30-
* lowering. Its whole-day rule turns a bare-day `$lte` into `$lt` the next
31-
* day. It is applied as for a `datetime` column on every column, which
28+
* 2. The pair both strategies build from that window,
29+
* {@link dateRangeWindowBounds}: a resolved preset's instants, or on a
30+
* `date` column the calendar days it covers in the reference zone (#27172);
31+
* an explicit window's own bounds. Then {@link normalizeDateRangeWindow},
32+
* the shared lowering. Its whole-day rule turns a bare-day `$lte` into
33+
* `$lt` the next day. It is applied as for a `datetime` column on every column, which
3234
* `calendar-day.ts` records is order-equivalent to `<= day` on a `date`
3335
* column, so one `[gte, lt)` shape serves both.
3436
* 3. `temporalStorageForm`, the rule the engine reads every temporal
@@ -42,7 +44,7 @@
4244
*/
4345

4446
import { temporalStorageForm } from '@objectstack/core';
45-
import { lowerDatasetCompareDateRange } from './dataset-executor.js';
47+
import { dateRangeWindowBounds, lowerDatasetCompareDateRange } from './dataset-executor.js';
4648
import { normalizeDateRangeWindow, type NormalizedFilterNode } from './strategies/filter-normalizer.js';
4749

4850
/** How a drill bound is spelled: the storage form of the window's column. */
@@ -74,9 +76,7 @@ export function drillWindowBounds(
7476
form: DrillBoundForm,
7577
): DrillWindowBounds | undefined {
7678
const window = lowerDatasetCompareDateRange(dateRange, timezone);
77-
const bounds = window.endExclusive
78-
? { $gte: window.start, $lt: window.end }
79-
: { $gte: window.start, $lte: window.end };
79+
const bounds = dateRangeWindowBounds(window, timezone, form === 'date');
8080
const node = normalizeDateRangeWindow(MEMBER, bounds, WHOLE_DAY);
8181
const leaves: NormalizedFilterNode[] = node?.kind === 'and' ? node.children : node ? [node] : [];
8282
let gte: unknown;

‎packages/services/service-analytics/src/preview-evaluator.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
type TemporalComparandKind,
4242
} from '@objectstack/core';
4343
import { explicitDateRangeWindow } from './date-range-array-arm.js';
44+
import { dateRangeWindowBounds } from './dataset-executor.js';
4445
import { DATASET_DURATION_UNIT_SECONDS } from '@objectstack/spec/ui';
4546
import { durationMeasureIn } from './duration-measure-sql.js';
4647
import type { DurationMeasureSpec } from './strategies/types.js';
@@ -826,7 +827,10 @@ export function evaluateAnalyticsQueryOverRows(
826827
// widens. This face kept its own copy of the rule here until #5930 step
827828
// 4, with a `'~'`-suffix reading of a full-timestamp end ("inclusive of
828829
// that instant's own sub-values") that no other face gives.
829-
const bounds = endExclusive ? { $gte: start, $lt: end } : { $gte: start, $lte: end };
830+
// [#27172] On a column declared `date`, a resolved preset is the calendar
831+
// days it covers in the reference zone — the pair `dateRangeWindowBounds`
832+
// spells for the published faces, so the draft counts the published days.
833+
const bounds = dateRangeWindowBounds({ start, end, endExclusive }, query.timezone, declaredType?.(field) === 'date');
830834
const window = previewStorageComparands(lowerFilterCondition({ [field]: bounds }, lowering), kindOf);
831835
filtered = filtered.filter((r) => matchesWhere(previewStorageRow(r, kindOf), window));
832836
}

‎packages/services/service-analytics/src/strategies/filter-normalizer.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2331,6 +2331,27 @@ export function declaredDatetimeLowering(
23312331
};
23322332
}
23332333

2334+
/**
2335+
* [#27172] Is the column a `timeDimensions[].dateRange` member binds against
2336+
* declared `date`? Asked of the same hook and the same target as
2337+
* {@link declaredDatetimeLowering}, for `dateRangeWindowBounds`
2338+
* (`dataset-executor.ts`): a resolved preset filters a `date` column by the
2339+
* reference zone's calendar days, and every other column by its instants.
2340+
* A column the host cannot name a type for answers `false`, which keeps the
2341+
* instants it was filtered by before.
2342+
*/
2343+
export function declaredDateColumn(
2344+
ctx: StrategyContext,
2345+
target: (member: string) => { object: string; field: string },
2346+
): (member: string) => boolean {
2347+
const declared = (ctx as DatasetScopedStrategyContext).declaredFieldType;
2348+
if (typeof declared !== 'function') return () => false;
2349+
return (member) => {
2350+
const { object, field } = target(member);
2351+
return declared.call(ctx, object, field) === 'date';
2352+
};
2353+
}
2354+
23342355
/**
23352356
* [ADR-0053 D-D1 item 8, amended — #5930 step 4] A `timeDimensions[].dateRange`
23362357
* window as the tree the strategies compile: the `{ $gte, $lte }` pair (or the

‎packages/services/service-analytics/src/strategies/native-sql-strategy.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AggregationMetricType, NUMERIC_VALUE_TYPES, type AggregationFunction }
66
import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext, DurationMeasureSpec } from './types.js';
77
import {
88
declaredDatetimeLowering,
9+
declaredDateColumn,
910
findNestedRelationCondition,
1011
invalidFilterError,
1112
lowerAnalyticsWhere,
@@ -51,6 +52,7 @@ import { scopedMeasureFilter } from '../scoped-measure-key.js';
5152
import { isJsonStoredShape } from '../contains-membership-sql.js';
5253
import { expandEmptyOperator } from '@objectstack/spec/data';
5354
import { resolveAnalyticsDateRangeString } from '@objectstack/core';
55+
import { dateRangeWindowBounds } from '../dataset-executor.js';
5456
// [#20889] What each aggregate function ANSWERS, and the `'number'` presenter —
5557
// the rule `driver-sql`'s own `aggregate()` applies, defined once in core.
5658
import { AGGREGATE_ANSWER_KIND, presentAsNumber } from '@objectstack/core';
@@ -1334,6 +1336,12 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
13341336
(member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf),
13351337
'type-blind',
13361338
);
1339+
// [#27172] Which `dateRange` member binds a `date` column, asked of the
1340+
// SAME target (`dateRangeWindowBounds`).
1341+
const dateColumn = declaredDateColumn(
1342+
ctx,
1343+
(member) => this.resolveStorageTarget(cube, member, tableName, joins.referenceOf),
1344+
);
13371345
// [#21376, #21426] The comparand verdicts' member reader (both arms read
13381346
// it), asked of the SAME target, and applied at the same three filter
13391347
// positions, before each is normalized ({@link judgedComparands}).
@@ -1450,7 +1458,13 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
14501458
// `today` does. ⛔ An explicit `[a, b]` a CALLER wrote keeps the
14511459
// inclusive reading it has always had — the #16179 separation, on this
14521460
// side too.
1453-
const bounds = resolved?.endExclusive ? { $gte: start, $lt: end } : { $gte: start, $lte: end };
1461+
//
1462+
// [#27172] On a `date` column a resolved window is the calendar days
1463+
// it covers in the reference zone, the pair `dateRangeWindowBounds`
1464+
// spells for every face; its instants slid a day early east of UTC.
1465+
const bounds = resolved
1466+
? dateRangeWindowBounds(resolved, query.timezone, dateColumn(td.dimension))
1467+
: { $gte: start, $lte: end };
14541468
const windowSql = this.compileFilterNode(
14551469
normalizeDateRangeWindow(td.dimension, bounds, lowering),
14561470
cube,

0 commit comments

Comments
 (0)