import { useMemo, useState } from 'react' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { useShardFeed } from '../../lib/useShardFeed.js' import { crestFor } from '../../data/cityCrests.js' import { api } from '../../api/client.js' // The town-governor board (City Loyalty). Loaded from /public/shard/governors, // kept live by merging city.update deltas by city. Empty on shards without the // City Loyalty system. Each city card links to its term history (look-back). const GOV_KINDS = new Set(['city.update']) const PHASE = { none: null, nominate: { label: 'Nominations open', color: '#7f8fd0' }, vote: { label: 'Voting', color: '#e6c26a' }, pending: { label: 'Result pending', color: '#c9a24b' }, } // A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt). function until(iso) { if (!iso) return '' const ms = new Date(iso).getTime() - Date.now() if (!Number.isFinite(ms) || ms <= 0) return '' const mins = Math.round(ms / 60000) if (mins < 60) return `in ${mins}m` const hrs = Math.round(mins / 60) if (hrs < 24) return `in ${hrs}h` return `in ${Math.round(hrs / 24)}d` } function fmtDate(ms) { if (ms == null) return '' return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) } function CityCrest({ city, size = 44 }) { const c = crestFor(city) return ( ) } // Collapsible term history for one city, fetched on demand from the ledger. function TermHistory({ city }) { const [open, setOpen] = useState(false) const { loading, error, data } = useAsync( () => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)), [open, city], ) return (
{open && (
{loading &&

Loading…

} {error &&

Could not load history.

} {data && data.length === 0 && (

No recorded terms yet.

)} {data && data.length > 0 && ( )}
)}
) } function CityCard({ c }) { const phase = PHASE[c.electionPhase] || null const gov = c.governor return (
{crestFor(c.city).label || c.city} {phase && ( {phase.label} )}
{gov ? ( <>Governor {gov.name} ) : ( 'Seat vacant' )}
{c.electionPhase && c.electionPhase !== 'none' && (
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'} {c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
)}
) } export default function Governors() { const { loading, error, data } = useAsync(() => api.shard.governors()) const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 }) const board = useMemo(() => { const map = new Map() for (const c of data || []) if (c && c.city) map.set(c.city, c) for (let i = events.length - 1; i >= 0; i -= 1) { const ev = events[i] if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev) } return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || '')) }, [data, events]) return (
{connected ? 'Live' : 'Offline'}
{loading && } {error && } {!loading && !error && ( <> {board.length === 0 ? (

City Loyalty governance is not enabled on this shard.

) : (
{board.map((c) => )}
)} )}
) }