From 93efc22f431dd7f8a4fac880e498f9b40cb81500 Mon Sep 17 00:00:00 2001 From: David Blass Date: Tue, 29 Sep 2026 17:05:51 -0400 Subject: [PATCH] fix(util): describe dates independently of the host timezone describeCollapsibleDate read local date fields, so Date bound descriptions, error messages and printable() output depended on the host's timezone. Date-only ISO literals like d'2023-01-01' parse as UTC midnight, so west of UTC they were described as the evening before ("7:00 PM, December 31, 2022") and never collapsed to "2023" as intended. A Date can't tell us whether its author meant a calendar date in UTC or in local time: d'2023/1/1' and new Date(2023, 0, 1) parse as local midnight. So a Date at midnight in either UTC or local time is described as just its calendar date, and both forms collapse as written. Anything with a time is described in UTC with a "UTC" label, after the date so it reads naturally: "January 15, 2023, 2:30 PM UTC". Echoing the original literal source was also considered, but it needs somewhere to store the source that doesn't affect node identity. Meta doesn't qualify, since it's serialized into the parent's innerHash, so equivalent bounds written differently would stop comparing equal. Known gap: exclusive bounds are normalized to a limit 1ms away, so Date > d'2023-01-01' is still described as "January 1, 2023, 12:00:00.001 AM UTC or later". Co-Authored-By: Claude Opus 5.5 (1M context) --- ark/schema/__tests__/bounds.test.ts | 6 +- ark/type/__tests__/range.test.ts | 14 ++++ ark/util/__tests__/collapsibleDate.test.ts | 68 ++++++++++-------- ark/util/__tests__/printable.test.ts | 6 +- ark/util/serialize.ts | 82 ++++++++++++++++------ 5 files changed, 119 insertions(+), 57 deletions(-) diff --git a/ark/schema/__tests__/bounds.test.ts b/ark/schema/__tests__/bounds.test.ts index 485aea89da..a435410088 100644 --- a/ark/schema/__tests__/bounds.test.ts +++ b/ark/schema/__tests__/bounds.test.ts @@ -72,17 +72,17 @@ contextualize(() => { }) attest(T.traverse(dateCases.lessThanMin)?.toString()).snap( - "must be 7:00:00.006 PM, December 31, 1969 or later (was 7:00:00.004 PM, December 31, 1969)" + "must be January 1, 1970, 12:00:00.006 AM UTC or later (was January 1, 1970, 12:00:00.004 AM UTC)" ) attest(T.traverse(dateCases.equalToExclusiveMin)?.toString()).snap( - "must be 7:00:00.006 PM, December 31, 1969 or later (was 7:00:00.005 PM, December 31, 1969)" + "must be January 1, 1970, 12:00:00.006 AM UTC or later (was January 1, 1970, 12:00:00.005 AM UTC)" ) attest(T.traverse(dateCases.between)).equals(dateCases.between) attest(T.traverse(dateCases.equalToInclusiveMax)).equals( dateCases.equalToInclusiveMax ) attest(T.traverse(dateCases.greaterThanMax)?.toString()).snap( - "must be 7:00:00.010 PM, December 31, 1969 or earlier (was 7:00:00.011 PM, December 31, 1969)" + "must be January 1, 1970, 12:00:00.010 AM UTC or earlier (was January 1, 1970, 12:00:00.011 AM UTC)" ) }) diff --git a/ark/type/__tests__/range.test.ts b/ark/type/__tests__/range.test.ts index 1f12c2c568..0ba1cb24a2 100644 --- a/ark/type/__tests__/range.test.ts +++ b/ark/type/__tests__/range.test.ts @@ -132,6 +132,20 @@ contextualize(() => { attest(T.allows(new Date("2005/10/10"))).equals(false) }) + it("Date bound descriptions", () => { + // ISO date-only strings parse as UTC midnight and collapse + attest(type("Date >= d'2023-01-01'").description).snap( + "a Date and 2023 or later" + ) + // other formats parse as local midnight, which also collapses + attest(type("Date >= d'2023/1/1'").description).snap( + "a Date and 2023 or later" + ) + attest(type("Date >= d'2023-01-01T14:30Z'").description).snap( + "a Date and January 1, 2023, 2:30 PM UTC or later" + ) + }) + it("dynamic Date", () => { const now = new Date() const T = type(`d'2000'< Date <=d'${now.toISOString()}'`) diff --git a/ark/util/__tests__/collapsibleDate.test.ts b/ark/util/__tests__/collapsibleDate.test.ts index ce21b23c96..b0e857400a 100644 --- a/ark/util/__tests__/collapsibleDate.test.ts +++ b/ark/util/__tests__/collapsibleDate.test.ts @@ -1,68 +1,78 @@ import { attest, contextualize } from "@ark/attest" -import { describeCollapsibleDate, throwError } from "@ark/util" +import { describeCollapsibleDate } from "@ark/util" -const newYorkDate = (dateString: string) => { - const date = new Date(dateString) - if (date.getMonth() > 1 && date.getMonth() < 11) { - throwError( - `This test util only supports months not affected by Daylight Savings Time` - ) - } - date.setHours(date.getHours() + 5) - return date -} +// tests run with TZ set to America/New_York (see mocha.globalSetup.ts), +// so these also verify descriptions don't depend on the local timezone contextualize(() => { it("returns year for date with only year precision", () => { - const date = newYorkDate("2023-01-01") + const date = new Date("2023-01-01") const result = describeCollapsibleDate(date) attest(result).snap("2023") }) it("returns full date for date with day precision", () => { - const date = newYorkDate("2023-01-15") + const date = new Date("2023-01-15") const result = describeCollapsibleDate(date) attest(result).snap("January 15, 2023") }) it("returns full date and time for date with minutes precision", () => { - const date = newYorkDate("2023-01-15T14:30:00.000Z") + const date = new Date("2023-01-15T14:30:00.000Z") const result = describeCollapsibleDate(date) - attest(result).snap("2:30 PM, January 15, 2023") + attest(result).snap("January 15, 2023, 2:30 PM UTC") }) it("returns full date and time for date with seconds", () => { - const date = newYorkDate("1993-02-15T14:30:31") + const date = new Date("1993-02-15T19:30:31Z") const result = describeCollapsibleDate(date) - attest(result).snap("7:30:31 PM, February 15, 1993") + attest(result).snap("February 15, 1993, 7:30:31 PM UTC") }) it("returns full date and time with milliseconds", () => { - const date = newYorkDate("2023-12-15T14:30:00.123Z") + const date = new Date("2023-12-15T14:30:00.123Z") const result = describeCollapsibleDate(date) - attest(result).snap("2:30:00.123 PM, December 15, 2023") + attest(result).snap("December 15, 2023, 2:30:00.123 PM UTC") }) it("handles midnight correctly", () => { - const date = newYorkDate("2023-01-15T00:00:00.000Z") + const date = new Date("2023-01-15T00:00:00.000Z") const result = describeCollapsibleDate(date) attest(result).snap("January 15, 2023") }) it("handles noon correctly", () => { - const date = newYorkDate("2023-02-15T12:00:00.000Z") + const date = new Date("2023-02-15T12:00:00.000Z") const result = describeCollapsibleDate(date) - attest(result).snap("12:00 PM, February 15, 2023") + attest(result).snap("February 15, 2023, 12:00 PM UTC") }) it("handles AM/PM correctly", () => { - const dateAM = newYorkDate("2023-01-15T09:00:00.000Z") + const dateAM = new Date("2023-01-15T09:00:00.000Z") const resultAM = describeCollapsibleDate(dateAM) - attest(resultAM).snap("9:00 AM, January 15, 2023") + attest(resultAM).snap("January 15, 2023, 9:00 AM UTC") - const datePM = newYorkDate("2023-01-15T21:00:00.000Z") + const datePM = new Date("2023-01-15T21:00:00.000Z") const resultPM = describeCollapsibleDate(datePM) - attest(resultPM).snap("9:00 PM, January 15, 2023") + attest(resultPM).snap("January 15, 2023, 9:00 PM UTC") + }) + + it("collapses local midnight", () => { + attest(describeCollapsibleDate(new Date(2023, 0, 1))).snap("2023") + attest(describeCollapsibleDate(new Date(2023, 0, 15))).snap( + "January 15, 2023" + ) + }) + + it("describes local times in UTC", () => { + // 9 AM in New York is 2 PM UTC + const date = new Date(2023, 0, 15, 9) + attest(describeCollapsibleDate(date)).snap("January 15, 2023, 2:00 PM UTC") + }) + + it("doesn't collapse year for a time on January 1", () => { + const date = new Date("2023-01-01T14:30:00.000Z") + attest(describeCollapsibleDate(date)).snap("January 1, 2023, 2:30 PM UTC") }) it("doesn't depend on the default locale's time format", () => { @@ -72,8 +82,10 @@ contextualize(() => { return toLocaleTimeString.call(this, "de-DE") } try { - const date = newYorkDate("2023-01-15T14:30:00.000Z") - attest(describeCollapsibleDate(date)).snap("2:30 PM, January 15, 2023") + const date = new Date("2023-01-15T14:30:00.000Z") + attest(describeCollapsibleDate(date)).snap( + "January 15, 2023, 2:30 PM UTC" + ) } finally { Date.prototype.toLocaleTimeString = toLocaleTimeString } diff --git a/ark/util/__tests__/printable.test.ts b/ark/util/__tests__/printable.test.ts index 95cb12d1c8..66d7e75540 100644 --- a/ark/util/__tests__/printable.test.ts +++ b/ark/util/__tests__/printable.test.ts @@ -101,17 +101,17 @@ contextualize(() => { it("date", () => { const d = new Date("2023-10-03T14:30:15.123Z") - attest(printable(d)).snap("10:30:15.123 AM, October 3, 2023") + attest(printable(d)).snap("October 3, 2023, 2:30:15.123 PM UTC") }) it("date only year", () => { const d = new Date("2023-01-01T00:00:00.000Z") - attest(printable(d)).snap("7:00 PM, December 31, 2022") + attest(printable(d)).snap("2023") }) it("date only date", () => { const d = new Date("2023-05-15T00:00:00.000Z") - attest(printable(d)).snap("8:00 PM, May 14, 2023") + attest(printable(d)).snap("May 15, 2023") }) it("instance with expression", () => { diff --git a/ark/util/serialize.ts b/ark/util/serialize.ts index bac5a5fe12..4820448dc7 100644 --- a/ark/util/serialize.ts +++ b/ark/util/serialize.ts @@ -178,30 +178,22 @@ const _serialize = ( /** * Converts a Date instance to a human-readable description relative to its precision + * + * A Date can't tell us whether its author meant a calendar date in UTC or in + * local time: ISO date-only strings like "2023-01-01" parse as UTC midnight, + * while "2023/1/1" and `new Date(2023, 0, 1)` are local midnight. Dates at + * midnight in either are described as just a date, so both collapse as + * written. Anything with a time is described in UTC, labeled as such, so the + * result doesn't depend on the host's timezone. */ export const describeCollapsibleDate = (date: Date): string => { - const year = date.getFullYear() - const month = date.getMonth() - const dayOfMonth = date.getDate() - const hours = date.getHours() - const minutes = date.getMinutes() - const seconds = date.getSeconds() - const milliseconds = date.getMilliseconds() - - if ( - month === 0 && - dayOfMonth === 1 && - hours === 0 && - minutes === 0 && - seconds === 0 && - milliseconds === 0 - ) - return `${year}` - - const datePortion = `${months[month]} ${dayOfMonth}, ${year}` - - if (hours === 0 && minutes === 0 && seconds === 0 && milliseconds === 0) - return datePortion + const utc = utcFields(date) + if (isMidnight(utc)) return describeCalendarDate(utc) + + const local = localFields(date) + if (isMidnight(local)) return describeCalendarDate(local) + + const { hours, minutes, seconds, milliseconds } = utc const h = hours % 12 || 12 const suffix = hours < 12 ? " AM" : " PM" @@ -213,9 +205,53 @@ export const describeCollapsibleDate = (date: Date): string => { if (milliseconds) timePortion += `.${pad(milliseconds, 3)}` - return `${timePortion + suffix}, ${datePortion}` + return `${describeFullDate(utc)}, ${timePortion + suffix} UTC` } +type DateFields = { + year: number + month: number + dayOfMonth: number + hours: number + minutes: number + seconds: number + milliseconds: number +} + +const utcFields = (date: Date): DateFields => ({ + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + dayOfMonth: date.getUTCDate(), + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + milliseconds: date.getUTCMilliseconds() +}) + +const localFields = (date: Date): DateFields => ({ + year: date.getFullYear(), + month: date.getMonth(), + dayOfMonth: date.getDate(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + milliseconds: date.getMilliseconds() +}) + +const isMidnight = (fields: DateFields) => + fields.hours === 0 && + fields.minutes === 0 && + fields.seconds === 0 && + fields.milliseconds === 0 + +const describeCalendarDate = (fields: DateFields) => + fields.month === 0 && fields.dayOfMonth === 1 ? + `${fields.year}` + : describeFullDate(fields) + +const describeFullDate = ({ year, month, dayOfMonth }: DateFields) => + `${months[month]} ${dayOfMonth}, ${year}` + const months = [ "January", "February",