diff --git a/src/duration.ts b/src/duration.ts index 74cb017..ee82e28 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -119,6 +119,72 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n ) } +const durationRoundingThresholds = [ + Infinity, // Year + 11, // Month + 28, // Day + 21, // Hour + 55, // Minute + 55, // Second + 900, // Millisecond +] + +export function relativeTime( + date: Date, + precision: Unit = 'second', + nowTimestamp: Date | number = Date.now(), +): [number, Intl.RelativeTimeFormatUnit] { + let precisionIndex = unitNames.indexOf(precision) + if (precisionIndex === -1) { + precisionIndex = unitNames.length + } + const now = new Date(nowTimestamp) + const sign = Math.sign(date.getTime() - now.getTime()) + const dateWithoutTime = new Date(date) + dateWithoutTime.setHours(0) + dateWithoutTime.setMinutes(0) + dateWithoutTime.setSeconds(0) + dateWithoutTime.setMilliseconds(0) + const nowWithoutTime = new Date(now) + nowWithoutTime.setHours(0) + nowWithoutTime.setMinutes(0) + nowWithoutTime.setSeconds(0) + nowWithoutTime.setMilliseconds(0) + if ( + precisionIndex >= 4 && // At least hour. + (dateWithoutTime.getTime() === nowWithoutTime.getTime() || + Math.abs(date.getTime() - now.getTime()) < 1000 * 60 * 60 * 12) + ) { + const difference = Math.round(((date.getTime() - now.getTime()) / 1000) * sign) + let hours = Math.floor(difference / 3600) + let minutes = Math.floor((difference % 3600) / 60) + const seconds = Math.floor(difference % 60) + if (hours === 0) { + if (seconds >= durationRoundingThresholds[5]) minutes += 1 + if (minutes >= durationRoundingThresholds[4]) return [sign, 'hour'] + if (precision === 'hour') return [0, 'hour'] + if (minutes === 0 && precisionIndex >= 6) return [seconds * sign, 'second'] + return [minutes * sign, 'minute'] + } else { + if (hours < 23 && minutes >= durationRoundingThresholds[4]) hours += 1 + return [hours * sign, 'hour'] + } + } + const days = Math.round(((dateWithoutTime.getTime() - nowWithoutTime.getTime()) / (1000 * 60 * 60 * 24)) * sign) + const months = date.getFullYear() * 12 + date.getMonth() - (now.getFullYear() * 12 + now.getMonth()) + if ( + precisionIndex >= 2 && // At least week. + (months === 0 || days <= 26) + ) { + if (precision === 'week' || days >= 6) return [Math.floor((days + 1) / 7) * sign, 'week'] + return [days * sign, 'day'] + } + if (precision !== 'year' && Math.abs(months) < 12) { + return [months, 'month'] + } + return [date.getFullYear() - now.getFullYear(), 'year'] +} + interface RoundingOpts { relativeTo: Date | number } diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 068183d..90bf4c9 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -1,4 +1,4 @@ -import {Duration, elapsedTime, getRelativeTimeUnit, isDuration, roundToSingleUnit, Unit, unitNames} from './duration.js' +import {Duration, Unit, elapsedTime, isDuration, relativeTime, roundToSingleUnit, unitNames} from './duration.js' const HTMLElement = globalThis.HTMLElement || (null as unknown as typeof window['HTMLElement']) export type DeprecatedFormat = 'auto' | 'micro' | 'elapsed' @@ -172,15 +172,16 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return duration.abs().toLocaleString(locale, {style}) } - #getRelativeFormat(duration: Duration): string { + #getRelativeFormat(date: Date): string { const relativeFormat = new Intl.RelativeTimeFormat(this.#lang, { numeric: 'auto', style: this.formatStyle, }) + let [int, unit] = relativeTime(date, this.precision) 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) + if ((tense === 'future' && int < 0) || (tense === 'past' && int > 0)) { + ;[int, unit] = [0, 'second'] + } if (unit === 'second' && int < 10) { return relativeFormat.format(0, this.precision === 'millisecond' ? 'second' : this.precision) } @@ -453,7 +454,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor if (format === 'duration') { newText = this.#getDurationFormat(duration) } else if (format === 'relative') { - newText = this.#getRelativeFormat(duration) + newText = this.#getRelativeFormat(date) } else { newText = this.#getDateTimeFormat(date) } diff --git a/test/duration.ts b/test/duration.ts index 3b35600..3fcb146 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 { + Duration, + applyDuration, + elapsedTime, + getRelativeTimeUnit, + relativeTime, + roundToSingleUnit, +} from '../src/duration.ts' import {Temporal} from '@js-temporal/polyfill' suite('duration', function () { @@ -228,6 +235,259 @@ suite('duration', function () { } }) + suite('relativeTime', function () { + const relativeTests = [ + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + expected: [0, 'second'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:01', + expected: [1, 'second'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T11:59:59', + expected: [-1, 'second'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:58', + expected: [1, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T11:59:02', + expected: [-1, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:05:00', + expected: [5, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T11:55:00', + expected: [-5, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:58:00', + expected: [1, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T11:02:00', + expected: [-1, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:54:55', + expected: [1, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T11:05:05', + expected: [-1, 'hour'], + }, + { + now: '2024-10-15T00:00:00', + date: '2024-10-15T23:59:59', + expected: [23, 'hour'], + }, + { + now: '2024-10-15T23:59:59', + date: '2024-10-15T00:00:00', + expected: [-23, 'hour'], + }, + { + now: '2024-10-15T18:00:00', + date: '2024-10-16T00:00:00', + expected: [6, 'hour'], + }, + { + now: '2024-10-15T00:00:00', + date: '2024-10-14T18:00:00', + expected: [-6, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-16T00:00:00', + expected: [1, 'day'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-14T23:00:00', + expected: [-1, 'day'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-25T12:00:00', + expected: [1, 'week'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-05T12:00:00', + expected: [-1, 'week'], + }, + { + now: '2024-10-01T12:00:00', + date: '2024-10-21T12:00:00', + expected: [3, 'week'], + }, + { + now: '2024-10-21T12:00:00', + date: '2024-10-01T12:00:00', + expected: [-3, 'week'], + }, + { + now: '2024-10-05T12:00:00', + date: '2024-11-01T12:00:00', + expected: [1, 'month'], + }, + { + now: '2024-10-01T12:00:00', + date: '2024-09-04T12:00:00', + expected: [-1, 'month'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-12-15T12:00:00', + expected: [2, 'month'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-08-15T12:00:00', + expected: [-2, 'month'], + }, + { + now: '2024-10-15T12:00:00', + date: '2025-01-15T12:00:00', + expected: [3, 'month'], + }, + { + now: '2025-01-15T12:00:00', + date: '2024-10-15T12:00:00', + expected: [-3, 'month'], + }, + { + now: '2024-10-15T12:00:00Z', + date: '2025-09-15T12:00:00Z', + expected: [11, 'month'], + }, + { + now: '2024-10-15T12:00:00Z', + date: '2023-11-15T12:00:00Z', + expected: [-11, 'month'], + }, + { + now: '2024-10-15T12:00:00Z', + date: '2025-10-15T12:00:00Z', + expected: [1, 'year'], + }, + { + now: '2024-10-15T12:00:00Z', + date: '2023-10-15T12:00:00Z', + expected: [-1, 'year'], + }, + { + now: '2024-10-15T12:00:00Z', + date: '2029-01-15T12:00:00Z', + expected: [5, 'year'], + }, + { + now: '2024-01-15T12:00:00Z', + date: '2019-10-15T12:00:00Z', + expected: [-5, 'year'], + }, + + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'minute', + expected: [0, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'hour', + expected: [0, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'day', + expected: [0, 'day'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'week', + expected: [0, 'week'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'month', + expected: [0, 'month'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:00', + precision: 'year', + expected: [0, 'year'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:00:50', + precision: 'minute', + expected: [0, 'minute'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T12:50:00', + precision: 'hour', + expected: [0, 'hour'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-15T22:00:00', + precision: 'day', + expected: [0, 'day'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-20T12:00:00', + precision: 'week', + expected: [0, 'week'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-10-31T12:00:00', + precision: 'month', + expected: [0, 'month'], + }, + { + now: '2024-10-15T12:00:00', + date: '2024-12-15T12:00:00', + precision: 'year', + expected: [0, 'year'], + }, + ] + for (const { + now, + date, + precision = 'second', + expected: [val, unit], + } of relativeTests) { + test(`relativeTime(${date}, ${precision}, ${now}) === [${val}, ${unit}]`, () => { + assert.deepEqual(relativeTime(new Date(date), precision, new Date(now)), [val, unit]) + }) + } + }) + suite('roundToSingleUnit', function () { const roundTests = new Set([ ['PT20S', 'PT20S'], diff --git a/test/relative-time.js b/test/relative-time.js index 0d13096..c5a81c8 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -452,7 +452,7 @@ suite('relative-time', function () { time.setAttribute('tense', 'past') time.setAttribute('datetime', '2023-01-01T00:00:00Z') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '11 years ago') + assert.equal(time.shadowRoot.textContent, '10 years ago') }) test('rewrites from now past datetime to minutes ago', async () => { @@ -499,7 +499,7 @@ suite('relative-time', function () { time.setAttribute('tense', 'past') time.setAttribute('datetime', '2023-06-01T00:00:00Z') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '4 months ago') + assert.equal(time.shadowRoot.textContent, '3 months ago') }) test('rewrites from last few days of month to smaller last month', async () => { @@ -1180,7 +1180,7 @@ suite('relative-time', function () { datetime: '2022-11-13T15:46:00.000Z', format: 'relative', precision: 'month', - expected: 'this month', + expected: 'next month', }, { datetime: '2022-11-13T15:46:00.000Z',