diff --git a/.changeset/memory-analytics-date-range-timezone.md b/.changeset/memory-analytics-date-range-timezone.md new file mode 100644 index 0000000000..ec09397297 --- /dev/null +++ b/.changeset/memory-analytics-date-range-timezone.md @@ -0,0 +1,20 @@ +--- +"@objectstack/driver-memory": minor +--- + +`driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042) + +`AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day. + +⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`: + +| | window | rows selected, from the same 8 probes | +|:--|:--|:--| +| before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` | +| after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` | + +Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled. + +A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green. + +Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long. diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts new file mode 100644 index 0000000000..baa70f96e0 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16042 — `parseDateRangeString()` accepted `AnalyticsQuery.timezone` and + * never read it, so `dateRange: 'today'` answered on the UTC calendar for a + * caller who had said which calendar they meant. + * + * ## What this file pins + * + * ⭐ The one claim the card is about: **a supplied non-UTC `timezone` changes + * which rows `'today'` selects** — both halves of that, because they fail + * independently and each failure is silent: + * + * 1. WHICH calendar day the window is anchored to. `Asia/Shanghai` is + * already on 2026-09-07 while UTC is still on 2026-09-06. + * 2. WHERE that day BEGINS as an instant. `Asia/Kolkata` is on the SAME + * calendar day as UTC at its cell's instant, and its window still starts + * 5h30 earlier — so that cell goes red against a repair that resolves the + * day in the zone and then cuts it at UTC midnight, which is the shape a + * `proxyDay()`-only reading of the card produces. + * + * ## Why the assertions are about ROWS, and what the "before" is + * + * The window is internal; what a caller sees is which rows the answer counted. + * So every cell drives the real public entry, `MemoryAnalyticsService.query()`, + * and asserts the row set — and it asserts the **before** in the same test: + * the identical query with no `timezone` must still return the UTC row set + * (#15825's case, which this must not disturb), and the two row sets must + * DIFFER. That difference is the defect, stated as data. + * + * ## The inline controls are load-bearing + * + * A cell whose zone offset happened to be 0 at its instant would assert + * nothing while passing, so each cell first asserts its two windows disagree, + * and the fences at the bottom assert that every cell is live, that both + * directions are represented (a zone a day AHEAD of UTC and one a day BEHIND), + * that at least one cell shares UTC's calendar day (half 2 alone), and that at + * least one window is 23 hours long (the spring-forward day, which is why the + * end bound is a calendar step and never `+ 86_400_000`). + * + * ⚠️ Every window literal below was computed independently of the code under + * test, from `Intl.DateTimeFormat` alone, by scanning for the instant at which + * the zone's local clock reads `00:00` — never from `@objectstack/core`'s + * primitives, which are what the repair uses. They are data, not a second + * implementation. + * + * ⛔ No probe is placed exactly ON a window's end instant, and a guard below + * asserts that. The call site's upper bound is INCLUSIVE for a full-timestamp + * end (`nextUtcCalendarDay` widens only a bare `YYYY-MM-DD`, so it returns + * `null` here and the bound falls back to `$lte`), which is a separate, + * pre-existing question this card does not touch. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; + +const REAL_TZ = process.env.TZ; + +/** Run `fn` with the PROCESS on `zone` and the clock frozen at `instant`. */ +async function at(zone: string, instant: string, fn: () => Promise): Promise { + process.env.TZ = zone; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(instant)); + try { + return await fn(); + } finally { + vi.useRealTimers(); + if (REAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = REAL_TZ; + } +} + +const CUBE: Cube = { + name: 'events', + title: 'Events', + sql: 'events', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, + }, + dimensions: { + probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, + createdAt: { + name: 'created_at', + label: 'Created At', + type: 'time', + sql: 'created_at', + granularities: ['day'], + }, + }, + public: true, +}; + +const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input); + +/** Ask `range` over rows planted at `instants`; answer which probes came back. */ +async function probesSelected( + instants: string[], + opts: { range?: string; timezone?: string } = {}, +): Promise { + const driver = new InMemoryDriver({ + initialData: { + events: instants.map((iso, i) => ({ + id: i + 1, + probe: iso, + created_at: new Date(iso), + })), + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const query: AnalyticsQuery = { + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: opts.range ?? 'today' }], + }; + if (opts.timezone !== undefined) query.timezone = opts.timezone; + const result = await service.query(asQuery(query)); + return result.rows.map((row) => String(row['events.probe'])).sort(); +} + +interface Cell { + zone: string; + /** Frozen clock, always written in UTC. */ + instant: string; + /** That zone's local calendar day and clock at `instant`, for readability. */ + local: string; + /** `'today'` on the UTC calendar — half-open `[start, end)`. */ + utcWindow: [string, string]; + /** `'today'` on `zone`'s calendar — half-open `[start, end)`. */ + tzWindow: [string, string]; +} + +/** + * ⭐ Both signs of offset, two zones whose offset is not a whole number of + * hours (Kolkata +05:30, Chatham +12:45), the two extremes of the tz database + * (Kiritimati +14:00, Niue −11:00), a cell that shares UTC's calendar day + * (Kolkata), and one spring-forward day whose window is 23 hours long + * (New York, 2026-03-08). + */ +const CELLS: Cell[] = [ + { + zone: 'Asia/Shanghai', instant: '2026-09-06T20:00:00Z', local: '2026-09-07 04:00', + utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'], + tzWindow: ['2026-09-06T16:00:00.000Z', '2026-09-07T16:00:00.000Z'], + }, + { + zone: 'America/Los_Angeles', instant: '2026-09-06T04:00:00Z', local: '2026-09-05 21:00', + utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'], + tzWindow: ['2026-09-05T07:00:00.000Z', '2026-09-06T07:00:00.000Z'], + }, + { + zone: 'Asia/Kolkata', instant: '2026-09-06T12:00:00Z', local: '2026-09-06 17:30', + utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'], + tzWindow: ['2026-09-05T18:30:00.000Z', '2026-09-06T18:30:00.000Z'], + }, + { + zone: 'America/New_York', instant: '2026-03-08T12:00:00Z', local: '2026-03-08 08:00', + utcWindow: ['2026-03-08T00:00:00.000Z', '2026-03-09T00:00:00.000Z'], + tzWindow: ['2026-03-08T05:00:00.000Z', '2026-03-09T04:00:00.000Z'], + }, + { + zone: 'Pacific/Kiritimati', instant: '2026-09-06T12:00:00Z', local: '2026-09-07 02:00', + utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'], + tzWindow: ['2026-09-06T10:00:00.000Z', '2026-09-07T10:00:00.000Z'], + }, + { + zone: 'Pacific/Niue', instant: '2026-09-06T05:00:00Z', local: '2026-09-05 18:00', + utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'], + tzWindow: ['2026-09-05T11:00:00.000Z', '2026-09-06T11:00:00.000Z'], + }, + { + zone: 'Pacific/Chatham', instant: '2026-06-15T12:00:00Z', local: '2026-06-16 00:45', + utcWindow: ['2026-06-15T00:00:00.000Z', '2026-06-16T00:00:00.000Z'], + tzWindow: ['2026-06-15T11:15:00.000Z', '2026-06-16T11:15:00.000Z'], + }, +]; + +const label = (c: Cell) => `${c.zone} @ ${c.instant} (local ${c.local})`; +const ms = (iso: string) => Date.parse(iso); +const iso = (t: number) => new Date(t).toISOString(); +const inWindow = (t: number, w: [string, string]) => t >= ms(w[0]) && t < ms(w[1]); + +/** + * Probes that discriminate the two windows in BOTH directions — rows the zone + * day contains and the UTC day does not, and the reverse. The `+ 30min` probe + * sits just past the zone day's end: it is outside the correct window in every + * cell, and inside a window whose end was computed as `start + 86_400_000` on + * the 23-hour spring-forward day. + */ +function probesFor(c: Cell): string[] { + const [tzStart, tzEnd] = c.tzWindow.map(ms); + const [utcStart, utcEnd] = c.utcWindow.map(ms); + const raw = [ + tzStart, + tzStart - 1, + tzStart + Math.floor((tzEnd - tzStart) / 2), + tzEnd - 1, + tzEnd + 30 * 60_000, + utcStart, + utcStart - 1, + utcEnd - 1, + ]; + return [...new Set(raw)].map(iso); +} + +describe("#16042 — a supplied `timezone` decides which calendar day 'today' is", () => { + for (const c of CELLS) { + it(`${label(c)}: 'today' is answered on the zone's calendar, not UTC's`, async () => { + const probes = probesFor(c); + + // CONTROL FIRST — a cell whose two windows coincide would pass + // while asserting nothing. + expect( + c.tzWindow[0], + `${label(c)}: the zone window must differ from the UTC window, otherwise this cell pins nothing`, + ).not.toBe(c.utcWindow[0]); + + // ⛔ Guard: no probe may sit exactly on a window END. The call + // site's upper bound is inclusive for a full-timestamp end, which + // is a separate question — a probe there would pin THAT instead. + for (const p of probes) { + expect(p, `${label(c)}: probe sits on a window end`).not.toBe(c.tzWindow[1]); + expect(p, `${label(c)}: probe sits on a window end`).not.toBe(c.utcWindow[1]); + } + + const expectedTz = probes.filter((p) => inWindow(ms(p), c.tzWindow)).sort(); + const expectedUtc = probes.filter((p) => inWindow(ms(p), c.utcWindow)).sort(); + + // The defect, stated as data: the two answers are different rows. + expect( + expectedTz, + `${label(c)}: the zone's row set must differ from UTC's, otherwise this cell pins nothing`, + ).not.toEqual(expectedUtc); + + await at(c.zone, c.instant, async () => { + // AFTER — the zone is honoured. + await expect(probesSelected(probes, { timezone: c.zone })).resolves.toEqual(expectedTz); + // BEFORE — the same query with no timezone still answers on + // UTC, the chain's terminal fallback (#15825's case). + await expect(probesSelected(probes)).resolves.toEqual(expectedUtc); + // …and the terminal fallback spelled out explicitly. + await expect(probesSelected(probes, { timezone: 'UTC' })).resolves.toEqual(expectedUtc); + }); + }); + } + + it('every cell is live — its zone window differs from the UTC window', () => { + const dead = CELLS.filter((c) => c.tzWindow[0] === c.utcWindow[0]).map(label); + expect(dead, 'a cell that no longer shifts has stopped guarding the fix').toEqual([]); + }); + + it('both directions are represented — a zone a day AHEAD of UTC and one BEHIND', () => { + const dirs = new Set( + CELLS.map((c) => { + const tzDay = c.local.slice(0, 10); + const utcDay = c.instant.slice(0, 10); + return tzDay === utcDay ? 'same' : tzDay > utcDay ? 'ahead' : 'behind'; + }), + ); + expect([...dirs].sort()).toEqual(['ahead', 'behind', 'same']); + }); + + it("at least one cell shares UTC's calendar day — half 2 (the day's START) alone", () => { + // This is the cell a `proxyDay()`-only repair fails: same calendar day, + // different starting instant. + const sameDay = CELLS.filter((c) => c.local.slice(0, 10) === c.instant.slice(0, 10)); + expect(sameDay.map(label).length).toBeGreaterThan(0); + for (const c of sameDay) expect(c.tzWindow[0]).not.toBe(c.utcWindow[0]); + }); + + it('at least one window is 23 hours long — the spring-forward day', () => { + const spans = CELLS.map((c) => (ms(c.tzWindow[1]) - ms(c.tzWindow[0])) / 3_600_000); + expect(spans, 'no cell exercises a DST-shortened day, so `+ 86_400_000` would pass').toContain(23); + }); +}); + +describe('#16042 — the resolution is host-independent and degrades to UTC', () => { + const c = CELLS[0]; // Asia/Shanghai + + it('the answer does not depend on the PROCESS timezone', async () => { + const probes = probesFor(c); + const expectedTz = probes.filter((p) => inWindow(ms(p), c.tzWindow)).sort(); + for (const hostZone of ['UTC', 'America/Los_Angeles', 'Asia/Tokyo', 'Pacific/Chatham']) { + await at(hostZone, c.instant, async () => { + await expect( + probesSelected(probes, { timezone: c.zone }), + `host TZ=${hostZone} changed the answer`, + ).resolves.toEqual(expectedTz); + }); + } + }); + + it('an unknown zone degrades to UTC rather than throwing', async () => { + const probes = probesFor(c); + const expectedUtc = probes.filter((p) => inWindow(ms(p), c.utcWindow)).sort(); + await at('Asia/Tokyo', c.instant, async () => { + await expect(probesSelected(probes, { timezone: 'Mars/Olympus' })).resolves.toEqual(expectedUtc); + }); + }); +}); + +describe("#16042 — `last N …` anchors on the zone's calendar too", () => { + const c = CELLS[0]; // Asia/Shanghai: local day 2026-09-07, UTC day 2026-09-06 + + it("'last 7 days' starts 7 days before the ZONE's day, at the zone's midnight", async () => { + // 7 days before Shanghai's 2026-09-07 is 2026-08-31; that day begins at + // 2026-08-30T16:00:00.000Z. Computed independently: Shanghai is +08:00 + // year-round, so its midnight is the previous day's 16:00Z. + const tzStart = '2026-08-30T16:00:00.000Z'; + const utcStart = '2026-08-30T00:00:00.000Z'; // 7 days before UTC's 2026-09-06 + expect(tzStart).not.toBe(utcStart); + + // The upper bound of a `last N` window is the current INSTANT, so a + // probe must sit before it; both probes do. + const probes = [utcStart, tzStart, iso(ms(tzStart) - 1)]; + + await at('Asia/Tokyo', c.instant, async () => { + // AFTER — the window opens at Shanghai's midnight, so the probe one + // millisecond earlier is OUT. + await expect(probesSelected(probes, { range: 'last 7 days', timezone: c.zone })).resolves.toEqual( + [tzStart].sort(), + ); + // BEFORE — with no timezone the window opens 16 hours earlier, at + // UTC midnight, and takes all three probes. That extra row IS the + // defect, in the `last N` leg. + await expect(probesSelected(probes, { range: 'last 7 days' })).resolves.toEqual( + [...probes].sort(), + ); + }); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index 6235995887..e2a48c2fa6 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -7,7 +7,13 @@ import type { Cube, AnalyticsQuery } from '@objectstack/spec/data'; // the SQL exit's wildcard rendering is not a third hand-copy of that escape. import { asciiCaseInsensitiveRegexSource, likePatternToGlobPattern } from '@objectstack/spec/data'; import type { InMemoryDriver } from './memory-driver.js'; -import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core'; +import { + Logger, + createLogger, + nextUtcCalendarDay, + calendarPartsInTzOrUtc, + zonedDateStartToUtcMs, +} from '@objectstack/core'; import { assertFilterConditionShape, uncompilableCombinatorError, @@ -739,7 +745,7 @@ export class MemoryAnalyticsService implements IAnalyticsService { if (timeDim.dateRange) { const range = Array.isArray(timeDim.dateRange) ? timeDim.dateRange - : this.parseDateRangeString(timeDim.dateRange); + : this.parseDateRangeString(timeDim.dateRange, query.timezone); if (range.length === 2) { // The window matches BOTH stored forms of a datetime value — the @@ -1412,7 +1418,7 @@ export class MemoryAnalyticsService implements IAnalyticsService { return sql.trim(); } - private parseDateRangeString(range: string): string[] { + private parseDateRangeString(range: string, timezone?: string): string[] { // Simple parser for common date range strings // In production, this would use a proper date range parser // @@ -1448,20 +1454,65 @@ export class MemoryAnalyticsService implements IAnalyticsService { // resolves to the UTC day (#14852, same two-calendar shape). So the same // analytics question asked through this path and through a flow token no // longer selects different rows in one deployment -- that agreement, not - // the hour count, is what this repair restores. + // the hour count, is what this repair restores. ⚠️ That paragraph scopes + // itself to a query carrying NO timezone; a query that carries one is + // answered on THAT zone's calendar -- see [#16042] below. + // + // [#16042] The reference TIMEZONE, which this path used to accept and drop. + // + // `AnalyticsQuery.timezone` is declared optional with no default precisely + // because an ABSENT value is a meaningful state that the engine resolves + // (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2 — + // `service-analytics`' `buildQuery` resolves that whole chain and writes the + // ANSWER into `query.timezone` before a driver ever sees it). So a driver + // owes the chain's last two links: the value it was handed, else UTC. The + // third state -- accepting the field and ignoring it -- is the one that + // misleads, and it is what a caller asking `'today'` with + // `timezone: 'Asia/Shanghai'` got: the UTC day, silently, with no warning. + // + // TWO halves, and each needs its own primitive: + // + // 1. WHICH calendar day "now" is -- `calendarPartsInTzOrUtc(now, tz)`, + // read from the platform tz database. Arithmetic then runs on a UTC + // "proxy" date built from those parts, the `proxyDay()` pattern in + // `@objectstack/core`'s filter-token macros: working in UTC keeps + // `last N months` free of DST jumps, and the zone only decides which + // calendar day the window is anchored to. + // 2. WHERE that day BEGINS as an instant -- `zonedDateStartToUtcMs(ymd, + // tz)`, that zone's local midnight. This half is required because the + // bounds here are rendered with `toISOString()` and compared against + // DATETIME values, which is exactly the case ADR-0053 settles in + // `service-analytics`' drill ranges: "`datetime` -> the reference tz's + // MIDNIGHT INSTANT (ISO), because the bucket is defined on that tz's + // calendar"; only a `date`-typed, tz-naive column takes the bare + // `YYYY-MM-DD` calendar bound. // - // ⚠️ Out of scope here, filed separately: `AnalyticsQuery.timezone` is - // declared (optional, no default) and this path does not consult it. UTC - // is the terminal fallback of the engine's own resolution chain - // (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), - // so UTC-ising is correct for every query that carries no timezone; - // making the range tokens timezone-AWARE is a larger question, and - // #14852 explicitly declined the same one. + // ⛔ Half 1 alone is NOT the fix, and the failure is silent: it would + // anchor to Shanghai's calendar day but cut it at UTC midnight, a window + // that is neither the UTC day nor the Shanghai day but an 8-hour-shifted + // hybrid -- worse for that caller than the UTC day they get today. + // ⛔ Nor is `+ 86_400_000` a next-day boundary once a zone is in play: + // measured on `America/New_York`, 2026-03-08 begins at 05:00Z and 2026-03-09 + // at 04:00Z, so that spring-forward day is 23 hours long. + // + // NO-TIMEZONE CASE UNCHANGED, by construction: `zonedDateStartToUtcMs` + // returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so + // every bound below is byte-identical to #15825's for a query carrying no + // timezone -- the common case, and the one this must not disturb. An + // unknown zone degrades to UTC rather than throwing, the same call + // `calendarPartsInTzOrUtc` makes one line above. const now = new Date(); - const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - + const ref = calendarPartsInTzOrUtc(now, timezone); + const today = new Date(Date.UTC(ref.year, ref.month - 1, ref.day)); + /** That proxy day's `YYYY-MM-DD`, then the instant it BEGINS in `timezone`. */ + const boundary = (proxy: Date): string => + new Date(zonedDateStartToUtcMs(proxy.toISOString().slice(0, 10), timezone)).toISOString(); + if (range === 'today') { - return [today.toISOString(), new Date(today.getTime() + 86400000).toISOString()]; + // The next calendar day, via the proxy calendar -- never `+ 86_400_000`. + const tomorrow = new Date(today.getTime()); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + return [boundary(today), boundary(tomorrow)]; } else if (range.startsWith('last ')) { const parts = range.split(' '); const num = parseInt(parts[1]); @@ -1478,9 +1529,10 @@ export class MemoryAnalyticsService implements IAnalyticsService { start.setUTCFullYear(start.getUTCFullYear() - num); } - return [start.toISOString(), now.toISOString()]; + // The upper bound is the current INSTANT, which no zone moves. + return [boundary(start), now.toISOString()]; } - + return [range, range]; // Fallback }