Skip to content

Commit 3cdcb9d

Browse files
committed
Add new implementation of relative time.
1 parent a80c1bf commit 3cdcb9d

File tree

4 files changed

+345
-10
lines changed

4 files changed

+345
-10
lines changed

Diff for: src/duration.ts

+66
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,72 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n
119119
)
120120
}
121121

122+
const durationRoundingThresholds = [
123+
Infinity, // Year
124+
11, // Month
125+
28, // Day
126+
21, // Hour
127+
55, // Minute
128+
55, // Second
129+
900, // Millisecond
130+
]
131+
132+
export function relativeTime(
133+
date: Date,
134+
precision: Unit = 'second',
135+
nowTimestamp: Date | number = Date.now(),
136+
): [number, Intl.RelativeTimeFormatUnit] {
137+
let precisionIndex = unitNames.indexOf(precision)
138+
if (precisionIndex === -1) {
139+
precisionIndex = unitNames.length
140+
}
141+
const now = new Date(nowTimestamp)
142+
const sign = Math.sign(date.getTime() - now.getTime())
143+
const dateWithoutTime = new Date(date)
144+
dateWithoutTime.setHours(0)
145+
dateWithoutTime.setMinutes(0)
146+
dateWithoutTime.setSeconds(0)
147+
dateWithoutTime.setMilliseconds(0)
148+
const nowWithoutTime = new Date(now)
149+
nowWithoutTime.setHours(0)
150+
nowWithoutTime.setMinutes(0)
151+
nowWithoutTime.setSeconds(0)
152+
nowWithoutTime.setMilliseconds(0)
153+
if (
154+
precisionIndex >= 4 && // At least hour.
155+
(dateWithoutTime.getTime() === nowWithoutTime.getTime() ||
156+
Math.abs(date.getTime() - now.getTime()) < 1000 * 60 * 60 * 12)
157+
) {
158+
const difference = Math.round(((date.getTime() - now.getTime()) / 1000) * sign)
159+
let hours = Math.floor(difference / 3600)
160+
let minutes = Math.floor((difference % 3600) / 60)
161+
const seconds = Math.floor(difference % 60)
162+
if (hours === 0) {
163+
if (seconds >= durationRoundingThresholds[5]) minutes += 1
164+
if (minutes >= durationRoundingThresholds[4]) return [sign, 'hour']
165+
if (precision === 'hour') return [0, 'hour']
166+
if (minutes === 0 && precisionIndex >= 6) return [seconds * sign, 'second']
167+
return [minutes * sign, 'minute']
168+
} else {
169+
if (hours < 23 && minutes >= durationRoundingThresholds[4]) hours += 1
170+
return [hours * sign, 'hour']
171+
}
172+
}
173+
const days = Math.round(((dateWithoutTime.getTime() - nowWithoutTime.getTime()) / (1000 * 60 * 60 * 24)) * sign)
174+
const months = date.getFullYear() * 12 + date.getMonth() - (now.getFullYear() * 12 + now.getMonth())
175+
if (
176+
precisionIndex >= 2 && // At least week.
177+
(months === 0 || days <= 26)
178+
) {
179+
if (precision === 'week' || days >= 6) return [Math.floor((days + 1) / 7) * sign, 'week']
180+
return [days * sign, 'day']
181+
}
182+
if (precision !== 'year' && Math.abs(months) < 12) {
183+
return [months, 'month']
184+
}
185+
return [date.getFullYear() - now.getFullYear(), 'year']
186+
}
187+
122188
interface RoundingOpts {
123189
relativeTo: Date | number
124190
}

Diff for: src/relative-time-element.ts

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import {Duration, elapsedTime, getRelativeTimeUnit, isDuration, roundToSingleUnit, Unit, unitNames} from './duration.js'
1+
import {
2+
Duration,
3+
Unit,
4+
elapsedTime,
5+
isDuration,
6+
relativeTime,
7+
roundToSingleUnit,
8+
unitNames,
9+
} from './duration.js'
210
const HTMLElement = globalThis.HTMLElement || (null as unknown as typeof window['HTMLElement'])
311

412
export type DeprecatedFormat = 'auto' | 'micro' | 'elapsed'
@@ -172,15 +180,16 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
172180
return duration.abs().toLocaleString(locale, {style})
173181
}
174182

175-
#getRelativeFormat(duration: Duration): string {
183+
#getRelativeFormat(date: Date): string {
176184
const relativeFormat = new Intl.RelativeTimeFormat(this.#lang, {
177185
numeric: 'auto',
178186
style: this.formatStyle,
179187
})
188+
let [int, unit] = relativeTime(date, this.precision)
180189
const tense = this.tense
181-
if (tense === 'future' && duration.sign !== 1) duration = emptyDuration
182-
if (tense === 'past' && duration.sign !== -1) duration = emptyDuration
183-
const [int, unit] = getRelativeTimeUnit(duration)
190+
if ((tense === 'future' && int < 0) || (tense === 'past' && int > 0)) {
191+
;[int, unit] = [0, 'second']
192+
}
184193
if (unit === 'second' && int < 10) {
185194
return relativeFormat.format(0, this.precision === 'millisecond' ? 'second' : this.precision)
186195
}
@@ -453,7 +462,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
453462
if (format === 'duration') {
454463
newText = this.#getDurationFormat(duration)
455464
} else if (format === 'relative') {
456-
newText = this.#getRelativeFormat(duration)
465+
newText = this.#getRelativeFormat(date)
457466
} else {
458467
newText = this.#getDateTimeFormat(date)
459468
}

Diff for: test/duration.ts

+261-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import {assert} from '@open-wc/testing'
2-
import {applyDuration, Duration, elapsedTime, getRelativeTimeUnit, roundToSingleUnit} from '../src/duration.ts'
2+
import {
3+
Duration,
4+
applyDuration,
5+
elapsedTime,
6+
getRelativeTimeUnit,
7+
relativeTime,
8+
roundToSingleUnit,
9+
} from '../src/duration.ts'
310
import {Temporal} from '@js-temporal/polyfill'
411

512
suite('duration', function () {
@@ -228,6 +235,259 @@ suite('duration', function () {
228235
}
229236
})
230237

238+
suite('relativeTime', function () {
239+
const relativeTests = [
240+
{
241+
now: '2024-10-15T12:00:00',
242+
date: '2024-10-15T12:00:00',
243+
expected: [0, 'second'],
244+
},
245+
{
246+
now: '2024-10-15T12:00:00',
247+
date: '2024-10-15T12:00:01',
248+
expected: [1, 'second'],
249+
},
250+
{
251+
now: '2024-10-15T12:00:00',
252+
date: '2024-10-15T11:59:59',
253+
expected: [-1, 'second'],
254+
},
255+
{
256+
now: '2024-10-15T12:00:00',
257+
date: '2024-10-15T12:00:58',
258+
expected: [1, 'minute'],
259+
},
260+
{
261+
now: '2024-10-15T12:00:00',
262+
date: '2024-10-15T11:59:02',
263+
expected: [-1, 'minute'],
264+
},
265+
{
266+
now: '2024-10-15T12:00:00',
267+
date: '2024-10-15T12:05:00',
268+
expected: [5, 'minute'],
269+
},
270+
{
271+
now: '2024-10-15T12:00:00',
272+
date: '2024-10-15T11:55:00',
273+
expected: [-5, 'minute'],
274+
},
275+
{
276+
now: '2024-10-15T12:00:00',
277+
date: '2024-10-15T12:58:00',
278+
expected: [1, 'hour'],
279+
},
280+
{
281+
now: '2024-10-15T12:00:00',
282+
date: '2024-10-15T11:02:00',
283+
expected: [-1, 'hour'],
284+
},
285+
{
286+
now: '2024-10-15T12:00:00',
287+
date: '2024-10-15T12:54:55',
288+
expected: [1, 'hour'],
289+
},
290+
{
291+
now: '2024-10-15T12:00:00',
292+
date: '2024-10-15T11:05:05',
293+
expected: [-1, 'hour'],
294+
},
295+
{
296+
now: '2024-10-15T00:00:00',
297+
date: '2024-10-15T23:59:59',
298+
expected: [23, 'hour'],
299+
},
300+
{
301+
now: '2024-10-15T23:59:59',
302+
date: '2024-10-15T00:00:00',
303+
expected: [-23, 'hour'],
304+
},
305+
{
306+
now: '2024-10-15T18:00:00',
307+
date: '2024-10-16T00:00:00',
308+
expected: [6, 'hour'],
309+
},
310+
{
311+
now: '2024-10-15T00:00:00',
312+
date: '2024-10-14T18:00:00',
313+
expected: [-6, 'hour'],
314+
},
315+
{
316+
now: '2024-10-15T12:00:00',
317+
date: '2024-10-16T00:00:00',
318+
expected: [1, 'day'],
319+
},
320+
{
321+
now: '2024-10-15T12:00:00',
322+
date: '2024-10-14T23:00:00',
323+
expected: [-1, 'day'],
324+
},
325+
{
326+
now: '2024-10-15T12:00:00',
327+
date: '2024-10-25T12:00:00',
328+
expected: [1, 'week'],
329+
},
330+
{
331+
now: '2024-10-15T12:00:00',
332+
date: '2024-10-05T12:00:00',
333+
expected: [-1, 'week'],
334+
},
335+
{
336+
now: '2024-10-01T12:00:00',
337+
date: '2024-10-21T12:00:00',
338+
expected: [3, 'week'],
339+
},
340+
{
341+
now: '2024-10-21T12:00:00',
342+
date: '2024-10-01T12:00:00',
343+
expected: [-3, 'week'],
344+
},
345+
{
346+
now: '2024-10-05T12:00:00',
347+
date: '2024-11-01T12:00:00',
348+
expected: [1, 'month'],
349+
},
350+
{
351+
now: '2024-10-01T12:00:00',
352+
date: '2024-09-04T12:00:00',
353+
expected: [-1, 'month'],
354+
},
355+
{
356+
now: '2024-10-15T12:00:00',
357+
date: '2024-12-15T12:00:00',
358+
expected: [2, 'month'],
359+
},
360+
{
361+
now: '2024-10-15T12:00:00',
362+
date: '2024-08-15T12:00:00',
363+
expected: [-2, 'month'],
364+
},
365+
{
366+
now: '2024-10-15T12:00:00',
367+
date: '2025-01-15T12:00:00',
368+
expected: [3, 'month'],
369+
},
370+
{
371+
now: '2025-01-15T12:00:00',
372+
date: '2024-10-15T12:00:00',
373+
expected: [-3, 'month'],
374+
},
375+
{
376+
now: '2024-10-15T12:00:00Z',
377+
date: '2025-09-15T12:00:00Z',
378+
expected: [11, 'month'],
379+
},
380+
{
381+
now: '2024-10-15T12:00:00Z',
382+
date: '2023-11-15T12:00:00Z',
383+
expected: [-11, 'month'],
384+
},
385+
{
386+
now: '2024-10-15T12:00:00Z',
387+
date: '2025-10-15T12:00:00Z',
388+
expected: [1, 'year'],
389+
},
390+
{
391+
now: '2024-10-15T12:00:00Z',
392+
date: '2023-10-15T12:00:00Z',
393+
expected: [-1, 'year'],
394+
},
395+
{
396+
now: '2024-10-15T12:00:00Z',
397+
date: '2029-01-15T12:00:00Z',
398+
expected: [5, 'year'],
399+
},
400+
{
401+
now: '2024-01-15T12:00:00Z',
402+
date: '2019-10-15T12:00:00Z',
403+
expected: [-5, 'year'],
404+
},
405+
406+
{
407+
now: '2024-10-15T12:00:00',
408+
date: '2024-10-15T12:00:00',
409+
precision: 'minute',
410+
expected: [0, 'minute'],
411+
},
412+
{
413+
now: '2024-10-15T12:00:00',
414+
date: '2024-10-15T12:00:00',
415+
precision: 'hour',
416+
expected: [0, 'hour'],
417+
},
418+
{
419+
now: '2024-10-15T12:00:00',
420+
date: '2024-10-15T12:00:00',
421+
precision: 'day',
422+
expected: [0, 'day'],
423+
},
424+
{
425+
now: '2024-10-15T12:00:00',
426+
date: '2024-10-15T12:00:00',
427+
precision: 'week',
428+
expected: [0, 'week'],
429+
},
430+
{
431+
now: '2024-10-15T12:00:00',
432+
date: '2024-10-15T12:00:00',
433+
precision: 'month',
434+
expected: [0, 'month'],
435+
},
436+
{
437+
now: '2024-10-15T12:00:00',
438+
date: '2024-10-15T12:00:00',
439+
precision: 'year',
440+
expected: [0, 'year'],
441+
},
442+
{
443+
now: '2024-10-15T12:00:00',
444+
date: '2024-10-15T12:00:50',
445+
precision: 'minute',
446+
expected: [0, 'minute'],
447+
},
448+
{
449+
now: '2024-10-15T12:00:00',
450+
date: '2024-10-15T12:50:00',
451+
precision: 'hour',
452+
expected: [0, 'hour'],
453+
},
454+
{
455+
now: '2024-10-15T12:00:00',
456+
date: '2024-10-15T22:00:00',
457+
precision: 'day',
458+
expected: [0, 'day'],
459+
},
460+
{
461+
now: '2024-10-15T12:00:00',
462+
date: '2024-10-20T12:00:00',
463+
precision: 'week',
464+
expected: [0, 'week'],
465+
},
466+
{
467+
now: '2024-10-15T12:00:00',
468+
date: '2024-10-31T12:00:00',
469+
precision: 'month',
470+
expected: [0, 'month'],
471+
},
472+
{
473+
now: '2024-10-15T12:00:00',
474+
date: '2024-12-15T12:00:00',
475+
precision: 'year',
476+
expected: [0, 'year'],
477+
},
478+
]
479+
for (const {
480+
now,
481+
date,
482+
precision = 'second',
483+
expected: [val, unit],
484+
} of relativeTests) {
485+
test(`relativeTime(${date}, ${precision}, ${now}) === [${val}, ${unit}]`, () => {
486+
assert.deepEqual(relativeTime(new Date(date), precision, new Date(now)), [val, unit])
487+
})
488+
}
489+
})
490+
231491
suite('roundToSingleUnit', function () {
232492
const roundTests = new Set([
233493
['PT20S', 'PT20S'],

0 commit comments

Comments
 (0)