-
Notifications
You must be signed in to change notification settings - Fork 187
Support explicit micro thresholds #354
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
feaebf5
a790e2e
9bfce6e
00fe320
8055d27
412da6b
26ca974
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,13 @@ | ||
| import {Duration, elapsedTime, getRelativeTimeUnit, isDuration, roundToSingleUnit, Unit, unitNames} from './duration.js' | ||
| import { | ||
| Duration, | ||
| applyDuration, | ||
| elapsedTime, | ||
| getRelativeTimeUnit, | ||
| isDuration, | ||
| roundToSingleUnit, | ||
| Unit, | ||
| unitNames, | ||
| } from './duration.js' | ||
| const HTMLElement = globalThis.HTMLElement || (null as unknown as typeof window['HTMLElement']) | ||
|
|
||
| export type DeprecatedFormat = 'auto' | 'micro' | 'elapsed' | ||
|
|
@@ -17,7 +26,9 @@ export class RelativeTimeUpdatedEvent extends Event { | |
| } | ||
|
|
||
| function getUnitFactor(el: RelativeTimeElement): number { | ||
| if (!el.date) return Infinity | ||
| const date = el.date | ||
| if (!date) return Infinity | ||
| const now = Date.now() | ||
| if (el.format === 'duration' || el.format === 'elapsed') { | ||
| const precision = el.precision | ||
| if (precision === 'second') { | ||
|
|
@@ -26,10 +37,40 @@ function getUnitFactor(el: RelativeTimeElement): number { | |
| return 60 * 1000 | ||
| } | ||
| } | ||
| const ms = Math.abs(Date.now() - el.date.getTime()) | ||
| if (ms < 60 * 1000) return 1000 | ||
| if (ms < 60 * 60 * 1000) return 60 * 1000 | ||
| return 60 * 60 * 1000 | ||
| const ms = Math.abs(now - date.getTime()) | ||
| let factor = 60 * 60 * 1000 | ||
| if (ms < 60 * 1000) { | ||
| factor = 1000 | ||
| } else if (ms < 60 * 60 * 1000) { | ||
| factor = 60 * 1000 | ||
| } | ||
| const threshold = getExplicitThreshold(el) | ||
| if (el.format === 'micro' && threshold) { | ||
| const thresholdDuration = Duration.from(threshold) | ||
| const signedThresholdDuration = date.getTime() > now ? negateDuration(thresholdDuration) : thresholdDuration | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the important live-update bit for threshold fallback: the normal observer cadence can be too late, so explicit micro thresholds schedule at |
||
| const thresholdTime = applyDuration(date, signedThresholdDuration).getTime() | ||
| const msUntilThreshold = thresholdTime - now | ||
| if (msUntilThreshold > 0) factor = Math.min(factor, msUntilThreshold) | ||
| } | ||
| return factor | ||
| } | ||
|
|
||
| function negateDuration(duration: Duration): Duration { | ||
| return new Duration( | ||
| -duration.years, | ||
| -duration.months, | ||
| -duration.weeks, | ||
| -duration.days, | ||
| -duration.hours, | ||
| -duration.minutes, | ||
| -duration.seconds, | ||
| -duration.milliseconds, | ||
| ) | ||
| } | ||
|
|
||
| function getExplicitThreshold(el: RelativeTimeElement): string | null { | ||
| const threshold = el.getAttribute('threshold') | ||
| return threshold && isDuration(threshold) ? threshold : null | ||
| } | ||
|
|
||
| // Determine whether the user has a 12 (vs. 24) hour cycle preference via the | ||
|
|
@@ -45,15 +86,16 @@ function isBrowser12hCycle(): boolean { | |
| const dateObserver = new (class { | ||
| elements: Set<RelativeTimeElement> = new Set() | ||
| time = Infinity | ||
| updating = false | ||
|
|
||
| observe(element: RelativeTimeElement) { | ||
| if (this.elements.has(element)) return | ||
| this.elements.add(element) | ||
| if (this.updating) return | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This intentionally allows already-observed elements to be rescheduled when |
||
| const date = element.date | ||
| if (date && date.getTime()) { | ||
| const ms = getUnitFactor(element) | ||
| const time = Date.now() + ms | ||
| if (time < this.time) { | ||
| if (time < this.time || this.time <= Date.now()) { | ||
| clearTimeout(this.timer) | ||
| this.timer = setTimeout(() => this.update(), ms) | ||
| this.time = time | ||
|
|
@@ -72,9 +114,14 @@ const dateObserver = new (class { | |
| if (!this.elements.size) return | ||
|
|
||
| let nearestDistance = Infinity | ||
| for (const timeEl of this.elements) { | ||
| nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) | ||
| timeEl.update() | ||
| this.updating = true | ||
| try { | ||
| for (const timeEl of this.elements) { | ||
| nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) | ||
| timeEl.update() | ||
| } | ||
| } finally { | ||
| this.updating = false | ||
| } | ||
| this.time = Math.min(60 * 60 * 1000, nearestDistance) | ||
| this.timer = setTimeout(() => this.update(), this.time) | ||
|
|
@@ -163,15 +210,22 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor | |
| }).format(date) | ||
| } | ||
|
|
||
| #resolveFormat(duration: Duration): ResolvedFormat { | ||
| #getExplicitThreshold(): string | null { | ||
| return getExplicitThreshold(this) | ||
| } | ||
|
|
||
| #resolveFormat(duration: Duration, thresholdDuration = duration): ResolvedFormat { | ||
| const format: string = this.format | ||
| if (format === 'datetime') return 'datetime' | ||
| if (format === 'duration') return 'duration' | ||
|
|
||
| // elapsed is an alias for 'duration' | ||
| if (format === 'elapsed') return 'duration' | ||
| // 'micro' is an alias for 'duration' | ||
| if (format === 'micro') return 'duration' | ||
| if (format === 'micro') { | ||
| const threshold = this.#getExplicitThreshold() | ||
| if (threshold && Duration.compare(thresholdDuration, threshold) === -1) return 'datetime' | ||
| return 'duration' | ||
| } | ||
|
|
||
| // 'auto' is an alias for 'relative' | ||
| if ((format === 'auto' || format === 'relative') && typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) { | ||
|
|
@@ -305,8 +359,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor | |
| } | ||
|
|
||
| #shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean { | ||
| // Never override duration format with absolute format. | ||
| if (format === 'duration') return false | ||
| // Never override duration or elapsed format with absolute format. | ||
| if (format === 'duration' && this.format !== 'micro') return false | ||
|
|
||
|
Comment on lines
359
to
364
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 26ca974 by making observation conditional on rendered output: user-preferred absolute micro output no longer keeps duration-style polling except for Today/current-year absolute-time updates. |
||
| return ( | ||
| this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') === 'true' || | ||
|
|
@@ -561,7 +615,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor | |
| } | ||
|
|
||
| const duration = elapsedTime(date, this.precision, now) | ||
| const format = this.#resolveFormat(duration) | ||
| const format = this.#resolveFormat(duration, elapsedTime(date, 'millisecond', now)) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a millisecond-precision duration here keeps threshold resolution independent of display precision. Micro still renders with its normal minute floor, but threshold cutoffs are based on the actual elapsed time. |
||
| let newText = oldText | ||
|
|
||
| // Experimental: Enable absolute time if users prefers it, but never for `duration` format | ||
|
|
@@ -590,8 +644,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor | |
| } | ||
|
|
||
| const shouldObserve = | ||
| format === 'relative' || | ||
| format === 'duration' || | ||
| (!displayUserPreferredAbsoluteTime && (format === 'relative' || format === 'duration')) || | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This keeps user-preferred absolute output from polling like duration output. Micro only remains observed for explicit threshold transitions or the existing Today/current-year absolute-time cases. |
||
| (this.format === 'micro' && Boolean(this.#getExplicitThreshold()) && date.getTime() > now) || | ||
| (displayUserPreferredAbsoluteTime && (this.#isToday(date) || this.#isCurrentYear(date))) | ||
| if (shouldObserve) { | ||
| dateObserver.observe(this) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,44 @@ suite('relative-time', function () { | |
| } | ||
| }) | ||
|
|
||
| test('reschedules future micro datetime updates at the explicit threshold boundary', async () => { | ||
| const originalSetTimeout = window.setTimeout | ||
| const delays = [] | ||
| const time = document.createElement('relative-time') | ||
| globalThis.setTimeout = window.setTimeout = function (_, ms) { | ||
| delays.push(ms) | ||
| return 1 | ||
| } | ||
| try { | ||
| time.setAttribute('format', 'micro') | ||
| time.setAttribute('datetime', new Date(Date.now() + 65 * 1000).toISOString()) | ||
| await Promise.resolve() | ||
| assert.equal(time.shadowRoot.textContent, '1m') | ||
| assert.isAtLeast(delays[0], 59000) | ||
|
|
||
| delays.length = 0 | ||
| time.setAttribute('threshold', 'PT1M') | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test covers the dynamic attribute case: a micro element can already be observed, then gain an explicit threshold that requires an earlier reschedule. |
||
| await Promise.resolve() | ||
| assert.match(time.shadowRoot.textContent, /on [A-Z][a-z]{2} \d{1,2}/) | ||
| assert.isAbove(delays[0], 0) | ||
| assert.isBelow(delays[0], 6000) | ||
|
|
||
| delays.length = 0 | ||
| const pastTime = document.createElement('relative-time') | ||
| pastTime.setAttribute('format', 'micro') | ||
| pastTime.setAttribute('threshold', 'PT1H') | ||
| pastTime.setAttribute('datetime', new Date(Date.now() - 59 * 60 * 1000 - 58 * 1000).toISOString()) | ||
| await Promise.resolve() | ||
| assert.equal(pastTime.shadowRoot.textContent, '1h') | ||
| assert.isAbove(delays[0], 0) | ||
| assert.isBelow(delays[0], 6000) | ||
| pastTime.disconnectedCallback() | ||
| } finally { | ||
| globalThis.setTimeout = window.setTimeout = originalSetTimeout | ||
| time.disconnectedCallback() | ||
| } | ||
| }) | ||
|
|
||
| test('does not call update() frequently with attributeChangedCallback', async () => { | ||
| let counter = 0 | ||
| const el = document.createElement('relative-time') | ||
|
|
@@ -335,6 +373,38 @@ suite('relative-time', function () { | |
| assert.match(time.shadowRoot.textContent, /on [A-Z][a-z]{2} \d{1,2}/) | ||
| }) | ||
|
|
||
| test('micro switches to dates after explicit P30D threshold', async () => { | ||
| freezeTime(new Date('2023-01-01T00:00:00Z')) | ||
| const time = document.createElement('relative-time') | ||
| time.setAttribute('format', 'micro') | ||
| time.setAttribute('lang', 'en-US') | ||
| time.setAttribute('threshold', 'P30D') | ||
| time.setAttribute('datetime', '2022-11-15T00:00:00Z') | ||
| await Promise.resolve() | ||
| assert.equal(time.shadowRoot.textContent, 'on Nov 15, 2022') | ||
| }) | ||
|
|
||
| test('micro uses duration within explicit P30D threshold', async () => { | ||
| freezeTime(new Date('2023-01-15T00:00:00Z')) | ||
| const time = document.createElement('relative-time') | ||
| time.setAttribute('format', 'micro') | ||
| time.setAttribute('lang', 'en-US') | ||
| time.setAttribute('threshold', 'P30D') | ||
| time.setAttribute('datetime', '2023-01-01T00:00:00Z') | ||
| await Promise.resolve() | ||
| assert.equal(time.shadowRoot.textContent, '2w') | ||
| }) | ||
|
|
||
| test('micro ignores default P30D threshold unless threshold attribute is set', async () => { | ||
| freezeTime(new Date('2023-01-01T00:00:00Z')) | ||
| const time = document.createElement('relative-time') | ||
| time.setAttribute('format', 'micro') | ||
| time.setAttribute('lang', 'en-US') | ||
| time.setAttribute('datetime', '2022-11-15T00:00:00Z') | ||
| await Promise.resolve() | ||
| assert.equal(time.shadowRoot.textContent, '2mo') | ||
| }) | ||
|
|
||
| test('uses `prefix` attribute to customise prefix', async () => { | ||
| freezeTime(new Date('2023-01-01T00:00:00Z')) | ||
| const time = document.createElement('relative-time') | ||
|
|
@@ -2032,17 +2102,43 @@ suite('relative-time', function () { | |
| assert.equal(el.shadowRoot.textContent, '1h') | ||
| }) | ||
|
|
||
| test('does not activate for format="micro"', async () => { | ||
| test('activates for format="micro"', async () => { | ||
| freezeTime(new Date('2023-01-15T17:00:00.000Z')) | ||
| document.documentElement.setAttribute('data-prefers-absolute-time', 'true') | ||
|
|
||
| const el = document.createElement('relative-time') | ||
| el.setAttribute('lang', 'en-US') | ||
| el.setAttribute('time-zone', 'GMT') | ||
| el.setAttribute('datetime', '2023-01-15T16:00:00.000Z') | ||
| el.setAttribute('format', 'micro') | ||
| await Promise.resolve() | ||
|
|
||
| assert.equal(el.shadowRoot.textContent, '1h') | ||
| assert.equal(el.shadowRoot.textContent, 'Today 4:00 PM UTC') | ||
| }) | ||
|
|
||
| test('does not observe old format="micro" absolute-time preference output', async () => { | ||
| freezeTime(new Date('2023-01-15T17:00:00.000Z')) | ||
| document.documentElement.setAttribute('data-prefers-absolute-time', 'true') | ||
| const originalSetTimeout = window.setTimeout | ||
| const delays = [] | ||
| globalThis.setTimeout = window.setTimeout = function (_, ms) { | ||
| delays.push(ms) | ||
| return 1 | ||
| } | ||
| try { | ||
| const el = document.createElement('relative-time') | ||
| el.setAttribute('lang', 'en-US') | ||
| el.setAttribute('time-zone', 'GMT') | ||
| el.setAttribute('datetime', '2022-01-15T17:00:00.000Z') | ||
| el.setAttribute('format', 'micro') | ||
| await Promise.resolve() | ||
|
|
||
| assert.equal(el.shadowRoot.textContent, 'Jan 15, 2022, 5:00 PM UTC') | ||
| assert.empty(delays) | ||
| el.disconnectedCallback() | ||
| } finally { | ||
| globalThis.setTimeout = window.setTimeout = originalSetTimeout | ||
| } | ||
| }) | ||
|
|
||
| test('activates for format="relative" (default)', async () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 26ca974 by correcting the threshold docs: threshold applies to relative/auto when tense is auto, and micro only honors it when explicitly set.