diff --git a/ark/schema/__tests__/bounds.test.ts b/ark/schema/__tests__/bounds.test.ts index 485aea89d..a43541008 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 1f12c2c56..0ba1cb24a 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 ce21b23c9..b0e857400 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 95cb12d1c..66d7e7554 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 bac5a5fe1..4820448dc 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",