Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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 · TypeScript
import 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('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
}

function escapeAttr(value: string) {
	return escapeHtml(value).replaceAll("'", '&#39;')
}

/**
 * About helpers overview.
 *
 */

/** Primary callable export for this subpath. */
export default aboutPageHtml