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.

app/ui/about-panel.tsx

170 lines · 4.9 KB · TypeScript
/** @jsxRuntime automatic */
/** @jsxImportSource remix/ui */
import { clientEntry, on, type Handle } from 'remix/ui'
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
	/** localStorage key prefix */
	appId?: string
}

/**
 * About metadata + manual update check.
 * Times render in the browser's local timezone; last-checked is stored locally.
 */
export const AboutPanel = clientEntry(
	'kody:app#AboutPanel',
	function AboutPanel(handle: Handle<AboutPanelProps>) {
		const appId = handle.props.appId || 'package-app-kit-demo'
		const checkBusy = createBusyGate({ delayMs: 200, minDurationMs: 400 })
		let lastCheckedAt: string | null = null
		let checking = false
		let status: 'idle' | 'ok' | 'update' | 'error' = 'idle'
		let statusDetail = ''

		if (typeof window !== 'undefined') {
			lastCheckedAt = readLastUpdateCheckAt(appId)
		}

		async function runCheck() {
			if (checking || typeof window === 'undefined') return
			checking = true
			status = 'idle'
			statusDetail = ''
			checkBusy.start(() => handle.update())
			handle.update()
			try {
				const root = document.documentElement
				const appBase = root.getAttribute('data-app-base') || ''
				const runningSha =
					handle.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)
					}
				}
				lastCheckedAt = writeLastUpdateCheckAt(new Date().toISOString(), appId)
				const latestSha = String(version.sha || '')
				if (waiting || (runningSha && latestSha && runningSha !== latestSha)) {
					status = 'update'
					statusDetail = waiting
						? 'Service worker waiting'
						: `Latest ${version.shortSha || latestSha.slice(0, 7)}`
				} else {
					status = 'ok'
					statusDetail = 'Up to date'
				}
			} catch (error) {
				status = 'error'
				statusDetail = error instanceof Error ? error.message : String(error)
				lastCheckedAt = writeLastUpdateCheckAt(new Date().toISOString(), appId)
			} finally {
				checking = false
				await checkBusy.stop(() => handle.update())
				handle.update()
			}
		}

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

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