Skip to content
Merged
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
99 changes: 93 additions & 6 deletions src/duration.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<RoundingOpts> = {}): Duration {
export function roundToSingleUnit(
duration: Duration,
{relativeTo = Date.now(), date, timeZone}: Partial<RoundingOpts> = {},
): Duration {
relativeTo = new Date(relativeTo)
if (duration.blank) return duration
const sign = duration.sign
Expand All @@ -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
Comment thread
gabrielcaires marked this conversation as resolved.
Resolved
Comment thread
gabrielcaires marked this conversation as resolved.
Resolved
} 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
Expand Down Expand Up @@ -263,7 +346,11 @@ export function getRelativeTimeUnit(
duration: Duration,
opts?: Partial<RoundingOpts>,
): [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
Expand Down
31 changes: 19 additions & 12 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import {
applyDuration,
elapsedTime,
getRelativeTimeUnit,
getSingleUnit,
isDuration,
msUntilNextDay,
roundToSingleUnit,
RoundingOpts,
Unit,
unitNames,
} from './duration.js'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 &&
Expand All @@ -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
Expand All @@ -307,15 +313,15 @@ 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,
})
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)
}
Expand Down Expand Up @@ -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

Expand All @@ -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)
}
Expand Down
82 changes: 79 additions & 3 deletions test/duration.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')}],
Expand All @@ -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')}],
Expand Down Expand Up @@ -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']],
Expand Down
12 changes: 12 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
Loading