Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ark/schema/__tests__/bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)
})

Expand Down
14 changes: 14 additions & 0 deletions ark/type/__tests__/range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}'`)
Expand Down
68 changes: 40 additions & 28 deletions ark/util/__tests__/collapsibleDate.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions ark/util/__tests__/printable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
82 changes: 59 additions & 23 deletions ark/util/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collapses any instant that lands on UTC midnight, even when it was built from a local time. new Date(2023, 0, 15, 19) in New York (or d'2023-01-15T19:00') renders as January 16, 2023, and new Date(2023, 0, 15, 9) in Tokyo renders as January 15, 2023 — both drop the time, and the first shifts the calendar date. As the doc comment notes, a Date can't distinguish this from a genuine UTC date-only input, so there's no clean fix; it's just a second known gap (alongside exclusive bounds) worth calling out.


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"
Expand All @@ -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",
Expand Down
Loading