From 944e6b8cce9ce8585f918357747e9f74478df87b Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Tue, 3 Nov 2026 12:43:26 +0000 Subject: [PATCH 01/10] Use calendar days when rounding day durations "yesterday" and "tomorrow" were decided by rounding the hours part, so a date 36-37 hours ago (still yesterday on the calendar) showed as "2 days ago". When a duration has days but no weeks/months/years, count the calendar days between relativeTo and the target date instead. The element now also passes the `now` it used for elapsedTime as relativeTo, so both calculations agree near midnight. Fixes #313 Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 13 ++++++++++++- src/relative-time-element.ts | 24 ++++++++++++------------ test/duration.ts | 17 +++++++++++++++-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 0195fc2..53c4171 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -169,6 +169,12 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n ) } +function calendarDaysBetween(a: Date, b: Date): number { + const startA = new Date(a.getFullYear(), a.getMonth(), a.getDate()) + const startB = new Date(b.getFullYear(), b.getMonth(), b.getDate()) + return Math.abs(Math.round((Number(startB) - Number(startA)) / 86400000)) +} + interface RoundingOpts { relativeTo: Date | number } @@ -197,7 +203,12 @@ 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 && !weeks && !months && !years) { + // Count calendar days so "yesterday"/"tomorrow" match the date, not a 24h rounding + days = calendarDaysBetween(relativeTo, applyDuration(relativeTo, duration)) + } else if (days && hours >= 12) { + days += Math.round(hours / 24) + } if (!days && hours >= 21) days += Math.round(hours / 24) if (days || weeks || months || years) hours = 0 diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 45d0390..4641c5a 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -267,8 +267,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return 'datetime' } - #getMicroDuration(duration: Duration): Duration { - duration = roundToSingleUnit(duration) + #getMicroDuration(duration: Duration, now: number): Duration { + duration = roundToSingleUnit(duration, {relativeTo: now}) // Allow month-level durations to pass through even with mismatched tense if ( duration.months === 0 && @@ -279,23 +279,23 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration } - #getMicroRelativeFormat(duration: Duration, locale: string): string { + #getMicroRelativeFormat(duration: Duration, locale: string, now: number): 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, now) + const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration, {relativeTo: now}) return relativeFormat.format(Math.abs(int) * (this.tense === 'past' ? -1 : 1), unit) } - #getDurationFormat(duration: Duration, locale: string): string { + #getDurationFormat(duration: Duration, locale: string, now: number): 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, now) empty = microEmptyDuration } else if ((tense === 'past' && duration.sign !== -1) || (tense === 'future' && duration.sign !== 1)) { duration = empty @@ -307,7 +307,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, now: number): string { const relativeFormat = relativeTimeFormat(locale, { numeric: 'auto', style: this.formatStyle, @@ -315,7 +315,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, {relativeTo: now}) if (unit === 'second' && int < 10) { return relativeFormat.format(0, this.precision === 'millisecond' ? 'second' : this.precision) } @@ -708,12 +708,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, now) } else { - newText = this.#getDurationFormat(duration, locale) + newText = this.#getDurationFormat(duration, locale, now) } } else if (format === 'relative') { - newText = this.#getRelativeFormat(duration, locale) + newText = this.#getRelativeFormat(duration, locale, now) } else { newText = this.#getDateTimeFormat(date, locale, timeZone, hourCycle) } diff --git a/test/duration.ts b/test/duration.ts index 87707de..e805ad7 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -263,8 +263,21 @@ 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')}], + // "yesterday"/"tomorrow" follow calendar days, not 24h rounding (#313) + ['-P1DT13H', '-P1D', {relativeTo: new Date('2025-04-30T13:53:00')}], + ['-P1DT12H', '-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')}], + ['-P2DT20H', '-P2D', {relativeTo: new Date('2025-04-30T22:00:00')}], + ['-P2DT20H', '-P3D', {relativeTo: new Date('2025-04-30T10:00:00')}], + ['-P2DT33M', '-P3D', {relativeTo: new Date('2025-04-30T00:30:00')}], + ['P2DT33M', 'P3D', {relativeTo: new Date('2025-04-30T23:30:00')}], + ['-P2DT33M', '-P2D', {relativeTo: new Date('2025-04-30T13:53:00')}], ['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')}], From 0aa52b8f056174c58056040cf8d01917c18f1615 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Tue, 3 Nov 2026 12:53:23 +0000 Subject: [PATCH 02/10] Count calendar days from the exact date Rebuilding the target date from a precision-truncated duration could cross midnight (dropped milliseconds, precision="hour"/"day"), and re-rounding an already rounded micro duration could do the same across DST. roundToSingleUnit now accepts the exact `date` and the element passes it along with relativeTo. On a 25 hour DST day a full day of elapsed time can stay on one calendar date; keep that in hours instead of rounding it to "now". Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 22 ++++++++++++++++++---- src/relative-time-element.ts | 26 ++++++++++++++------------ test/duration.ts | 9 +++++++++ 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 53c4171..662abe8 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -175,11 +175,17 @@ function calendarDaysBetween(a: Date, b: Date): number { return Math.abs(Math.round((Number(startB) - Number(startA)) / 86400000)) } -interface RoundingOpts { +export interface RoundingOpts { relativeTo: Date | number + // The exact date the duration was measured to. Durations from `elapsedTime` are truncated to their precision, so + // rebuilding the date from them can land on the wrong side of midnight. + date: Date | number } -export function roundToSingleUnit(duration: Duration, {relativeTo = Date.now()}: Partial = {}): Duration { +export function roundToSingleUnit( + duration: Duration, + {relativeTo = Date.now(), date}: Partial = {}, +): Duration { relativeTo = new Date(relativeTo) if (duration.blank) return duration const sign = duration.sign @@ -205,11 +211,19 @@ export function roundToSingleUnit(duration: Duration, {relativeTo = Date.now()}: if (days && !weeks && !months && !years) { // Count calendar days so "yesterday"/"tomorrow" match the date, not a 24h rounding - days = calendarDaysBetween(relativeTo, applyDuration(relativeTo, duration)) + const calendarDays = calendarDaysBetween(relativeTo, date ? new Date(date) : applyDuration(relativeTo, duration)) + if (calendarDays) { + days = calendarDays + } else { + // Still the same calendar day (a 25 hour DST day), so keep it in hours + hours += days * 24 + days = 0 + } } else if (days && hours >= 12) { days += Math.round(hours / 24) + } else if (!days && hours >= 21) { + days += Math.round(hours / 24) } - if (!days && hours >= 21) days += Math.round(hours / 24) if (days || weeks || months || years) hours = 0 // Resolve calendar dates diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 4641c5a..bcaa9d3 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -5,6 +5,7 @@ import { getRelativeTimeUnit, isDuration, roundToSingleUnit, + RoundingOpts, Unit, unitNames, } from './duration.js' @@ -267,8 +268,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return 'datetime' } - #getMicroDuration(duration: Duration, now: number): Duration { - duration = roundToSingleUnit(duration, {relativeTo: now}) + #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 +280,23 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration } - #getMicroRelativeFormat(duration: Duration, locale: string, now: number): string { + #getMicroRelativeFormat(duration: Duration, locale: string, rounding: RoundingOpts): string { const relativeFormat = relativeTimeFormat(locale, { numeric: 'always', style: 'narrow', }) - duration = this.#getMicroDuration(duration, now) - const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration, {relativeTo: now}) + duration = this.#getMicroDuration(duration, rounding) + const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration, rounding) return relativeFormat.format(Math.abs(int) * (this.tense === 'past' ? -1 : 1), unit) } - #getDurationFormat(duration: Duration, locale: string, now: number): 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, now) + duration = this.#getMicroDuration(duration, rounding) empty = microEmptyDuration } else if ((tense === 'past' && duration.sign !== -1) || (tense === 'future' && duration.sign !== 1)) { duration = empty @@ -307,7 +308,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration.abs().toLocaleString(locale, {style}) } - #getRelativeFormat(duration: Duration, locale: string, now: number): string { + #getRelativeFormat(duration: Duration, locale: string, rounding: RoundingOpts): string { const relativeFormat = relativeTimeFormat(locale, { numeric: 'auto', style: this.formatStyle, @@ -315,7 +316,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, {relativeTo: now}) + const [int, unit] = getRelativeTimeUnit(duration, rounding) if (unit === 'second' && int < 10) { return relativeFormat.format(0, this.precision === 'millisecond' ? 'second' : this.precision) } @@ -698,6 +699,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } const duration = elapsedTime(date, this.precision, now) + const rounding = {relativeTo: now, date} const format = this.#resolveFormat(duration, elapsedTime(date, 'millisecond', now)) let newText = oldText @@ -708,12 +710,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, now) + newText = this.#getMicroRelativeFormat(duration, locale, rounding) } else { - newText = this.#getDurationFormat(duration, locale, now) + newText = this.#getDurationFormat(duration, locale, rounding) } } else if (format === 'relative') { - newText = this.#getRelativeFormat(duration, locale, now) + 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 e805ad7..237533b 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -278,6 +278,15 @@ suite('duration', function () { ['-P2DT33M', '-P3D', {relativeTo: new Date('2025-04-30T00:30:00')}], ['P2DT33M', 'P3D', {relativeTo: new Date('2025-04-30T23:30:00')}], ['-P2DT33M', '-P2D', {relativeTo: new Date('2025-04-30T13:53:00')}], + // The exact date wins over one rebuilt from a truncated duration + [ + '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')}], + // A 25 hour DST day can hold a full day of elapsed time on one calendar date + ['-P1D', '-PT24H', {relativeTo: new Date('2025-11-02T23:30:00'), date: new Date('2025-11-02T00:30:00')}], ['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')}], From 5b7df856f75c08fbb715440ab61def48ed91a0cc Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Tue, 3 Nov 2026 12:57:57 +0000 Subject: [PATCH 03/10] Trim comments and redundant test cases Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 7 +++---- test/duration.ts | 7 ------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 662abe8..3b40014 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -177,8 +177,7 @@ function calendarDaysBetween(a: Date, b: Date): number { export interface RoundingOpts { relativeTo: Date | number - // The exact date the duration was measured to. Durations from `elapsedTime` are truncated to their precision, so - // rebuilding the date from them can land on the wrong side of midnight. + // Exact date, as durations from elapsedTime are truncated to their precision date: Date | number } @@ -210,12 +209,12 @@ export function roundToSingleUnit( if (hours || days || weeks || months || years) minutes = 0 if (days && !weeks && !months && !years) { - // Count calendar days so "yesterday"/"tomorrow" match the date, not a 24h rounding + // "yesterday" is a calendar day, not 24 hours const calendarDays = calendarDaysBetween(relativeTo, date ? new Date(date) : applyDuration(relativeTo, duration)) if (calendarDays) { days = calendarDays } else { - // Still the same calendar day (a 25 hour DST day), so keep it in hours + // Same calendar day, possible on a 25 hour DST day hours += days * 24 days = 0 } diff --git a/test/duration.ts b/test/duration.ts index 237533b..dec4766 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -267,25 +267,18 @@ suite('duration', function () { ['-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')}], - // "yesterday"/"tomorrow" follow calendar days, not 24h rounding (#313) ['-P1DT13H', '-P1D', {relativeTo: new Date('2025-04-30T13:53:00')}], - ['-P1DT12H', '-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')}], - ['-P2DT20H', '-P2D', {relativeTo: new Date('2025-04-30T22:00:00')}], - ['-P2DT20H', '-P3D', {relativeTo: new Date('2025-04-30T10:00:00')}], ['-P2DT33M', '-P3D', {relativeTo: new Date('2025-04-30T00:30:00')}], ['P2DT33M', 'P3D', {relativeTo: new Date('2025-04-30T23:30:00')}], - ['-P2DT33M', '-P2D', {relativeTo: new Date('2025-04-30T13:53:00')}], - // The exact date wins over one rebuilt from a truncated duration [ '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')}], - // A 25 hour DST day can hold a full day of elapsed time on one calendar date ['-P1D', '-PT24H', {relativeTo: new Date('2025-11-02T23:30:00'), date: new Date('2025-11-02T00:30:00')}], ['P4D', 'P4D', {relativeTo: new Date('2023-07-01T00:00:00')}], ['-P4D', '-P4D', {relativeTo: new Date('2023-07-01T00:00:00')}], From 8c58f77b36b03ded88a71cc4b1b2d2e5bf62e601 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Wed, 4 Nov 2026 13:39:17 +0000 Subject: [PATCH 04/10] Round calendar days in the element's time zone and refresh at midnight - Pass the resolved time zone into calendar-day rounding - Pick the micro unit from the already-rounded duration instead of rounding twice - Schedule an update at the next midnight for hour-scale dates - Freeze the clock in micro day-format tests Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 54 ++++++++++++++++++++++++++++++------ src/relative-time-element.ts | 9 ++++-- test/duration.ts | 29 ++++++++++++++++++- test/relative-time.js | 12 ++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 3b40014..02b5ab0 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,21 +169,51 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n ) } -function calendarDaysBetween(a: Date, b: Date): number { - const startA = new Date(a.getFullYear(), a.getMonth(), a.getDate()) - const startB = new Date(b.getFullYear(), b.getMonth(), b.getDate()) - return Math.abs(Math.round((Number(startB) - Number(startA)) / 86400000)) +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', + 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) + return Date.UTC( + get('year'), + get('month') - 1, + get('day'), + get('hour'), + get('minute'), + get('second'), + date.getMilliseconds(), + ) +} + +function calendarDaysBetween(a: Date, b: Date, timeZone?: string): number { + return Math.abs(Math.floor(wallClockTime(b, timeZone) / msPerDay) - Math.floor(wallClockTime(a, timeZone) / msPerDay)) +} + +export function msUntilNextDay(now: Date | number, timeZone?: string): number { + return msPerDay - (wallClockTime(new Date(now), timeZone) % msPerDay) } 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(), date}: Partial = {}, + {relativeTo = Date.now(), date, timeZone}: Partial = {}, ): Duration { relativeTo = new Date(relativeTo) if (duration.blank) return duration @@ -210,7 +240,11 @@ export function roundToSingleUnit( if (days && !weeks && !months && !years) { // "yesterday" is a calendar day, not 24 hours - const calendarDays = calendarDaysBetween(relativeTo, date ? new Date(date) : applyDuration(relativeTo, duration)) + const calendarDays = calendarDaysBetween( + relativeTo, + date ? new Date(date) : applyDuration(relativeTo, duration), + timeZone, + ) if (calendarDays) { days = calendarDays } else { @@ -287,7 +321,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 bcaa9d3..e35d62b 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -3,7 +3,9 @@ import { applyDuration, elapsedTime, getRelativeTimeUnit, + getSingleUnit, isDuration, + msUntilNextDay, roundToSingleUnit, RoundingOpts, Unit, @@ -51,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) { @@ -286,7 +291,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor style: 'narrow', }) duration = this.#getMicroDuration(duration, rounding) - const [int, unit] = getRelativeTimeUnit(duration.blank ? microEmptyDuration : duration, rounding) + const [int, unit] = getSingleUnit(duration.blank ? microEmptyDuration : duration) return relativeFormat.format(Math.abs(int) * (this.tense === 'past' ? -1 : 1), unit) } @@ -699,7 +704,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } const duration = elapsedTime(date, this.precision, now) - const rounding = {relativeTo: now, date} + const rounding = {relativeTo: now, date, timeZone} const format = this.#resolveFormat(duration, elapsedTime(date, 'millisecond', now)) let newText = oldText diff --git a/test/duration.ts b/test/duration.ts index dec4766..4cb5806 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' @@ -280,6 +287,16 @@ suite('duration', function () { ], ['-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', + }, + ], ['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')}], @@ -371,6 +388,16 @@ 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) + }) + }) + suite('getRelativeTimeUnit', function () { const relativeTests = new Set([ ['PT20S', [20, 'second']], diff --git a/test/relative-time.js b/test/relative-time.js index 1523051..3a342b0 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') From 1f8a2e8070d80ca49dd65615853572a46bbc6346 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Thu, 5 Nov 2026 10:02:58 +0000 Subject: [PATCH 05/10] Find the next calendar day across DST jumps and accept a zero timestamp Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 13 +++++++++++-- test/duration.ts | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 02b5ab0..10e084f 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -200,8 +200,17 @@ function calendarDaysBetween(a: Date, b: Date, timeZone?: string): number { return Math.abs(Math.floor(wallClockTime(b, timeZone) / msPerDay) - Math.floor(wallClockTime(a, timeZone) / msPerDay)) } +// Real time until the calendar date changes, which differs from wall-clock time when the offset changes first export function msUntilNextDay(now: Date | number, timeZone?: string): number { - return msPerDay - (wallClockTime(new Date(now), timeZone) % msPerDay) + const start = Number(now) + const nextDay = (Math.floor(wallClockTime(new Date(start), timeZone) / msPerDay) + 1) * msPerDay + let time = start + let next = Infinity + for (let i = 0; i < 3; i++) { + time += nextDay - wallClockTime(new Date(time), timeZone) + if (wallClockTime(new Date(time), timeZone) >= nextDay) next = Math.min(next, time) + } + return (next === Infinity ? time : next) - start } export interface RoundingOpts { @@ -242,7 +251,7 @@ export function roundToSingleUnit( // "yesterday" is a calendar day, not 24 hours const calendarDays = calendarDaysBetween( relativeTo, - date ? new Date(date) : applyDuration(relativeTo, duration), + date === undefined ? applyDuration(relativeTo, duration) : new Date(date), timeZone, ) if (calendarDays) { diff --git a/test/duration.ts b/test/duration.ts index 4cb5806..b4ef366 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -297,6 +297,7 @@ suite('duration', function () { timeZone: 'America/New_York', }, ], + ['-P1D', '-P2D', {relativeTo: new Date('1970-01-02T21:00:00Z'), date: 0}], ['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')}], @@ -396,6 +397,10 @@ suite('duration', function () { 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) + }) }) suite('getRelativeTimeUnit', function () { From 29c68e75cc9f574f37d922f3a3a1e8c1f5945ab7 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Fri, 6 Nov 2026 11:40:20 +0000 Subject: [PATCH 06/10] Detect date changes in either direction with fewer zone lookups Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 27 +++++++++++++++++++++------ test/duration.ts | 8 ++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 10e084f..11530cb 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -200,17 +200,32 @@ function calendarDaysBetween(a: Date, b: Date, timeZone?: string): number { return Math.abs(Math.floor(wallClockTime(b, timeZone) / msPerDay) - Math.floor(wallClockTime(a, timeZone) / msPerDay)) } -// Real time until the calendar date changes, which differs from wall-clock time when the offset changes first +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) - const nextDay = (Math.floor(wallClockTime(new Date(start), timeZone) / msPerDay) + 1) * msPerDay + let offset = utcOffset(start, timeZone) + const today = Math.floor((start + offset) / msPerDay) + const nextDay = (today + 1) * msPerDay let time = start - let next = Infinity for (let i = 0; i < 3; i++) { - time += nextDay - wallClockTime(new Date(time), timeZone) - if (wallClockTime(new Date(time), timeZone) >= nextDay) next = Math.min(next, time) + 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 (next === Infinity ? time : next) - start + return time - start } export interface RoundingOpts { diff --git a/test/duration.ts b/test/duration.ts index b4ef366..fe07178 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -401,6 +401,14 @@ suite('duration', function () { 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 () { From d1837b89ec7cf86e3058dd64353e01c9ac28ee48 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Fri, 6 Nov 2026 19:56:19 +0000 Subject: [PATCH 07/10] Count calendar days for hours rounded up to a day A 23 hour spring-forward day lets under 24 hours span two dates. Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 10 +++++----- test/duration.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 11530cb..9bbd9cb 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -262,8 +262,8 @@ export function roundToSingleUnit( if (minutes >= 55) hours += Math.round(minutes / 60) if (hours || days || weeks || months || years) minutes = 0 - if (days && !weeks && !months && !years) { - // "yesterday" is a calendar day, not 24 hours + 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), @@ -271,15 +271,15 @@ export function roundToSingleUnit( ) if (calendarDays) { days = calendarDays - } else { + } 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 diff --git a/test/duration.ts b/test/duration.ts index fe07178..f032872 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -298,6 +298,15 @@ suite('duration', function () { }, ], ['-P1D', '-P2D', {relativeTo: new Date('1970-01-02T21:00:00Z'), date: 0}], + [ + '-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')}], From 26edf75f39478818bb436610cdc40d08fdee5801 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Fri, 6 Nov 2026 21:29:56 +0000 Subject: [PATCH 08/10] Keep carrying 21+ hours into a day for month and year rounding Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 2 ++ test/duration.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/duration.ts b/src/duration.ts index 9bbd9cb..0b3c236 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -280,6 +280,8 @@ export function roundToSingleUnit( } } 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 diff --git a/test/duration.ts b/test/duration.ts index f032872..7f3fd99 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -325,6 +325,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')}], From a6e6173edfe267cd8b8eb77a12c9228be43431bd Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Fri, 6 Nov 2026 22:25:49 +0000 Subject: [PATCH 09/10] Read zoned wall-clock years 0-99 as written Date.UTC maps them to 1900-1999. Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 14 +++++--------- test/duration.ts | 5 +++++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 0b3c236..3a1518a 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -185,15 +185,11 @@ function wallClockTime(date: Date, timeZone?: string): number { second: 'numeric', }).formatToParts(date) const get = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find(part => part.type === type)?.value) - return Date.UTC( - get('year'), - get('month') - 1, - get('day'), - get('hour'), - get('minute'), - get('second'), - date.getMilliseconds(), - ) + // Not Date.UTC, which reads years 0-99 as 1900-1999 + const wallClock = new Date(0) + wallClock.setUTCFullYear(get('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 { diff --git a/test/duration.ts b/test/duration.ts index 7f3fd99..7fdea07 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -298,6 +298,11 @@ suite('duration', function () { }, ], ['-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'}, + ], [ '-PT23H', '-P2D', From 2c8cc21b1f4b27142caea128153b3ee13d50f574 Mon Sep 17 00:00:00 2001 From: Gabriel Caires Date: Fri, 6 Nov 2026 23:20:50 +0000 Subject: [PATCH 10/10] Read BC era years in zoned wall-clock time Co-Authored-By: Claude Opus 5.5 --- src/duration.ts | 5 ++++- test/duration.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/duration.ts b/src/duration.ts index 3a1518a..f9ae320 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -177,6 +177,7 @@ function wallClockTime(date: Date, timeZone?: string): number { const parts = dateTimeFormat('en-US', { timeZone, hourCycle: 'h23', + era: 'short', year: 'numeric', month: 'numeric', day: 'numeric', @@ -185,9 +186,11 @@ function wallClockTime(date: Date, timeZone?: string): number { 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(get('year'), get('month') - 1, get('day')) + wallClock.setUTCFullYear(year, get('month') - 1, get('day')) wallClock.setUTCHours(get('hour'), get('minute'), get('second'), date.getMilliseconds()) return Number(wallClock) } diff --git a/test/duration.ts b/test/duration.ts index 7fdea07..fdcb8fb 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -303,6 +303,11 @@ suite('duration', function () { '-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',