Skip to content

Commit dfb909b

Browse files
committed
fix(service-analytics): compareTo resolves its window on the reference calendar
Deletes dataset-executor's local parseUTC/toISODate pair — a UTC-calendar duplicate of @objectstack/core's shared datetime vocabulary — and routes the compareTo day math through that vocabulary instead. A bare YYYY-MM-DD is a calendar day, so the year shift, the previous-period length and the bucket ordinals keep running on the zone-free UTC proxy zonedDateStartToUtcMs yields for an unset zone. The one seam a reference zone reaches is the projection of the lowered window's INSTANTS onto days, which now calls bucketDateKey at 'day' with the timezone buildQuery already resolves the primary pass in. Threading a zone into the arithmetic instead would put DST in the middle of a year shift; threading it only into the lowering (as before) left the projection on UTC and misaligned the two grids by a day in opposite directions either side of the meridian. Claude-Session: https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1540b76 commit dfb909b

2 files changed

Lines changed: 132 additions & 34 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): resolve `compareTo`'s comparison window on the reference calendar, not UTC
6+
7+
`DatasetExecutor`'s `compareTo` day math carried its own local `parseUTC`/`toISODate` pair
8+
and read every bound on the UTC calendar. The lowered preset window is a pair of INSTANTS
9+
that open and close at the *reference zone's* midnight, so projecting them onto UTC days
10+
moved a boundary in every non-UTC zone — and in opposite directions either side of the
11+
meridian. `this_month` + `compareTo: { kind: 'previousYear' }` frozen at 2026-09-09 compared
12+
30-day September against a 31-day window: `Asia/Shanghai` opened at `2025-08-31`,
13+
`America/New_York` closed at `2025-10-01`. No error, no warning — a slightly-too-wide
14+
comparison leg rendered exactly like a correct one.
15+
16+
The local pair is deleted. The bare-calendar-day arithmetic (year shift, previous-period
17+
length, bucket ordinals) now runs through `@objectstack/core`'s `zonedDateStartToUtcMs` on
18+
its zone-free UTC proxy, and the one seam that turns instants into days — the lowered
19+
window's projection — goes through the same package's `bucketDateKey`, threaded with the
20+
timezone `buildQuery` already resolves the primary pass in. UTC callers are unaffected.

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

Lines changed: 112 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import type {
1010
import { emptyGroupValueFor, type FilterCondition } from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import {
13+
bucketDateKey,
1314
bucketKeyToCalendarRange,
1415
filterTokenContextFrom,
1516
resolveAnalyticsDateRangeString,
1617
resolveFilterTokens,
18+
zonedDateStartToUtcMs,
1719
type LoweredDateRangeWindow,
1820
} from '@objectstack/core';
1921
import type { CompiledDataset, DerivedMeasureSpec } from './dataset-compiler.js';
@@ -480,30 +482,72 @@ export function resolveOrdering(
480482

481483
// ── compareTo date math (deterministic — no Date.now) ────────────────────────
482484

483-
function parseUTC(date: string): number {
484-
// Accepts 'YYYY-MM-DD' (and ISO datetimes); interpreted as UTC.
485-
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
486-
// [#5716] `DATASET_INVALID` / 400 — the string comes from the REQUEST
487-
// (`selection.timeDimensions[].dateRange`, usually a dashboard's date filter),
488-
// reaches here only through `shiftRange`'s `compareTo` math, and no schema
489-
// refines it into a date. A caller who sends an unparseable bound gets told
490-
// which bound it was; nothing about it is a server fault.
485+
/**
486+
* [#18245] The epoch ms one `compareTo` bound denotes.
487+
*
488+
* ⭐ **A bare `YYYY-MM-DD` is a CALENDAR DAY, not an instant**, and every piece
489+
* of arithmetic below it feeds — {@link shiftYear}, {@link shiftRange}'s
490+
* previous-period length, {@link bucketOrdinalOfDay} — is calendar arithmetic
491+
* that no timezone changes: "one year before 2026-09-01" is `2025-09-01` in
492+
* Shanghai exactly as in New York. So a bare day is carried on the **UTC
493+
* proxy** `zonedDateStartToUtcMs` yields for an unset zone, which is the
494+
* pattern `analytics-date-range.ts`'s own header prescribes ("anchors on the
495+
* reference timezone's calendar day and does its arithmetic on a UTC proxy").
496+
* ⛔ Threading a zone in HERE instead would put DST in the middle of a year
497+
* shift: `2026-03-09` is `04:00Z` in `America/New_York` (EDT) and the same
498+
* clock reading a year earlier is `2025-03-08T23:00` EST — a different day.
499+
*
500+
* The other spelling is a real INSTANT (the ISO bound an author may write in
501+
* the explicit-array arm, which judges arity and bound TYPE but never a bound's
502+
* VALUE — `date-range-array-arm.ts`). It is parsed as one, unchanged.
503+
*
504+
* [#5716] `DATASET_INVALID` / 400 — the string comes from the REQUEST
505+
* (`selection.timeDimensions[].dateRange`, usually a dashboard's date filter)
506+
* and no schema refines it into a date. A caller who sends an unparseable bound
507+
* gets told which bound it was; nothing about it is a server fault.
508+
*/
509+
function boundInstantMs(bound: string): number {
510+
const ms = bound.length === 10 ? zonedDateStartToUtcMs(bound) : Date.parse(bound);
491511
if (Number.isNaN(ms)) {
492-
throw datasetInvalidError(`[dataset-executor] invalid date in dateRange: "${date}"`);
512+
throw datasetInvalidError(`[dataset-executor] invalid date in dateRange: "${bound}"`);
493513
}
494514
return ms;
495515
}
496516

497517
const DAY_MS = 86_400_000;
498518

499-
function toISODate(ms: number): string {
500-
return new Date(ms).toISOString().slice(0, 10);
519+
/**
520+
* [#18245] The calendar day an instant falls on, in `timezone` — the ONE seam
521+
* in this module's `compareTo` math that a reference zone reaches.
522+
*
523+
* It is `@objectstack/core`'s `bucketDateKey` at its `'day'` granularity: the
524+
* same shared, `Intl`-backed extraction the runtime's own grouping labels rows
525+
* with, and the exact inverse of the `zonedDateStartToUtcMs` that
526+
* `analytics-date-range.ts` renders every preset bound through. ⛔ There is
527+
* deliberately no second implementation of it here — that is the one this
528+
* package's shared vocabulary module exists to refuse, and what this function
529+
* replaced (a local `toISODate` slicing `toISOString()`) was exactly that
530+
* second implementation, silently pinned to UTC.
531+
*
532+
* Callers doing pure calendar-day arithmetic pass NO zone and get the UTC
533+
* proxy back, round-tripping {@link boundInstantMs} exactly.
534+
*/
535+
function calendarDayAt(ms: number, timezone?: string): string {
536+
const day = bucketDateKey(ms, 'day', timezone);
537+
if (day == null) {
538+
// `bucketDateKey` answers `null` only for an absent or unparseable instant,
539+
// and every caller here holds a finite epoch ms this module just computed.
540+
// Loud rather than a fabricated day: same condition, same envelope as an
541+
// unparseable bound above (#5716).
542+
throw datasetInvalidError(`[dataset-executor] compareTo date math produced no calendar day for ${ms}`);
543+
}
544+
return day;
501545
}
502546

503547
function shiftYear(date: string, years: number): string {
504-
const d = new Date(parseUTC(date));
548+
const d = new Date(boundInstantMs(date));
505549
d.setUTCFullYear(d.getUTCFullYear() + years);
506-
return toISODate(d.getTime());
550+
return calendarDayAt(d.getTime());
507551
}
508552

509553
/**
@@ -531,10 +575,10 @@ function shiftYear(date: string, years: number): string {
531575
* ## Why it reports INSTANTS while `runCompare` shifts DAYS
532576
*
533577
* What is reported here is the window the VOCABULARY resolved, which is what
534-
* the kit holds every face to. Projecting it onto this module's UTC calendar is
578+
* the kit holds every face to. Projecting it onto calendar days is
535579
* a per-face calendar translation and stays downstream, in
536-
* {@link inclusiveUtcDayWindow} — the same split `lowerPreviewDateRange` makes
537-
* when it leaves the #3777 bare-day widening in its own predicate.
580+
* {@link inclusiveCalendarDayWindow} — the same split `lowerPreviewDateRange`
581+
* makes when it leaves the #3777 bare-day widening in its own predicate.
538582
*
539583
* ⛔ The ARRAY arm is the CALLER's explicit window and is handed back bound for
540584
* bound, with the inclusive upper reading it has always had (#16179) — the
@@ -558,7 +602,7 @@ export function lowerDatasetCompareDateRange(
558602
}
559603

560604
/**
561-
* [#17973] The UTC calendar days a lowered window covers, as the INCLUSIVE
605+
* [#17973] The calendar days a lowered window covers, as the INCLUSIVE
562606
* `[first, last]` pair every piece of `compareTo` math in this module takes.
563607
*
564608
* {@link shiftRange} measures `previousPeriod`'s length as a count of whole
@@ -573,10 +617,33 @@ export function lowerDatasetCompareDateRange(
573617
* calendar preset one day too long and shift `previousPeriod` by a day. The
574618
* three rolling presets end at NOW, a moment they REACH, so their bound is
575619
* already the last day.
620+
*
621+
* ## [#18245] ⭐ `timezone` decides WHICH calendar, and it is not optional here
622+
*
623+
* The bounds arriving here are INSTANTS, and the ten calendar presets open and
624+
* close at the reference zone's midnight — `analytics-date-range.ts` renders
625+
* both through `zonedDateStartToUtcMs` precisely so they do. Reading those
626+
* instants on the UTC calendar therefore moves a boundary in every zone whose
627+
* midnight is not UTC's, and it moves it in OPPOSITE directions either side of
628+
* the meridian. MEASURED on `e0d05538c`, `this_month` + `previousYear` frozen
629+
* at 2026-09-09: `Asia/Shanghai` opened at `2025-08-31` (its September midnight
630+
* is the previous UTC day) and `America/New_York` closed at `2025-10-01` (its
631+
* October midnight is the next UTC day) — each 31 days against a 30-day
632+
* September, each still an ordinary `200`.
633+
*
634+
* ⛔ That is a day-boundary projection, ⛔ not an off-by-one: no constant makes
635+
* both sides right, which is why the caller threads the SAME zone
636+
* {@link DatasetExecutor.buildQuery} resolves the primary pass in.
576637
*/
577-
function inclusiveUtcDayWindow(window: LoweredDateRangeWindow): [string, string] {
578-
const endMs = parseUTC(window.end);
579-
return [toISODate(parseUTC(window.start)), toISODate(window.endExclusive ? endMs - 1 : endMs)];
638+
function inclusiveCalendarDayWindow(
639+
window: LoweredDateRangeWindow,
640+
timezone?: string,
641+
): [string, string] {
642+
const endMs = boundInstantMs(window.end);
643+
return [
644+
calendarDayAt(boundInstantMs(window.start), timezone),
645+
calendarDayAt(window.endExclusive ? endMs - 1 : endMs, timezone),
646+
];
580647
}
581648

582649
/**
@@ -682,13 +749,15 @@ export function shiftRange(range: [string, string], kind: CompareTo['kind']): [s
682749
case 'previousYear':
683750
return [shiftYear(start, -1), shiftYear(end, -1)];
684751
case 'previousPeriod': {
685-
// The equal-length window ending the day before `start`.
686-
const startMs = parseUTC(start);
687-
const endMs = parseUTC(end);
752+
// The equal-length window ending the day before `start`. Both bounds are
753+
// CALENDAR days by the time they reach here (#18245), so this counts days
754+
// on the UTC proxy and no zone enters the arithmetic.
755+
const startMs = boundInstantMs(start);
756+
const endMs = boundInstantMs(end);
688757
const lengthDays = Math.round((endMs - startMs) / DAY_MS) + 1;
689758
const prevEndMs = startMs - DAY_MS;
690759
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
691-
return [toISODate(prevStartMs), toISODate(prevEndMs)];
760+
return [calendarDayAt(prevStartMs), calendarDayAt(prevEndMs)];
692761
}
693762
default: {
694763
const exhaustive: never = kind;
@@ -741,10 +810,12 @@ function isoWeekKeyOfUtcMs(ms: number): string {
741810
* silently shifts by one and the comparison column lands on its neighbour.
742811
* Ordinals are computed from the CALENDAR, so a gap costs nothing.
743812
*
744-
* @param ymd - a `YYYY-MM-DD` UTC calendar day.
813+
* @param ymd - a `YYYY-MM-DD` calendar day, already resolved in the reference
814+
* zone by {@link inclusiveCalendarDayWindow}; the ordinal is counted on the
815+
* UTC proxy, which is zone-free calendar arithmetic (#18245).
745816
*/
746817
export function bucketOrdinalOfDay(ymd: string, granularity: DateGranularityValue): number {
747-
const ms = parseUTC(ymd);
818+
const ms = boundInstantMs(ymd);
748819
const d = new Date(ms);
749820
const y = d.getUTCFullYear();
750821
const m = d.getUTCMonth(); // 0-11
@@ -788,7 +859,7 @@ export function bucketKeyAtOrdinal(ordinal: number, granularity: DateGranularity
788859
return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
789860
case 'day':
790861
default:
791-
return toISODate(ordinal * DAY_MS);
862+
return calendarDayAt(ordinal * DAY_MS);
792863
}
793864
}
794865

@@ -1395,26 +1466,33 @@ export class DatasetExecutor {
13951466
//
13961467
// [#17973] The STRING arm is the CLOSED preset vocabulary, lowered by the one
13971468
// shared `resolveAnalyticsDateRangeString` every other face calls and then
1398-
// projected onto this module's UTC calendar. ⛔ What this replaced was the
1469+
// projected onto calendar days. ⛔ What this replaced was the
13991470
// degenerate `[range, range]` fallback #17015 removed everywhere else: it
1400-
// handed `parseUTC` the preset NAME, so `last_30_days` — declared, honoured,
1401-
// and exactly what the schema tells an author to write — came back as
1402-
// `DATASET_INVALID "invalid date in dateRange"`. A false diagnostic on a
1403-
// valid input has no repair to send the author to.
1471+
// handed the preset NAME to this module's date parser, so `last_30_days` —
1472+
// declared, honoured, and exactly what the schema tells an author to write —
1473+
// came back as `DATASET_INVALID "invalid date in dateRange"`. A false
1474+
// diagnostic on a valid input has no repair to send the author to.
14041475
//
14051476
// The timezone precedence is `buildQuery`'s, verbatim, so the comparison
14061477
// window is resolved in the SAME calendar as the primary pass it is
14071478
// compared against — a preset resolved here in UTC while the primary pass
14081479
// read it in the org's zone would misalign the two grids by a day.
1480+
//
1481+
// [#18245] ⭐ And it is threaded a SECOND time, into the projection. Lowering
1482+
// in the org's zone and then reading the resulting INSTANTS on the UTC
1483+
// calendar reproduced the very misalignment the paragraph above prevents —
1484+
// one day, in opposite directions either side of the meridian, under an
1485+
// ordinary `200`. One zone, resolved once, carried to both steps.
1486+
const timezone = selection.timezone ?? context?.timezone ?? 'UTC';
14091487
const lowered = lowerDatasetCompareDateRange(
14101488
td.dateRange as string | readonly unknown[],
1411-
selection.timezone ?? context?.timezone ?? 'UTC',
1489+
timezone,
14121490
);
14131491
const range: [string, string] = Array.isArray(td.dateRange)
14141492
? // The caller's own bounds, untouched — `explicitDateRangeWindow` already
14151493
// refused anything that is not a two-bound window (#17124).
14161494
[lowered.start, lowered.end]
1417-
: inclusiveUtcDayWindow(lowered);
1495+
: inclusiveCalendarDayWindow(lowered, timezone);
14181496
const shifted = shiftRange(range, cmp.kind);
14191497
const shiftedTd = (selection.timeDimensions ?? []).map((t) =>
14201498
t.dimension === dimension ? { ...t, dateRange: shifted } : t,

0 commit comments

Comments
 (0)