diff --git a/src/duration.ts b/src/duration.ts index 0195fc2..f9ae320 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -1,6 +1,6 @@ import DurationFormat from './duration-format-ponyfill.js' import type {DurationFormatOptions} from './duration-format-ponyfill.js' -import {createCache} from './intl-cache.js' +import {createCache, dateTimeFormat} from './intl-cache.js' const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/ // `DurationFormat` normalizes its options on construction, so reuse one instance @@ -169,11 +169,75 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n ) } -interface RoundingOpts { +const msPerDay = 24 * 60 * 60 * 1000 + +// Wall-clock time of `date` in `timeZone` (or the local zone), read as if it were UTC +function wallClockTime(date: Date, timeZone?: string): number { + if (!timeZone) return Number(date) - date.getTimezoneOffset() * 60 * 1000 + const parts = dateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + era: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }).formatToParts(date) + const get = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find(part => part.type === type)?.value) + // Era years count 1 BC, 2 BC, ... back from 1 AD, without a year zero + const year = parts.find(part => part.type === 'era')?.value === 'BC' ? 1 - get('year') : get('year') + // Not Date.UTC, which reads years 0-99 as 1900-1999 + const wallClock = new Date(0) + wallClock.setUTCFullYear(year, get('month') - 1, get('day')) + wallClock.setUTCHours(get('hour'), get('minute'), get('second'), date.getMilliseconds()) + return Number(wallClock) +} + +function calendarDaysBetween(a: Date, b: Date, timeZone?: string): number { + return Math.abs(Math.floor(wallClockTime(b, timeZone) / msPerDay) - Math.floor(wallClockTime(a, timeZone) / msPerDay)) +} + +function utcOffset(time: number, timeZone?: string): number { + return wallClockTime(new Date(time), timeZone) - time +} + +// Real time until the calendar date changes in either direction, following offset changes such as DST +export function msUntilNextDay(now: Date | number, timeZone?: string): number { + const start = Number(now) + let offset = utcOffset(start, timeZone) + const today = Math.floor((start + offset) / msPerDay) + const nextDay = (today + 1) * msPerDay + let time = start + for (let i = 0; i < 3; i++) { + const midnight = nextDay - offset + if (utcOffset(midnight, timeZone) === offset) return midnight - start + // The offset changes before midnight: find when, as the jump itself may change the date + let changed = midnight + while (changed - time > 1) { + const mid = Math.floor((time + changed) / 2) + if (utcOffset(mid, timeZone) === offset) time = mid + else changed = mid + } + time = changed + offset = utcOffset(time, timeZone) + if (Math.floor((time + offset) / msPerDay) !== today) return time - start + } + return time - start +} + +export interface RoundingOpts { relativeTo: Date | number + // Exact date, as durations from elapsedTime are truncated to their precision + date: Date | number + timeZone?: string } -export function roundToSingleUnit(duration: Duration, {relativeTo = Date.now()}: Partial = {}): Duration { +export function roundToSingleUnit( + duration: Duration, + {relativeTo = Date.now(), date, timeZone}: Partial = {}, +): Duration { relativeTo = new Date(relativeTo) if (duration.blank) return duration const sign = duration.sign @@ -197,8 +261,27 @@ export function roundToSingleUnit(duration: Duration, {relativeTo = Date.now()}: if (minutes >= 55) hours += Math.round(minutes / 60) if (hours || days || weeks || months || years) minutes = 0 - if (days && hours >= 12) days += Math.round(hours / 24) - if (!days && hours >= 21) days += Math.round(hours / 24) + if ((days || hours >= 21) && !weeks && !months && !years) { + // "yesterday" is a calendar day, not 24 hours, and a 23 hour DST day can span two dates + const calendarDays = calendarDaysBetween( + relativeTo, + date === undefined ? applyDuration(relativeTo, duration) : new Date(date), + timeZone, + ) + if (calendarDays) { + days = calendarDays + } else if (days) { + // Same calendar day, possible on a 25 hour DST day + hours += days * 24 + days = 0 + } else { + days += Math.round(hours / 24) + } + } else if (days && hours >= 12) { + days += Math.round(hours / 24) + } else if (!days && hours >= 21) { + days += Math.round(hours / 24) + } if (days || weeks || months || years) hours = 0 // Resolve calendar dates @@ -263,7 +346,11 @@ export function getRelativeTimeUnit( duration: Duration, opts?: Partial, ): [number, Intl.RelativeTimeFormatUnit] { - const rounded = roundToSingleUnit(duration, opts) + return getSingleUnit(roundToSingleUnit(duration, opts)) +} + +// Unit of a duration already rounded by roundToSingleUnit, which is not idempotent +export function getSingleUnit(rounded: Duration): [number, Intl.RelativeTimeFormatUnit] { if (rounded.blank) return [0, 'second'] for (const unit of unitNames) { if (unit === 'millisecond') continue diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 801994d..f42822f 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -3,8 +3,11 @@ import { applyDuration, elapsedTime, getRelativeTimeUnit, + getSingleUnit, isDuration, + msUntilNextDay, roundToSingleUnit, + RoundingOpts, Unit, unitNames, } from './duration.js' @@ -50,6 +53,9 @@ function getUnitFactor(el: RelativeTimeElement): number { factor = 1000 } else if (ms < 60 * 60 * 1000) { factor = 60 * 1000 + } else { + // Day rounding follows the calendar, so labels like "yesterday" change at midnight + factor = Math.min(factor, msUntilNextDay(now, el.timeZone)) } const threshold = getExplicitThreshold(el) if (el.format === 'micro' && threshold) { @@ -267,8 +273,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return 'datetime' } - #getMicroDuration(duration: Duration): Duration { - duration = roundToSingleUnit(duration) + #getMicroDuration(duration: Duration, rounding: RoundingOpts): Duration { + duration = roundToSingleUnit(duration, rounding) // Allow month-level durations to pass through even with mismatched tense if ( duration.months === 0 && @@ -279,23 +285,23 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration } - #getMicroRelativeFormat(duration: Duration, locale: string): string { + #getMicroRelativeFormat(duration: Duration, locale: string, rounding: RoundingOpts): string { const relativeFormat = relativeTimeFormat(locale, { numeric: 'always', style: 'narrow', }) - duration = this.#getMicroDuration(duration) - const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration) + duration = this.#getMicroDuration(duration, rounding) + const [int, unit] = getSingleUnit(duration.blank ? microEmptyDuration : duration) return relativeFormat.format(Math.abs(int) * (this.tense === 'past' ? -1 : 1), unit) } - #getDurationFormat(duration: Duration, locale: string): string { + #getDurationFormat(duration: Duration, locale: string, rounding: RoundingOpts): string { const format = this.format const style = this.formatStyle const tense = this.tense let empty = emptyDuration if (format === 'micro') { - duration = this.#getMicroDuration(duration) + duration = this.#getMicroDuration(duration, rounding) empty = microEmptyDuration } else if ((tense === 'past' && duration.sign !== -1) || (tense === 'future' && duration.sign !== 1)) { duration = empty @@ -307,7 +313,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration.abs().toLocaleString(locale, {style}) } - #getRelativeFormat(duration: Duration, locale: string): string { + #getRelativeFormat(duration: Duration, locale: string, rounding: RoundingOpts): string { const relativeFormat = relativeTimeFormat(locale, { numeric: 'auto', style: this.formatStyle, @@ -315,7 +321,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const tense = this.tense if (tense === 'future' && duration.sign !== 1) duration = emptyDuration if (tense === 'past' && duration.sign !== -1) duration = emptyDuration - const [int, unit] = getRelativeTimeUnit(duration) + const [int, unit] = getRelativeTimeUnit(duration, rounding) if (unit === 'second' && Math.abs(int) < 10) { return relativeFormat.format(0, this.precision === 'millisecond' ? 'second' : this.precision) } @@ -698,6 +704,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } const duration = elapsedTime(date, this.precision, now) + const rounding = {relativeTo: now, date, timeZone} const format = this.#resolveFormat(duration, elapsedTime(date, 'millisecond', now)) let newText = oldText @@ -708,12 +715,12 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } else { if (format === 'duration') { if (this.format === 'micro' && this.tense !== 'auto' && Intl.RelativeTimeFormat) { - newText = this.#getMicroRelativeFormat(duration, locale) + newText = this.#getMicroRelativeFormat(duration, locale, rounding) } else { - newText = this.#getDurationFormat(duration, locale) + newText = this.#getDurationFormat(duration, locale, rounding) } } else if (format === 'relative') { - newText = this.#getRelativeFormat(duration, locale) + newText = this.#getRelativeFormat(duration, locale, rounding) } else { newText = this.#getDateTimeFormat(date, locale, timeZone, hourCycle) } diff --git a/test/duration.ts b/test/duration.ts index 87707de..fdcb8fb 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -1,5 +1,12 @@ import {assert} from '@open-wc/testing' -import {applyDuration, Duration, elapsedTime, getRelativeTimeUnit, roundToSingleUnit} from '../src/duration.ts' +import { + applyDuration, + Duration, + elapsedTime, + getRelativeTimeUnit, + msUntilNextDay, + roundToSingleUnit, +} from '../src/duration.ts' import type {Unit} from '../src/duration.ts' import type {DurationFormatOptions} from '../src/duration-format-ponyfill.ts' import {Temporal} from '@js-temporal/polyfill' @@ -263,8 +270,53 @@ suite('duration', function () { ['PT1H55M', 'PT2H'], ['PT20H', 'PT20H'], ['PT21H', 'P1D'], - ['P1DT20H', 'P2D'], - ['P1DT18H', 'P2D'], + ['P1DT20H', 'P2D', {relativeTo: new Date('2023-07-01T12:00:00')}], + ['-P1DT20H', '-P2D', {relativeTo: new Date('2023-07-01T12:00:00')}], + ['P1DT18H', 'P2D', {relativeTo: new Date('2023-07-01T12:00:00')}], + ['-P1DT18H', '-P2D', {relativeTo: new Date('2023-07-01T12:00:00')}], + ['-P1DT13H', '-P1D', {relativeTo: new Date('2025-04-30T13:53:00')}], + ['-P1DT14H', '-P2D', {relativeTo: new Date('2025-04-30T13:53:00')}], + ['P1DT13H', 'P1D', {relativeTo: new Date('2025-04-30T10:00:00')}], + ['P1DT13H', 'P2D', {relativeTo: new Date('2025-04-30T13:53:00')}], + ['-P2DT33M', '-P3D', {relativeTo: new Date('2025-04-30T00:30:00')}], + ['P2DT33M', 'P3D', {relativeTo: new Date('2025-04-30T23:30:00')}], + [ + 'P1DT13H59M59S', + 'P2D', + {relativeTo: new Date('2025-04-30T10:00:00.500'), date: new Date('2025-05-02T00:00:00')}, + ], + ['-P1D', '-P2D', {relativeTo: new Date('2025-04-30T00:30:00'), date: new Date('2025-04-28T01:00:00')}], + ['-P1D', '-PT24H', {relativeTo: new Date('2025-11-02T23:30:00'), date: new Date('2025-11-02T00:30:00')}], + ['-P1DT2H', '-P2D', {relativeTo: new Date('2025-04-30T21:30:00Z'), date: new Date('2025-04-29T19:30:00Z')}], + [ + '-P1DT2H', + '-P1D', + { + relativeTo: new Date('2025-04-30T21:30:00Z'), + date: new Date('2025-04-29T19:30:00Z'), + timeZone: 'America/New_York', + }, + ], + ['-P1D', '-P2D', {relativeTo: new Date('1970-01-02T21:00:00Z'), date: 0}], + [ + '-P1D', + '-P1D', + {relativeTo: new Date('0100-01-01T12:00:00Z'), date: new Date('0099-12-31T12:00:00Z'), timeZone: 'UTC'}, + ], + [ + '-P1D', + '-P1D', + {relativeTo: new Date('0001-01-01T12:00:00Z'), date: new Date('0000-12-31T12:00:00Z'), timeZone: 'UTC'}, + ], + [ + '-PT23H', + '-P2D', + { + relativeTo: new Date('2025-03-10T04:00:00Z'), + date: new Date('2025-03-09T04:59:00Z'), + timeZone: 'America/New_York', + }, + ], ['P4D', 'P4D', {relativeTo: new Date('2023-07-01T00:00:00')}], ['-P4D', '-P4D', {relativeTo: new Date('2023-07-01T00:00:00')}], ['P6D', 'P1W', {relativeTo: new Date('2023-07-01T00:00:00')}], @@ -283,6 +335,8 @@ suite('duration', function () { ['-P24DT25H', '-P1M', {relativeTo: new Date('2023-07-01T00:00:00')}], ['P25D', 'P1M', {relativeTo: new Date('2023-07-01T00:00:00')}], ['-P25D', '-P1M', {relativeTo: new Date('2023-07-01T00:00:00')}], + ['P11MT23H', 'P1Y', {relativeTo: new Date('2023-12-31T12:00:00')}], + ['-P11MT23H', '-P1Y', {relativeTo: new Date('2023-01-01T12:00:00')}], ['P1M1D', 'P1M', {relativeTo: new Date('2023-07-02T00:00:00')}], ['-P1M1D', '-P1M', {relativeTo: new Date('2023-07-02T00:00:00')}], ['P1M1D', 'P2M', {relativeTo: new Date('2023-07-31T00:00:00')}], @@ -356,6 +410,28 @@ suite('duration', function () { } }) + suite('msUntilNextDay', function () { + test('counts to local midnight', () => { + assert.equal(msUntilNextDay(new Date('2025-04-30T23:30:00')), 30 * 60 * 1000) + }) + + test('counts to midnight in the given time zone', () => { + assert.equal(msUntilNextDay(new Date('2025-05-01T03:30:00Z'), 'America/New_York'), 30 * 60 * 1000) + }) + + test('follows a DST jump before midnight', () => { + assert.equal(msUntilNextDay(new Date('2025-03-30T00:30:00Z'), 'America/Nuuk'), 30 * 60 * 1000) + }) + + test('follows a clock rollback to the previous date', () => { + assert.equal(msUntilNextDay(new Date('2010-03-04T14:30:00Z'), 'Antarctica/Casey'), 30 * 60 * 1000) + }) + + test('follows a fall back within the same date', () => { + assert.equal(msUntilNextDay(new Date('2025-11-02T05:30:00Z'), 'America/New_York'), 23.5 * 60 * 60 * 1000) + }) + }) + suite('getRelativeTimeUnit', function () { const relativeTests = new Set([ ['PT20S', [20, 'second']], diff --git a/test/relative-time.js b/test/relative-time.js index 84be196..51a547b 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -834,6 +834,7 @@ suite('relative-time', function () { }) test('micro formats days', async () => { + freezeTime(new Date(2024, 0, 15, 12)) const now = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString() const time = document.createElement('relative-time') time.setAttribute('tense', 'past') @@ -936,7 +937,18 @@ suite('relative-time', function () { assert.equal(time.shadowRoot.textContent, 'in 1h') }) + test('micro formats same-day hours rounded to a day', async () => { + freezeTime(new Date(2024, 0, 15, 0, 30)) + const time = document.createElement('relative-time') + time.setAttribute('tense', 'future') + time.setAttribute('datetime', new Date(2024, 0, 15, 22, 30).toISOString()) + time.setAttribute('format', 'micro') + await Promise.resolve() + assert.equal(time.shadowRoot.textContent, 'in 1d') + }) + test('micro formats days', async () => { + freezeTime(new Date(2024, 0, 15, 12)) const now = new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString() const time = document.createElement('relative-time') time.setAttribute('tense', 'future')