@@ -10,10 +10,12 @@ import type {
1010import { emptyGroupValueFor , type FilterCondition } from '@objectstack/spec/data' ;
1111import type { ExecutionContext } from '@objectstack/spec/kernel' ;
1212import {
13+ bucketDateKey ,
1314 bucketKeyToCalendarRange ,
1415 filterTokenContextFrom ,
1516 resolveAnalyticsDateRangeString ,
1617 resolveFilterTokens ,
18+ zonedDateStartToUtcMs ,
1719 type LoweredDateRangeWindow ,
1820} from '@objectstack/core' ;
1921import 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
497517const 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
503547function 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 */
746817export 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