Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
33 changes: 33 additions & 0 deletions nuxt/components/RelativeTime.vue
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>
54 changes: 54 additions & 0 deletions nuxt/lib/relative-time.mjs
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
Comment thread
ZJvandeWeg marked this conversation as resolved.
// 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')
}
56 changes: 56 additions & 0 deletions nuxt/lib/relative-time.test.mjs
Comment thread
ZJvandeWeg marked this conversation as resolved.
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)
})
2 changes: 1 addition & 1 deletion nuxt/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const breadcrumbs = computed(() => {
<div class="sticky top-20 w-full mt-4 md:mt-6 px-8">
<HandbookToc :links="page?.body?.toc?.links" />
<div v-if="page?.updated" class="text-xs pb-1 text-right mt-4 text-gray-500 max-lg:hidden">
Updated: {{ page.updated }}
Updated: <RelativeTime :value="page.updated" />
</div>
<ClientOnly>
<div v-if="page?.originalPath" class="text-xs pb-1 text-right italic max-lg:hidden">
Expand Down
43 changes: 33 additions & 10 deletions nuxt/server/lib/handbookChanges.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------
Expand Down Expand Up @@ -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`)
}
})