Skip to content

Commit

Permalink
Merge pull request #204 from github/add-elapsed-time-format
Browse files Browse the repository at this point in the history
add elapsed time format
  • Loading branch information
keithamus authored Nov 16, 2022
2 parents 30138d7 + 9a0f42f commit 7b4c668
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 46 deletions.
76 changes: 50 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ So, a relative date phrase is used for up to a month and then the actual date is
| Property Name | Attribute Name | Possible Values | Default Value |
|:---------------|:-----------------|:-----------------------------------------------------------------------------------------|:-----------------------|
| `datetime` | `datetime` | `string` | - |
| `format` | `format` | `'auto'|'micro'|'elapsed'|string` | 'auto' |
| `date` | - | `Date \| null` | - |
| `tense` | `tense` | `'auto'\|'past'\|'future'` | `'auto'` |
| `precision` | `precision` | `'year'|'month'|'day'|'hour'|'minute'|'second'` | `'second'` |
| `threshold` | `threshold` | `string` | `'P30D'` |
| `prefix` | `prefix` | `string` | `'on'` |
| `second` | `second` | `'numeric'\|'2-digit'\|undefined` | `undefined` |
Expand Down Expand Up @@ -100,6 +102,39 @@ This is the datetime that the element is meant to represent. This must be a vali
</script>
```

##### format (`'auto'|'micro'|'elapsed'|string`, default: `'auto'`)

The default format is `auto`, but this can be changed to `micro` or `elapsed`.

The default `auto` format will display dates relative to the current time (unless they are past the `threshold` value - see below). The values are rounded to display a single unit, for example if the time between the given `datetime` and the current wall clock time exceeds a day, then the format will _only_ ouput in days, and will not display hours, minutes or seconds.

The `micro` format which will display relative dates (within the threshold) in a more compact format. Similar to `auto`, the `micro` format rounds values to the nearest largest value. Additionally, `micro` format will not round _lower_ than 1 minute, as such a `datetime` which is less than a minute from the current wall clock time will display `'1m'`.

The `elapsed` format will always non-rounded units of time, where any non-zero unit of time is displayed in the compact notation format. This format is also absolute, and so tense does not apply. This can be useful for displaying actively running timers.

| `format=auto` | `format=micro` | `format=elapsed` |
|:-------------:|:--------------:|:----------------:|
| in 2 years | 2y | 2y 10d 3h 20m 8s |
| 2 years ago | 2y | 2y 10d 3h 20m 8s |
| in 30 days | 30d | 30d 4h 20m 8s |
| 21 minutes ago| 21m | 21m 30s |
| 37 seconds ago| 1m | 37s |

Additionally, format accepts a [strftime](https://strftime.org/) compatible format. Providing a strftime format will override all other attributes on the element, and the time will be displayed formatted based on the strftime value. This can be useful, for example, to dynamically remove relative formatting based on a user action.

```html
<relative-time datetime="1970-04-01T16:30:00-08:00" threshold="P100Y" format="micro">
<!-- Will display "<N>y" -->
</relative-time>
```

```html
<relative-time datetime="1970-04-01T16:30:00-08:00" format="%Y-%m-%d">
<!-- Will display "1970-01-01" -->
</relative-time>
```


##### tense (`'auto'|'past'|'future'`, default: `auto`)

If `format` is anything other than `'auto'` or `'micro'` then this value will be ignored.
Expand All @@ -118,6 +153,21 @@ Tense can be used to fix relative-time to always display a date's relative tense
</relative-time>
```

#### precision (`'year'|'month'|'day'|'hour'|'minute'|'second'`, default: `'second'`)

If `format` is anything other than `'elapsed'` then this value will be ignored.

Precision can be used to limit the display of an `elapsed` formatted time. By default the `elapsed` time will display times down to the `second` level of precision. Changing this value will truncate the display to the respective unit, and smaller units will be elided. Some examples:

| `precision=` | Display |
|:-------------:|:-------------------:|
| seconds | 2y 6m 10d 3h 20m 8s |
| minutes | 2y 6m 10d 3h 20m |
| hours | 2y 6m 10d 3h |
| days | 2y 6m 10d |
| months | 2y 6m |
| years | 2y |

##### threshold (`string`, default: `P30D`)

If `tense` is anything other than `'auto'` or `format` is anything other than `'auto'` or `'micro'` then this value will be ignored.
Expand Down Expand Up @@ -154,32 +204,6 @@ When formatting an absolute date (see above `threshold` for more details) it can

For dates outside of the specified `threshold`, the formatting of the date can be configured using these attributes. The values for these attributes are passed to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat):

##### format (`'auto'|'micro'|string`, default: `'auto'`)

The default format is `auto`, but this can be changed to `micro` which will display relative dates (within the threshold) in a more compact format. Micro format will not display relative times shorter than 1 minute (`'1m'`). Some examples:

| `format=auto` | `format=micro` |
|:-------------:|:--------------:|
| in 2 years | 2y |
| 2 yars ago | 2y |
| in 30 days | 30d |
| 21 minutes ago| 21m |
| 37 seconds ago| 1m |

Additionally, format accepts a [strftime](https://strftime.org/) compatible format. Providing a strftime format will override all other attributes on the element, and the time will be displayed formatted based on the strftime value. This can be useful, for example, to dynamically remove relative formatting based on a user action.

```html
<relative-time datetime="1970-04-01T16:30:00-08:00" threshold="P100Y" format="micro">
<!-- Will display "<N>y" -->
</relative-time>
```

```html
<relative-time datetime="1970-04-01T16:30:00-08:00" format="%Y-%m-%d">
<!-- Will display "1970-01-01" -->
</relative-time>
```

##### lang

Lang is a [built-in global attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang). Relative Time will use this to provide an applicable language to the `Intl` APIs. If the individual element does not have a `lang` attribute then it will traverse upwards in the tree to find the closest element that does, or default the lang to `en`.
Expand Down
35 changes: 26 additions & 9 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,30 @@ <h2>Future Date</h2>
<relative-time datetime="2017-01-01T00:00:00.000Z" format="%Y-%m-%d">
<p>

<p>
Time until:
<time-until datetime="2024-12-31T00:00:00-05:00">
Oops! This browser doesn't support Web Components.
</time-until>
</p>

<p>
Time until (micro format):
<time-until format="micro" datetime="2024-12-31T00:00:00-05:00">
Oops! This browser doesn't support Web Components.
</time-until>
</p>


<h2>Dynamic Formats</h2>
<p>
Nearby, dynamic, relative time:
<relative-time datetime="2019-08-12T20:50:00.000Z" id="dynamic1">
Oops! This browser doesn't support Web Components.
</relative-time>
</p>


<p>
Nearby, dynamic, relative time:
<relative-time datetime="2019-08-12T20:50:00.000Z" id="dynamic2">
Expand All @@ -103,30 +120,30 @@ <h2>Future Date</h2>
</relative-time>
</p>



<p>
Time until:
<time-until datetime="2024-12-31T00:00:00-05:00">
How long you've been on this page
<relative-time id="dynamic4" format="elapsed">
Oops! This browser doesn't support Web Components.
</time-until>
</relative-time>
</p>

<p>
Time until (micro format):
<time-until format="micro" datetime="2024-12-31T00:00:00-05:00">
Countdown timer until 2038 bug:
<relative-time datetime="2038-01-19T03:14:08Z" format="elapsed">
Oops! This browser doesn't support Web Components.
</time-until>
</p>
<!-- <script type="module" src="../dist/index.js"></script> -->
<script type="module" src="https://unpkg.com/@github/time-elements@latest?module"></script>

<script type="module" src="../dist/index.js"></script>
<!-- <script type="module" src="https://unpkg.com/@github/time-elements@latest?module"></script> -->
<script>
document.body.addEventListener('relative-time-updated', event => {
console.log('event from', event.target, event)
});
document.getElementById('dynamic1').date = new Date()
document.getElementById('dynamic2').date = new Date(Date.now() - 30000)
document.getElementById('dynamic3').date = new Date(Date.now() + 5000)
document.getElementById('dynamic4').date = new Date()
</script>
</body>
</html>
21 changes: 20 additions & 1 deletion src/duration-format.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type Unit = 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year'
export const unitNames = ['second', 'minute', 'hour', 'day', 'month', 'year'] as const
export type Unit = typeof unitNames[number]

export function timeAgo(date: Date): [number, Unit] {
const ms = new Date().getTime() - date.getTime()
Expand Down Expand Up @@ -109,3 +110,21 @@ export function microTimeUntil(date: Date): [number, Unit] {
return [1, 'minute']
}
}

export function elapsedTime(date: Date): Array<[number, Unit]> {
const ms = Math.abs(date.getTime() - new Date().getTime())
const sec = Math.floor(ms / 1000)
const min = Math.floor(sec / 60)
const hr = Math.floor(min / 60)
const day = Math.floor(hr / 24)
const month = Math.floor(day / 30)
const year = Math.floor(month / 12)
const units: Array<[number, Unit]> = []
if (year) units.push([year, 'year'])
if (month - year * 12) units.push([month - year * 12, 'month'])
if (day - month * 30) units.push([day - month * 30, 'day'])
if (hr - day * 24) units.push([hr - day * 24, 'hour'])
if (min - hr * 60) units.push([min - hr * 60, 'minute'])
if (sec - min * 60) units.push([sec - min * 60, 'second'])
return units
}
42 changes: 33 additions & 9 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {microTimeAgo, microTimeUntil, timeUntil, timeAgo} from './duration-format.js'
import {unitNames, Unit, microTimeAgo, microTimeUntil, timeUntil, timeAgo, elapsedTime} from './duration-format.js'
import {DateTimeFormat as DateTimeFormatPonyFill} from './datetimeformat-ponyfill.js'
import {RelativeTimeFormat as RelativeTimeFormatPonyfill} from './relative-time-ponyfill.js'
import {isDuration, withinDuration} from './duration.js'
Expand All @@ -10,7 +10,7 @@ const DateTimeFormat = supportsIntlDatetime ? Intl.DateTimeFormat : DateTimeForm
const supportsIntlRelativeTime = 'Intl' in window && 'RelativeTimeFormat' in Intl
const RelativeTimeFormat = supportsIntlRelativeTime ? Intl.RelativeTimeFormat : RelativeTimeFormatPonyfill

export type Format = 'auto' | 'micro' | string
export type Format = 'auto' | 'micro' | 'elapsed' | string
export type Tense = 'auto' | 'past' | 'future'

export class RelativeTimeUpdatedEvent extends Event {
Expand All @@ -19,8 +19,17 @@ export class RelativeTimeUpdatedEvent extends Event {
}
}

function getUnitFactor(ms: number): number {
ms = Math.abs(Date.now() - ms)
function getUnitFactor(el: RelativeTimeElement): number {
if (!el.date) return Infinity
if (el.format === 'elapsed') {
const precision = el.precision
if (precision === 'second') {
return 1000
} else if (precision === 'minute') {
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
Expand All @@ -35,7 +44,7 @@ const dateObserver = new (class {
this.elements.add(element)
const date = element.date
if (date && date.getTime()) {
const ms = getUnitFactor(date.getTime())
const ms = getUnitFactor(element)
const time = Date.now() + ms
if (time < this.time) {
clearTimeout(this.timer)
Expand All @@ -57,8 +66,7 @@ const dateObserver = new (class {

let nearestDistance = Infinity
for (const timeEl of this.elements) {
const distance = timeEl.date ? getUnitFactor(timeEl.date.getTime()) : Infinity
nearestDistance = Math.min(nearestDistance, distance)
nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl))
timeEl.update()
}
this.time = Math.min(60 * 60 * 1000, nearestDistance)
Expand Down Expand Up @@ -89,6 +97,7 @@ export default class RelativeTimeElement extends HTMLElement implements Intl.Dat
'prefix',
'threhsold',
'tense',
'precision',
'format',
'datetime',
'lang',
Expand Down Expand Up @@ -119,8 +128,12 @@ export default class RelativeTimeElement extends HTMLElement implements Intl.Dat
const date = this.date
if (!date) return
const format = this.format
if (format !== 'auto' && format !== 'micro') {
if (format !== 'auto' && format !== 'micro' && format !== 'elapsed') {
return strftime(date, format)
} else if (format === 'elapsed') {
const precisionIndex = unitNames.indexOf(this.precision) || 0
const units = elapsedTime(date).filter(unit => unitNames.indexOf(unit[1]) >= precisionIndex)
return units.map(([int, unit]) => `${int}${unit[0]}`).join(' ') || `0${this.precision[0]}`
}
const tense = this.tense
const micro = format === 'micro'
Expand Down Expand Up @@ -271,9 +284,20 @@ export default class RelativeTimeElement extends HTMLElement implements Intl.Dat
this.setAttribute('tense', value)
}

get precision(): Unit {
const precision = this.getAttribute('precision') as unknown as Unit
if (unitNames.includes(precision)) return precision
return 'second'
}

set precision(value: Unit) {
this.setAttribute('precision', value)
}

get format(): Format {
const format = this.getAttribute('format')
if (format === 'micro') return 'micro'
if (format === 'elapsed') return 'elapsed'
if (format && format.includes('%')) return format
return 'auto'
}
Expand Down Expand Up @@ -343,7 +367,7 @@ export default class RelativeTimeElement extends HTMLElement implements Intl.Dat
const date = this.date
const format = this.format
const isRelative = (format === 'auto' || format === 'micro') && date && withinDuration(now, date, this.threshold)
if (isRelative) {
if (format === 'elapsed' || isRelative) {
dateObserver.observe(this)
} else {
dateObserver.unobserve(this)
Expand Down
22 changes: 21 additions & 1 deletion test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,25 @@ suite('relative-time', function () {
{datetime: '2021-05-18T14:46:00.000Z', tense: 'past', format: 'micro', expected: '1y'},
{datetime: '2021-05-17T14:46:00.000Z', tense: 'past', format: 'micro', expected: '2y'},

// Elapsed Times
{datetime: '2022-10-24T14:46:10.000Z', format: 'elapsed', expected: '10s'},
{datetime: '2022-10-24T14:45:50.000Z', format: 'elapsed', expected: '10s'},
{datetime: '2022-10-24T14:45:50.000Z', format: 'elapsed', precision: 'minute', expected: '0m'},
{datetime: '2022-10-24T14:47:40.000Z', format: 'elapsed', expected: '1m 40s'},
{datetime: '2022-10-24T14:44:20.000Z', format: 'elapsed', expected: '1m 40s'},
{datetime: '2022-10-24T14:44:20.000Z', format: 'elapsed', precision: 'minute', expected: '1m'},
{datetime: '2022-10-24T15:51:40.000Z', format: 'elapsed', expected: '1h 5m 40s'},
{datetime: '2022-10-24T15:51:40.000Z', format: 'elapsed', precision: 'minute', expected: '1h 5m'},
{datetime: '2022-10-24T15:52:00.000Z', format: 'elapsed', expected: '1h 6m'},
{datetime: '2022-10-24T17:46:00.000Z', format: 'elapsed', expected: '3h'},
{datetime: '2022-10-24T10:46:00.000Z', format: 'elapsed', expected: '4h'},
{datetime: '2022-10-25T18:46:00.000Z', format: 'elapsed', expected: '1d 4h'},
{datetime: '2022-10-23T10:46:00.000Z', format: 'elapsed', expected: '1d 4h'},
{datetime: '2022-10-23T10:46:00.000Z', format: 'elapsed', precision: 'day', expected: '1d'},
{datetime: '2021-10-30T14:46:00.000Z', format: 'elapsed', expected: '11m 29d'},
{datetime: '2021-10-30T14:46:00.000Z', format: 'elapsed', precision: 'month', expected: '11m'},
{datetime: '2021-10-29T14:46:00.000Z', format: 'elapsed', expected: '1y'},

// Dates in the past
{datetime: '2022-09-24T14:46:00.000Z', tense: 'future', format: 'auto', expected: 'now'},
{datetime: '2022-10-23T14:46:00.000Z', tense: 'future', format: 'auto', expected: 'now'},
Expand Down Expand Up @@ -756,13 +775,14 @@ suite('relative-time', function () {
}
])

for (const {datetime, expected, tense, format, reference = referenceDate} of tests) {
for (const {datetime, expected, tense, format, precision = '', reference = referenceDate} of tests) {
test(`<relative-time datetime="${datetime}" tense="${tense}" format="${format}"> => ${expected}`, function () {
freezeTime(new Date(reference))
const time = document.createElement('relative-time')
time.setAttribute('tense', tense)
time.setAttribute('datetime', datetime)
time.setAttribute('format', format)
time.setAttribute('precision', precision)
assert.equal(time.shadowRoot.textContent, expected)
})
}
Expand Down

0 comments on commit 7b4c668

Please sign in to comment.