From 6a2afe5a2445ff3b5c81fa91ab0a56ba6020e22b Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 3 Aug 2026 12:55:32 +0200 Subject: [PATCH 1/3] Show the docs update time as relative, exact stamp on hover "Updated: 2026-03-18 15:07:47 +0000" asks the reader to work out how stale a page is. "Updated: 5 months ago" answers it. The exact stamp stays available in the title attribute and in datetime, so nothing is lost. Docs pages are prerendered, so the relative label is set on mount rather than at build time, where it would freeze until the next build. The server still renders the exact stamp, which is what a reader without JS keeps and what makes hydration quiet. --- nuxt/components/RelativeTime.vue | 25 ++++++++++++++ nuxt/lib/relative-time.mjs | 54 ++++++++++++++++++++++++++++++ nuxt/lib/relative-time.test.mjs | 56 ++++++++++++++++++++++++++++++++ nuxt/pages/docs/[...slug].vue | 2 +- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 nuxt/components/RelativeTime.vue create mode 100644 nuxt/lib/relative-time.mjs create mode 100644 nuxt/lib/relative-time.test.mjs diff --git a/nuxt/components/RelativeTime.vue b/nuxt/components/RelativeTime.vue new file mode 100644 index 0000000000..82ed6b908f --- /dev/null +++ b/nuxt/components/RelativeTime.vue @@ -0,0 +1,25 @@ + + + diff --git a/nuxt/lib/relative-time.mjs b/nuxt/lib/relative-time.mjs new file mode 100644 index 0000000000..bb68341901 --- /dev/null +++ b/nuxt/lib/relative-time.mjs @@ -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') +} diff --git a/nuxt/lib/relative-time.test.mjs b/nuxt/lib/relative-time.test.mjs new file mode 100644 index 0000000000..299ac1a19c --- /dev/null +++ b/nuxt/lib/relative-time.test.mjs @@ -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) +}) diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 39203b3d16..5ea509a1e4 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -88,7 +88,7 @@ const breadcrumbs = computed(() => {
- Updated: {{ page.updated }} + Updated:
From b6829782b3fefd122a25fcea75b3fade8006c99f Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 3 Aug 2026 13:02:39 +0200 Subject: [PATCH 2/3] Derive the relative label rather than assigning it on mount app.vue renders with no key and the docs page sets no page key, so every /docs page shares one component instance. Navigating between them patches the value prop instead of remounting, and onMounted does not fire again, so an assigned ref kept showing the previous page's label. A computed tracks the prop. --- nuxt/components/RelativeTime.vue | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nuxt/components/RelativeTime.vue b/nuxt/components/RelativeTime.vue index 82ed6b908f..ff27d76fe4 100644 --- a/nuxt/components/RelativeTime.vue +++ b/nuxt/components/RelativeTime.vue @@ -9,13 +9,21 @@ const props = defineProps<{ 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. Render the exact stamp on the server, which is also what -// a reader without JS keeps, then relabel on mount. Starting the ref at the same value -// the server sent keeps hydration quiet. -const label = ref(props.value) +// 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(null) onMounted(() => { - label.value = formatRelative(props.value) ?? props.value + 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 }) From c4af147dff7055f24297285e21173fb52d92af16 Mon Sep 17 00:00:00 2001 From: ZJ van de Weg Date: Mon, 3 Aug 2026 15:17:33 -0700 Subject: [PATCH 3/3] Run unit tests in CI and fix flaky handbook-changes test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test.yml built and link-checked but never ran `npm test`. The one existing failure hardcoded a specific PR as "most recent", which was guaranteed to rot as soon as the handbook changed again — it now derives the latest commit from git at test time instead. --- .github/workflows/test.yml | 3 ++ nuxt/server/lib/handbookChanges.test.mjs | 43 ++++++++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8d9ed6e67..62c333b126 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,9 @@ jobs: - name: Install Dependencies run: npm install working-directory: 'website' + - name: Run unit tests + run: npm test + working-directory: 'website' - name: Build the forge run: npm run build:nuxt:skip-images working-directory: 'website' diff --git a/nuxt/server/lib/handbookChanges.test.mjs b/nuxt/server/lib/handbookChanges.test.mjs index 42883582a5..57b8599663 100644 --- a/nuxt/server/lib/handbookChanges.test.mjs +++ b/nuxt/server/lib/handbookChanges.test.mjs @@ -6,6 +6,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' import { getHandbookChanges, toHandbookRel, relToUrl, mondayOf, prsFromCommits } from './handbookChanges.mjs' // --- pure helpers ----------------------------------------------------------- @@ -145,16 +146,38 @@ test('getHandbookChanges produces well-formed, newest-first weekly data', () => } }) -test('known recent handbook changes appear in the most recent week', () => { +test('the actual latest handbook commit appears in the most recent week', () => { + // Ask git directly (independent of handbookChanges.mjs) for the single most + // recent commit that touched a tracked handbook file, and confirm it shows + // up in weeks[0]. Asserting against a hardcoded PR number here would rot the + // moment anyone else edits the handbook, since "most recent" keeps moving. + const latest = execFileSync('git', [ + 'log', '--no-merges', '-M', '-1', '--name-status', '--date=short', + '--pretty=format:%H%x1f%ad%x1f%s', + '--', 'nuxt/content/handbook', 'src/handbook' + ], { cwd: process.cwd() }).toString() + + const [header, ...fileLines] = latest.split('\n').filter(Boolean) + const [sha, date, subject] = header.split('\x1f') + const prMatch = subject.match(/\(#(\d+)\)\s*$/) + const files = fileLines.map(line => line.split('\t')).map(parts => + parts[0].startsWith('R') || parts[0].startsWith('C') ? parts[2] : parts[1] + ) + const urls = files.map(toHandbookRel).filter(Boolean).map(relToUrl) + assert.ok(urls.length > 0, 'the latest handbook commit touches at least one tracked page') + const weeks = getHandbookChanges(process.cwd()) const recent = weeks[0] - // #5158 (Release Process) landed in the most recent week of activity. - const releaseProcess = recent.pages.find(p => p.url === '/handbook/engineering/releases/process/') - assert.ok(releaseProcess, 'Release Process page present in most recent week') - assert.equal(releaseProcess.prUrl, 'https://github.com/FlowFuse/website/pull/5158') - - // The same week's PR list surfaces #5158 as a deduped entry. - const pr5158 = recent.prs.find(p => p.prNumber === 5158) - assert.ok(pr5158, 'PR #5158 present in most recent week prs') - assert.equal(pr5158.url, 'https://github.com/FlowFuse/website/pull/5158') + assert.equal(recent.weekStart, mondayOf(date).toISOString().slice(0, 10), 'weeks[0] is the latest commit\'s week') + + for (const url of urls) { + const page = recent.pages.find(p => p.url === url) + assert.ok(page, `${url} present in most recent week`) + assert.equal(page.commits.some(c => c.sha === sha), true, `commit ${sha} recorded against ${url}`) + } + + if (prMatch) { + const pr = recent.prs.find(p => p.prNumber === Number(prMatch[1])) + assert.ok(pr, `PR #${prMatch[1]} present in most recent week prs`) + } })