← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
src/about.ts
191 lines · 6.6 KB · TypeScriptimport type { AppVersion } from './version.ts'
import { emptyVersion, normalizeVersion } from './version.ts'
export type AboutPageData = {
sha: string
shortSha: string
message: string
committedAt: string
publishedAt: string
committedRelative: string
publishedRelative: string
codeUrl: string | null
runningSha: string
runningShortSha: string
updateAvailable: boolean
}
/**
* Format a relative age like "3 hours ago" from an ISO timestamp.
*
* @param iso - ISO date string (or empty)
* @param now - Optional now (ms) for tests
* @returns Human relative age, or "—" when missing/invalid
*
* @example
* import { formatRelativeAge } from 'kody:@kentcdodds/package-app-kit/about'
* formatRelativeAge('2026-09-12T18:00:00.000Z')
*/
export function formatRelativeAge(iso: string, now = Date.now()): string {
const raw = String(iso || '').trim()
if (!raw) return '—'
const then = Date.parse(raw)
if (!Number.isFinite(then)) return '—'
const delta = Math.max(0, now - then)
const sec = Math.floor(delta / 1000)
if (sec < 45) return 'just now'
const min = Math.floor(sec / 60)
if (min < 60) return min === 1 ? '1 minute ago' : `${min} minutes ago`
const hr = Math.floor(min / 60)
if (hr < 48) return hr === 1 ? '1 hour ago' : `${hr} hours ago`
const day = Math.floor(hr / 24)
if (day < 30) return day === 1 ? '1 day ago' : `${day} days ago`
const month = Math.floor(day / 30)
if (month < 18) return month === 1 ? '1 month ago' : `${month} months ago`
const year = Math.floor(day / 365)
return year === 1 ? '1 year ago' : `${year} years ago`
}
/**
* Format an ISO timestamp in the runtime's local timezone (browser or Worker TZ).
* Prefer calling this in the browser so "local" means the user's device.
*/
export function formatLocalDateTime(iso: string, timeZone?: string): string {
const raw = String(iso || '').trim()
if (!raw) return '—'
const ms = Date.parse(raw)
if (!Number.isFinite(ms)) return '—'
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
...(timeZone ? { timeZone } : {}),
}).format(new Date(ms))
} catch {
return new Date(ms).toLocaleString()
}
}
/**
* Build About page view-model from a record-version payload (+ optional running sha).
* Use for About screens showing commit sha/message, dates + relative ages, and package code link.
*
* @param input.version - Payload from packageStorage / `./record-version`
* @param input.runningSha - SHA baked into the currently running client shell
* @param input.codeUrl - Link to package source / listing (optional)
* @param input.now - Optional now (ms) for relative ages
* @returns Formatted About fields
*
* @example
* import { formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'
* const about = formatAboutPageData({
* version: { sha: 'abc1234', message: 'Ship kit', publishedAt: new Date().toISOString() },
* runningSha: 'abc1234',
* codeUrl: 'https://kody.codes/@kentcdodds/package-app-kit',
* })
*/
export function formatAboutPageData(input: {
version?: Partial<AppVersion> | null
runningSha?: string
codeUrl?: string | null
now?: number
}): AboutPageData {
const version = input.version ? normalizeVersion(input.version) : emptyVersion()
const runningSha = String(input.runningSha ?? '').trim()
const now = input.now ?? Date.now()
return {
...version,
committedRelative: formatRelativeAge(version.committedAt, now),
publishedRelative: formatRelativeAge(version.publishedAt, now),
codeUrl: input.codeUrl ? String(input.codeUrl) : null,
runningSha,
runningShortSha: runningSha.slice(0, 7),
updateAvailable: Boolean(version.sha && runningSha && version.sha !== runningSha),
}
}
function aboutBodyHtml(data: AboutPageData, title: string): string {
const code = data.codeUrl
? `<p><a href="${escapeAttr(data.codeUrl)}" rel="noopener">Package code</a></p>`
: ''
const update = data.updateAvailable
? `<p class="pak-muted">Update available — running <code>${escapeHtml(data.runningShortSha || '—')}</code>, latest <code>${escapeHtml(data.shortSha || '—')}</code>.</p>`
: `<p class="pak-muted">Running <code>${escapeHtml(data.runningShortSha || data.shortSha || '—')}</code>.</p>`
return `
<p class="pak-eyebrow">${escapeHtml(title)}</p>
<h2 class="pak-about-heading">Commit <code>${escapeHtml(data.shortSha || '—')}</code></h2>
<p>${escapeHtml(data.message || 'No publish message recorded yet.')}</p>
<dl class="pak-muted pak-about-meta">
<div><dt>Committed</dt> <dd>${escapeHtml(data.committedAt || '—')} (${escapeHtml(data.committedRelative)})</dd></div>
<div><dt>Published</dt> <dd>${escapeHtml(data.publishedAt || '—')} (${escapeHtml(data.publishedRelative)})</dd></div>
</dl>
${update}
${code}
<p class="pak-about-actions"><button class="pak-btn" type="button" data-about-check-update>Check for updates</button></p>
`.trim()
}
/**
* Full About page body (sha, message, commit/publish dates + relative, code link, update check).
* Render at `/about` — do not dump this on the home shell.
*
* @example
* import { aboutPageHtml, formatAboutPageData } from 'kody:@kentcdodds/package-app-kit/about'
* const html = aboutPageHtml(formatAboutPageData({ version: { sha: 'abc' } }))
*/
export function aboutPageHtml(
data: AboutPageData,
options?: { title?: string },
): string {
const title = options?.title || 'About'
return `
<section class="pak-card pak-stack" data-about-page>
${aboutBodyHtml(data, title)}
</section>
`.trim()
}
/**
* Collapsed About disclosure kept for apps that still want an in-page panel.
* Prefer `aboutPageHtml` on a real `/about` route.
*
* @example
* import { aboutPanelHtml } from 'kody:@kentcdodds/package-app-kit/about'
* const html = aboutPanelHtml(formatAboutPageData({ version: { sha: 'abc' } }))
*/
export function aboutPanelHtml(
data: AboutPageData,
options?: { title?: string; summaryLabel?: string },
): string {
const title = options?.title || 'About'
const summary = options?.summaryLabel || 'About'
return `
<details class="pak-about" data-about>
<summary class="pak-about-summary pak-btn" data-about-toggle>${escapeHtml(summary)}</summary>
<section class="pak-card pak-about-panel" data-about-panel>
${aboutBodyHtml(data, title)}
</section>
</details>
`.trim()
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
function escapeAttr(value: string) {
return escapeHtml(value).replaceAll("'", ''')
}
/**
* About helpers overview.
*
*/
/** Primary callable export for this subpath. */
export default aboutPageHtml