Skip to content
← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

FollowFork

starter-tanstack/app/ui/about-panel.tsx

160 lines · 4.6 KB · TypeScript
import { useEffect, useState } from 'react'
import {
	formatLocalDateTime,
	readLastUpdateCheckAt,
	writeLastUpdateCheckAt,
} from './local-time.ts'
import { createBusyGate } from './busy.ts'
import { ErrorBanner } from './error-banner.tsx'

export type AboutPanelProps = {
	sha: string
	shortSha: string
	message: string
	publishedAt: string
	committedAt: string
	codeUrl: string | null
	runningSha: string
	appId?: string
}

/**
 * About metadata + manual update check.
 * Times render in the browser's local timezone; last-checked is stored locally.
 */
export function AboutPanel(props: AboutPanelProps) {
	const appId = props.appId || '__PACKAGE_ID__'
	const [lastCheckedAt, setLastCheckedAt] = useState<string | null>(null)
	const [checking, setChecking] = useState(false)
	const [showBusy, setShowBusy] = useState(false)
	const [status, setStatus] = useState<'idle' | 'ok' | 'update' | 'error'>('idle')
	const [statusDetail, setStatusDetail] = useState('')

	useEffect(() => {
		setLastCheckedAt(readLastUpdateCheckAt(appId))
	}, [appId])

	async function runCheck() {
		if (checking || typeof window === 'undefined') return
		setChecking(true)
		setStatus('idle')
		setStatusDetail('')
		const gate = createBusyGate({ delayMs: 200, minDurationMs: 400 })
		gate.start((phase) => setShowBusy(phase === 'busy'))
		try {
			const root = document.documentElement
			const appBase = root.getAttribute('data-app-base') || ''
			const runningSha =
				props.runningSha || root.getAttribute('data-running-sha') || ''
			const res = await fetch(`${appBase}/api/version`, {
				cache: 'no-store',
				credentials: 'same-origin',
			})
			const version = res.ok
				? ((await res.json()) as { sha?: string; shortSha?: string })
				: {}
			let waiting = false
			if ('serviceWorker' in navigator) {
				const regs = await navigator.serviceWorker.getRegistrations()
				for (const reg of regs) {
					if (reg.waiting) waiting = true
					if (reg.update) await reg.update().catch(() => null)
				}
			}
			const checked = writeLastUpdateCheckAt(new Date().toISOString(), appId)
			setLastCheckedAt(checked)
			const latestSha = String(version.sha || '')
			if (waiting || (runningSha && latestSha && runningSha !== latestSha)) {
				setStatus('update')
				setStatusDetail(
					waiting ? 'Service worker waiting' : `Latest ${version.shortSha || latestSha.slice(0, 7)}`,
				)
			} else {
				setStatus('ok')
				setStatusDetail('Up to date')
			}
		} catch (error) {
			setStatus('error')
			setStatusDetail(error instanceof Error ? error.message : String(error))
			setLastCheckedAt(writeLastUpdateCheckAt(new Date().toISOString(), appId))
		} finally {
			setChecking(false)
			await gate.stop((phase) => setShowBusy(phase === 'busy'))
		}
	}

	const {
		sha,
		shortSha,
		message,
		publishedAt,
		committedAt,
		codeUrl,
	} = props
	const publishedLocal = formatLocalDateTime(publishedAt)
	const committedLocal = formatLocalDateTime(committedAt)
	const checkedLocal = formatLocalDateTime(lastCheckedAt)

	return (
		<section className="pak-card pak-stack" data-about-panel>
			<dl className="pak-stack-sm">
				<div>
					<dt className="pak-muted">SHA</dt>
					<dd>
						<code tabIndex={0} data-tip={sha || ''}>
							{shortSha || sha || '—'}
						</code>
					</dd>
				</div>
				<div>
					<dt className="pak-muted">Message</dt>
					<dd>{message || '—'}</dd>
				</div>
				<div>
					<dt className="pak-muted">Published</dt>
					<dd data-tip={publishedAt || undefined}>{publishedLocal}</dd>
				</div>
				<div>
					<dt className="pak-muted">Committed</dt>
					<dd data-tip={committedAt || undefined}>{committedLocal}</dd>
				</div>
				<div>
					<dt className="pak-muted">Last checked</dt>
					<dd data-tip={lastCheckedAt || undefined}>
						{lastCheckedAt ? checkedLocal : 'Never'}
					</dd>
				</div>
				{codeUrl ? (
					<div>
						<dt className="pak-muted">Code</dt>
						<dd>
							<a href={codeUrl} data-tip={codeUrl}>
								Package
							</a>
						</dd>
					</div>
				) : null}
			</dl>
			<div className="pak-cluster">
				<button
					className="pak-btn"
					type="button"
					data-about-check-update
					disabled={checking || undefined}
					data-pending={checking ? 'true' : undefined}
					aria-busy={checking ? 'true' : undefined}
					onClick={() => void runCheck()}
				>
					{showBusy ? 'Checking…' : 'Check for updates'}
				</button>
				{status === 'ok' ? <span className="pak-muted">Up to date</span> : null}
				{status === 'update' ? (
					<span className="pak-muted">{statusDetail || 'Update available'}</span>
				) : null}
			</div>
			{status === 'error' ? (
				<ErrorBanner title="Update check failed" message={statusDetail || 'Unknown error'} />
			) : null}
		</section>
	)
}