-
Notifications
You must be signed in to change notification settings - Fork 19
Show the docs update time as relative, exact stamp on hover #5474
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
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6a2afe5
Show the docs update time as relative, exact stamp on hover
dimitrieh 70349dd
Merge remote-tracking branch 'origin/main' into docs-relative-updated…
dimitrieh b682978
Derive the relative label rather than assigning it on mount
dimitrieh c4af147
Run unit tests in CI and fix flaky handbook-changes test
ZJvandeWeg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <script setup lang="ts"> | ||
| // @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly | ||
| import { formatRelative, toIso } from '../lib/relative-time.mjs' | ||
|
|
||
| const props = defineProps<{ | ||
| value: string | ||
| }>() | ||
|
|
||
| const iso = computed(() => toIso(props.value)) | ||
|
|
||
| // Docs pages are prerendered, so a relative label baked in at build time would be stale | ||
| // by the time anyone reads it. Leave `now` unset on the server so the exact stamp is what | ||
| // gets rendered, which is also what a reader without JS keeps and what makes hydration | ||
| // match, then set it on mount to switch every instance over to relative. | ||
| const now = ref<Date | null>(null) | ||
|
|
||
| onMounted(() => { | ||
| now.value = new Date() | ||
| }) | ||
|
|
||
| // Derived rather than assigned on mount: every /docs page shares this one route | ||
| // component, so navigating between pages patches `value` in place instead of remounting. | ||
| // An assigned ref would keep showing the previous page's label. | ||
| const label = computed(() => { | ||
| if (!now.value) return props.value | ||
| return formatRelative(props.value, now.value) ?? props.value | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <time v-if="iso" :datetime="iso" :title="value" class="cursor-help">{{ label }}</time> | ||
| <template v-else>{{ value }}</template> | ||
| </template> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // Formats the docs `updated:` stamp. Kept free of Nuxt and Vue imports so it can be | ||
| // unit tested with `node --test`. | ||
|
|
||
| // Git writes commit dates as "2026-03-18 15:07:47 +0000". That is not a valid HTML | ||
| // datetime value, and engines are not required to parse it, so normalise it here | ||
| // rather than handing it to `new Date()` and hoping. | ||
| const GIT_DATE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\s*([+-])(\d{2}):?(\d{2}))?$/ | ||
|
|
||
| const MINUTE = 60 | ||
| const HOUR = MINUTE * 60 | ||
| const DAY = HOUR * 24 | ||
|
|
||
| /** | ||
| * Normalise a git commit date to an ISO 8601 string, or return null if it is not one. | ||
| */ | ||
| export function toIso (raw) { | ||
| if (typeof raw !== 'string') return null | ||
|
|
||
| const match = raw.trim().match(GIT_DATE) | ||
| if (!match) return null | ||
|
|
||
| const [, date, time, sign, offsetHours, offsetMinutes] = match | ||
| const offset = sign ? `${sign}${offsetHours}:${offsetMinutes}` : 'Z' | ||
|
|
||
| const iso = `${date}T${time}${offset}` | ||
| return Number.isNaN(Date.parse(iso)) ? null : iso | ||
| } | ||
|
|
||
| /** | ||
| * Turn a git commit date into "3 days ago". Returns null when the input is unparseable, | ||
| * so the caller can fall back to showing the raw stamp. | ||
| * | ||
| * The unit is the largest one that still yields a count of at least 1, because "8 months | ||
| * ago" tells a reader more about how stale a page is than "241 days ago" does. | ||
| */ | ||
| export function formatRelative (raw, now = new Date()) { | ||
| const iso = toIso(raw) | ||
| if (!iso) return null | ||
|
|
||
| const seconds = (Date.parse(iso) - now.getTime()) / 1000 | ||
| const absolute = Math.abs(seconds) | ||
|
|
||
| if (absolute < MINUTE) return 'just now' | ||
|
|
||
| const format = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) | ||
|
|
||
| if (absolute < HOUR) return format.format(Math.round(seconds / MINUTE), 'minute') | ||
| if (absolute < DAY) return format.format(Math.round(seconds / HOUR), 'hour') | ||
| if (absolute < DAY * 7) return format.format(Math.round(seconds / DAY), 'day') | ||
| if (absolute < DAY * 30) return format.format(Math.round(seconds / (DAY * 7)), 'week') | ||
| if (absolute < DAY * 365) return format.format(Math.round(seconds / (DAY * 30)), 'month') | ||
|
|
||
| return format.format(Math.round(seconds / (DAY * 365)), 'year') | ||
| } | ||
|
ZJvandeWeg marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { test } from 'node:test' | ||
| import assert from 'node:assert/strict' | ||
|
|
||
| import { formatRelative, toIso } from './relative-time.mjs' | ||
|
|
||
| const now = new Date('2026-08-03T12:00:00Z') | ||
|
|
||
| test('toIso normalises a git commit date', () => { | ||
| assert.equal(toIso('2026-03-18 15:07:47 +0000'), '2026-03-18T15:07:47+00:00') | ||
| assert.equal(toIso('2026-03-18 15:07:47 +0100'), '2026-03-18T15:07:47+01:00') | ||
| assert.equal(toIso('2026-03-18 15:07:47 -0500'), '2026-03-18T15:07:47-05:00') | ||
| }) | ||
|
|
||
| test('toIso accepts a stamp with no offset', () => { | ||
| assert.equal(toIso('2026-03-18 15:07:47'), '2026-03-18T15:07:47Z') | ||
| }) | ||
|
|
||
| test('toIso rejects anything it cannot read', () => { | ||
| assert.equal(toIso(''), null) | ||
| assert.equal(toIso('last Tuesday'), null) | ||
| assert.equal(toIso(undefined), null) | ||
| assert.equal(toIso('2026-13-45 99:99:99 +0000'), null) | ||
| }) | ||
|
|
||
| test('formatRelative picks the largest unit that still counts at least one', () => { | ||
| const cases = [ | ||
| ['2026-08-03 11:59:30 +0000', 'just now'], | ||
| ['2026-08-03 11:30:00 +0000', '30 minutes ago'], | ||
| ['2026-08-03 04:00:00 +0000', '8 hours ago'], | ||
| ['2026-08-01 12:00:00 +0000', '2 days ago'], | ||
| ['2026-07-20 12:00:00 +0000', '2 weeks ago'], | ||
| ['2026-03-18 12:00:00 +0000', '5 months ago'], | ||
| ['2024-08-03 12:00:00 +0000', '2 years ago'], | ||
| ] | ||
|
|
||
| for (const [raw, expected] of cases) { | ||
| assert.equal(formatRelative(raw, now), expected, raw) | ||
| } | ||
| }) | ||
|
|
||
| test('formatRelative words a count of one the way a person would', () => { | ||
| assert.equal(formatRelative('2026-08-02 12:00:00 +0000', now), 'yesterday') | ||
| assert.equal(formatRelative('2026-07-27 12:00:00 +0000', now), 'last week') | ||
| assert.equal(formatRelative('2026-07-04 12:00:00 +0000', now), 'last month') | ||
| assert.equal(formatRelative('2025-07-10 12:00:00 +0000', now), 'last year') | ||
| }) | ||
|
|
||
| test('formatRelative honours the offset rather than the wall clock', () => { | ||
| // Same instant, written in two zones: both are one hour ago. | ||
| assert.equal(formatRelative('2026-08-03 11:00:00 +0000', now), '1 hour ago') | ||
| assert.equal(formatRelative('2026-08-03 12:00:00 +0100', now), '1 hour ago') | ||
| }) | ||
|
|
||
| test('formatRelative returns null on an unreadable stamp so the caller can fall back', () => { | ||
| assert.equal(formatRelative('not a date', now), null) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.