|
1 |
+import Link from "next/link"; |
|
2 |
+import type { Metadata } from "next"; |
|
3 |
+import { notFound } from "next/navigation"; |
|
4 |
+import { Bar, Chip, Flag, PageHeader, Panel, Stat, Table, Td } from "@/components/ui"; |
|
5 |
+import { api } from "@/lib/api"; |
|
6 |
+import { fmtInt, fmtPct, relTime } from "@/lib/format"; |
|
7 |
+ |
|
8 |
+export const dynamic = "force-dynamic"; |
|
9 |
+ |
|
10 |
+export async function generateMetadata({ params }: { params: Promise<{ sector: string }> }): Promise<Metadata> { |
|
11 |
+ const { sector } = await params; |
|
12 |
+ const c = await api.coverageSector(sector); |
|
13 |
+ if (!c) return { title: "Coverage" }; |
|
14 |
+ return { title: `${c.label} · coverage ${c.score.toFixed(1)}`, description: `${fmtInt(c.covered)} of ${fmtInt(c.members)} ${c.label.toLowerCase()} organizations observed by WebSensor (${c.universes.length} universes).`, alternates: { canonical: `/coverage/${c.sector}` } }; |
|
15 |
+} |
|
16 |
+ |
|
17 |
+function band(score: number): string { |
|
18 |
+ return score >= 80 ? "text-signal" : score >= 60 ? "text-high" : score >= 40 ? "text-warn" : "text-fg-muted"; |
|
19 |
+} |
|
20 |
+ |
|
21 |
+type Params = { u?: string; status?: string }; |
|
22 |
+ |
|
23 |
+export default async function CoverageSectorPage({ params, searchParams }: { params: Promise<{ sector: string }>; searchParams: Promise<Params> }) { |
|
24 |
+ const { sector } = await params; |
|
25 |
+ const sp = await searchParams; |
|
26 |
+ const c = await api.coverageSector(sector); |
|
27 |
+ if (!c) notFound(); |
|
28 |
+ const universes = sp.u ? c.universes.filter((u) => u.key === sp.u) : c.universes; |
|
29 |
+ const filter = (m: { covered: boolean; shadow: number; seed_status: string | null }): boolean => { |
|
30 |
+ if (sp.status === "observed") return m.covered; |
|
31 |
+ if (sp.status === "shadow") return !m.covered && m.shadow > 0; |
|
32 |
+ if (sp.status === "queued") return !m.covered && m.shadow === 0 && (m.seed_status === "queued" || m.seed_status === "discovering"); |
|
33 |
+ if (sp.status === "missing") return !m.covered && m.shadow === 0 && !(m.seed_status === "queued" || m.seed_status === "discovering"); |
|
34 |
+ return true; |
|
35 |
+ }; |
|
36 |
+ const href = (patch: Params): string => { |
|
37 |
+ const n = { ...sp, ...patch }; |
|
38 |
+ const q = new URLSearchParams(); |
|
39 |
+ if (n.u) q.set("u", n.u); |
|
40 |
+ if (n.status) q.set("status", n.status); |
|
41 |
+ const s = q.toString(); |
|
42 |
+ return `/coverage/${c.sector}${s ? `?${s}` : ""}`; |
|
43 |
+ }; |
|
44 |
+ return ( |
|
45 |
+ <> |
|
46 |
+ <PageHeader |
|
47 |
+ compact |
|
48 |
+ kicker={<Link href="/coverage" className="hover:underline">Coverage</Link>} |
|
49 |
+ title={<span>{c.label} <span className={`font-mono ${band(c.score)}`}>{c.score.toFixed(1)}</span></span>} |
|
50 |
+ description={`${c.description ?? ""} ${fmtInt(c.covered)} of ${fmtInt(c.members)} organizations observed · breadth ${fmtPct(c.breadth)} · depth ${fmtPct(c.depth)} · ${fmtInt(c.sensors)} sensors matched · ${fmtInt(c.shadow_members)} in shadow · ${fmtInt(c.queued)} queued for discovery. Updated ${relTime(c.generated_at)}.`} |
|
51 |
+ /> |
|
52 |
+ <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0"> |
|
53 |
+ <Stat label="Score" value={<span className={band(c.score)}>{c.score.toFixed(1)}</span>} hint={`weight ×${c.weight}`} /> |
|
54 |
+ <Stat label="Breadth" value={fmtPct(c.breadth)} hint="importance-weighted share observed" /> |
|
55 |
+ <Stat label="Depth" value={fmtPct(c.depth)} hint="sensors per organization / 5" /> |
|
56 |
+ <Stat label="Observed" value={`${fmtInt(c.covered)} / ${fmtInt(c.members)}`} tone="signal" /> |
|
57 |
+ <Stat label="Shadow" value={fmtInt(c.shadow_members)} tone="info" hint="validated, proving signal" /> |
|
58 |
+ <Stat label="Countries" value={fmtInt(c.by_country.filter((x) => x.country !== "—").length)} hint={`${c.universes.length} universes`} /> |
|
59 |
+ </div> |
|
60 |
+ <div className="mb-2 flex flex-wrap items-center gap-1"> |
|
61 |
+ <span className="label mr-1">status</span> |
|
62 |
+ {[["", "all"], ["observed", "observed"], ["shadow", "shadow"], ["queued", "queued"], ["missing", "not yet seeded"]].map(([k, l]) => ( |
|
63 |
+ <Chip key={k} href={href({ status: k || undefined })} tone={(sp.status ?? "") === k ? "signal" : "default"}>{l}</Chip> |
|
64 |
+ ))} |
|
65 |
+ <span className="label mx-1">·</span> |
|
66 |
+ <span className="label mr-1">universe</span> |
|
67 |
+ <Chip href={href({ u: undefined })} tone={!sp.u ? "signal" : "default"}>all</Chip> |
|
68 |
+ {c.universes.map((u) => ( |
|
69 |
+ <Chip key={u.key} href={href({ u: u.key })} tone={sp.u === u.key ? "signal" : "default"}>{u.label} <span className="font-mono text-[10.5px] text-fg-subtle">{u.covered}/{u.members}</span></Chip> |
|
70 |
+ ))} |
|
71 |
+ </div> |
|
72 |
+ {universes.map((u) => { |
|
73 |
+ const items = u.items.filter(filter); |
|
74 |
+ return ( |
|
75 |
+ <Panel key={u.key} dense className="mb-4" title={<span>{u.label} <span className={`font-mono text-[12px] ${band(u.score)}`}>{u.score.toFixed(1)}</span> <span className="font-mono text-[11px] text-fg-subtle">· {u.covered}/{u.members} observed · {fmtInt(u.sensors)} sensors</span></span>} action={u.provenance ? <span className="max-w-[40ch] truncate text-[10.5px] text-fg-subtle" title={u.provenance}>{u.provenance}</span> : undefined}> |
|
76 |
+ {items.length === 0 ? ( |
|
77 |
+ <div className="p-3 text-[12px] text-fg-subtle">No member matches this filter.</div> |
|
78 |
+ ) : ( |
|
79 |
+ <div className="max-h-[32rem] overflow-auto"> |
|
80 |
+ <Table head={["Organization", "Domain", "Country", "Imp.", "Status", "Sensors", "Depth", ""]}> |
|
81 |
+ {items.map((m) => ( |
|
82 |
+ <tr key={`${u.key}/${m.domain}`} className="hover:bg-panel-2/60"> |
|
83 |
+ <Td>{m.source_id ? <Link href={`/source/${m.source_id}`} className="font-medium hover:underline">{m.name}</Link> : <span className="font-medium">{m.name}</span>}</Td> |
|
84 |
+ <Td mono><Link href={`/domain/${m.domain}`} className="text-fg-muted hover:underline">{m.domain}</Link></Td> |
|
85 |
+ <Td>{m.country ? <span className="inline-flex items-center gap-1 font-mono text-[11px] text-fg-muted"><Flag code={m.country} /> {m.country}</span> : <span className="text-fg-subtle">—</span>}</Td> |
|
86 |
+ <Td mono className="text-fg-subtle">{m.importance}</Td> |
|
87 |
+ <Td>{m.covered ? <Chip tone="ok">observed</Chip> : m.shadow > 0 ? <Chip tone="info">shadow ×{m.shadow}</Chip> : m.seed_status === "queued" || m.seed_status === "discovering" ? <Chip tone="warn">{m.seed_status}</Chip> : m.seed_status === "blocked" ? <Chip tone="danger">blocked</Chip> : m.seed_status ? <Chip>{m.seed_status}</Chip> : <Chip>not seeded</Chip>}</Td> |
|
88 |
+ <Td mono>{m.sensors}{m.shadow ? <span className="text-fg-subtle"> +{m.shadow}</span> : null}</Td> |
|
89 |
+ <Td><div className="w-16"><Bar value={m.depth * 100} tone={m.depth >= 0.8 ? "signal" : m.depth >= 0.4 ? "mid" : "info"} /></div></Td> |
|
90 |
+ <Td>{m.source_id && <Link href={`/source/${m.source_id}`} className="whitespace-nowrap text-[11px] text-fg-subtle hover:text-fg">source →</Link>}</Td> |
|
91 |
+ </tr> |
|
92 |
+ ))} |
|
93 |
+ </Table> |
|
94 |
+ </div> |
|
95 |
+ )} |
|
96 |
+ </Panel> |
|
97 |
+ ); |
|
98 |
+ })} |
|
99 |
+ </> |
|
100 |
+ ); |
|
101 |
+} |