spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { EventTimeline } from '@/components/events/event-timeline';4import { eventLabel } from '@/components/events/event-badge';5import { Chips, Note } from '@/components/stats/shared';6import { Pagination } from '@/components/ui/pagination';7import { Container, PageHeader } from '@/components/ui/section';8import { Unavailable } from '@/components/ui/unavailable';9import { api, safe } from '@/lib/api';10import { fmtAgo, fmtInt } from '@/lib/format';11import { routes, SITE_NAME, SITE_URL } from '@/lib/site';1213export const revalidate = 60;1415type Search = Promise<{ page?: string | string[]; type?: string | string[]; since?: string | string[] }>;16const PAGE_SIZE = 50;17const WINDOWS = [18 { key: '24h', label: 'Last 24 h', hours: 24 },19 { key: '7d', label: 'Last 7 d', hours: 24 * 7 },20 { key: '30d', label: 'Last 30 d', hours: 24 * 30 },21] as const;2223const pick = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);2425const TITLE = 'Orbital events timeline — launches, decays, orbit changes';26const DESC = 'Chronological feed of what changed in Earth orbit: newly catalogued payloads, decays and reentries, orbit changes and decommissions, with source and derived confidence for every event.';2728export async function generateMetadata({ searchParams }: { searchParams: Search }): Promise<Metadata> {29 const sp = await searchParams;30 const type = pick(sp.type);31 const title = type ? `${eventLabel(type)} events — timeline` : TITLE;32 const url = `${SITE_URL}${type ? routes.events(`type=${encodeURIComponent(type)}`) : routes.events()}`;33 return {34 title,35 description: DESC,36 alternates: { canonical: url },37 openGraph: { title: `${title} | ${SITE_NAME}`, description: DESC, url, type: 'website', siteName: SITE_NAME },38 twitter: { card: 'summary_large_image', title: `${title} | ${SITE_NAME}`, description: DESC },39 };40}4142function buildHref(params: { page?: number; type?: string; since?: string }): string {43 const p = new URLSearchParams();44 if (params.type) p.set('type', params.type);45 if (params.since) p.set('since', params.since);46 if (params.page && params.page > 1) p.set('page', String(params.page));47 const s = p.toString();48 return routes.events(s || undefined);49}5051export default async function EventsPage({ searchParams }: { searchParams: Search }) {52 const sp = await searchParams;53 const page = Math.max(1, Number(pick(sp.page) ?? 1) || 1);54 const type = pick(sp.type) || undefined;55 const sinceRaw = pick(sp.since) || undefined;56 const since = sinceRaw && !Number.isNaN(new Date(sinceRaw).getTime()) ? sinceRaw : undefined;5758 const base = { page, page_size: PAGE_SIZE, type };59 let payload = await safe(api.events({ ...base, since }));60 let sinceFailed = false;61 if (!payload && since) {62 // The API currently rejects `since` (500) — degrade honestly to the unfiltered feed and say so.63 sinceFailed = true;64 payload = await safe(api.events(base));65 }6667 const types = payload?.types ?? [];68 const now = Date.now();69 const typeChips = [{ href: buildHref({ since }), label: 'All types', active: !type, count: types.length ? fmtInt(types.reduce((s, t) => s + (Number(t.count) || 0), 0)) : undefined }, ...types.map((t) => ({ href: buildHref({ type: t.type, since }), label: eventLabel(t.type), active: type === t.type, count: fmtInt(t.count) }))];70 const windowChips = [{ href: buildHref({ type }), label: 'All time', active: !since }, ...WINDOWS.map((w) => {71 const iso = new Date(now - w.hours * 3600_000).toISOString().slice(0, 19) + 'Z';72 // active if current `since` is within ±1 h of this window73 const active = !!since && Math.abs(new Date(since).getTime() - (now - w.hours * 3600_000)) < 3600_000;74 return { href: buildHref({ type, since: iso }), label: w.label, active };75 })];7677 return (78 <Container>79 <PageHeader eyebrow="Events" title="Orbital events timeline" lede="Every change detected by SatelliteIndex connectors, newest first: payloads catalogued from a launch, decays, orbit changes, decommissions. Each event carries its source and a derived confidence.">80 {payload && payload.data[0] && <p className="mono mt-4 text-xs text-ink-3">Latest event {fmtAgo(payload.data[0].event_time)} · {fmtInt(payload.pagination.total)} events in view</p>}81 </PageHeader>8283 <div className="space-y-3 pb-6">84 <Chips items={typeChips} ariaLabel="Event type" />85 <Chips items={windowChips} ariaLabel="Time window" />86 </div>8788 {sinceFailed && (89 <Note tone="warn" className="mb-6">90 The time-window filter is unavailable from the API right now — showing the unfiltered feed instead. <Link href={buildHref({ type })} className="text-accent hover:underline">Clear window</Link>91 </Note>92 )}9394 {!payload ? (95 <div className="py-6">96 <Unavailable what="Events feed" />97 </div>98 ) : payload.data.length === 0 ? (99 <div className="py-6">100 <Unavailable what="Events matching these filters" />101 <Note className="mt-3">102 Only event types produced by connected sources appear here. <Link href={routes.events()} className="text-accent hover:underline">Reset filters</Link>103 </Note>104 </div>105 ) : (106 <>107 <EventTimeline events={payload.data} />108 <Pagination className="mt-8" page={payload.pagination.page} pages={payload.pagination.pages} total={payload.pagination.total} pageSize={payload.pagination.page_size} makeHref={(p) => buildHref({ page: p, type, since: sinceFailed ? undefined : since })} />109 </>110 )}111112 <Note className="mt-10 pb-8">113 Times are UTC. Confidence is a <Link href={routes.methodology()} className="text-accent hover:underline">derived</Link> score, not a measurement. Event types that no connected source produces (e.g. regulatory approvals) are simply absent — nothing is simulated.114 </Note>115 </Container>116 );117}118