SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
5.9 KB · 118 lines tsx
Raw Blame History
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