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(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 (
SHA
{shortSha || sha || '—'}
Message
{message || '—'}
Published
{publishedLocal}
Committed
{committedLocal}
Last checked
{lastCheckedAt ? checkedLocal : 'Never'}
{codeUrl ? (
Code
Package
) : null}
{status === 'ok' ? Up to date : null} {status === 'update' ? ( {statusDetail || 'Update available'} ) : null}
{status === 'error' ? ( ) : null}
) }