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/components/RelativeTime.vue b/nuxt/components/RelativeTime.vue new file mode 100644 index 0000000000..ff27d76fe4 --- /dev/null +++ b/nuxt/components/RelativeTime.vue @@ -0,0 +1,33 @@ + + + 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:
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`) + } })