TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import Link from "next/link";2import type { Metadata } from "next";3import { EventRow } from "@/components/event-row";4import { Badge, Bar, Chip, Empty, Flag, PageHeader, Panel, Score, StateBadge, Table, Td, Tabs, TierBadge } from "@/components/ui";5import { api, type Cluster, type EventItem } from "@/lib/api";6import { CHANNELS, SAVED_VIEWS, feedHref, fmtInt, fmtOffset, fmtScore, relTime, typeLabel } from "@/lib/format";78export const dynamic = "force-dynamic";910const TABS = [11 { key: "trending", label: "Trending" },12 { key: "breaking", label: "Breaking" },13 { key: "silent", label: "Silent" },14 { key: "newly", label: "Newly detected" },15 { key: "active", label: "Active sources" },16 { key: "unusual", label: "Unusual activity" },17 { key: "clusters", label: "Clusters" },18 { key: "entities", label: "Entities" },19 { key: "sources", label: "Sources" },20 { key: "categories", label: "Categories" },21 { key: "countries", label: "Countries" },22] as const;23type TabKey = (typeof TABS)[number]["key"];2425function tabOf(v: string | undefined): TabKey {26 return (TABS.find((t) => t.key === v)?.key ?? "trending") as TabKey;27}2829export async function generateMetadata({ searchParams }: { searchParams: Promise<{ tab?: string }> }): Promise<Metadata> {30 const { tab } = await searchParams;31 const t = TABS.find((x) => x.key === tabOf(tab))!;32 return { title: `Discover · ${t.label}`, description: "Trending entities, breaking clusters, silent changes, newly detected signals, active and anomalous sources, clusters, entities, sources, categories and countries.", alternates: { canonical: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}` } };33}3435/** Discover page (spec §28): URL-driven tabs over the derived views of the event store. */36export default async function ExplorePage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {37 const sp = await searchParams;38 const tab = tabOf(sp.tab);39 const [x, trending, desk, rank, sources, countries] = await Promise.all([40 api.explore(),41 tab === "trending" ? api.trending(24, 30) : Promise.resolve({ items: [] }),42 tab === "breaking" ? api.breakingDesk() : Promise.resolve(null),43 tab === "entities" ? api.rank(50) : Promise.resolve({ items: [] }),44 tab === "sources" ? api.sources({}) : Promise.resolve({ items: [] }),45 tab === "countries" ? api.countries() : Promise.resolve({ items: [] }),46 ]);47 const counts: Partial<Record<TabKey, number>> = {48 trending: tab === "trending" ? trending.items.length : undefined,49 breaking: desk ? desk.breaking_now.length + desk.developing.length + desk.recently_confirmed.length : undefined,50 silent: x?.silent_changes?.length,51 newly: x?.newly_detected?.length,52 active: x?.most_active_sources?.length,53 unusual: x?.unusual_activity?.length,54 clusters: x?.clusters?.length,55 entities: tab === "entities" ? rank.items.length : undefined,56 sources: tab === "sources" ? sources.items.length : undefined,57 categories: x?.by_category?.length,58 countries: tab === "countries" ? countries.items.length : undefined,59 };60 return (61 <>62 <PageHeader compact kicker="Discover" title="Explore" description="Derived views over the event store: who is changing the most, what mattered most, what changed silently, and where activity is abnormal." />63 <Tabs className="mb-3" current={tab} items={TABS.map((t) => ({ key: t.key, label: t.label, href: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}`, count: counts[t.key] }))} />64 <div className="mb-3 flex flex-wrap items-center gap-1">65 <span className="mr-1 text-[11px] text-fg-subtle">Saved views</span>66 {SAVED_VIEWS.map((v) => (67 <Chip key={v.key} href={feedHref(v.query, "/live")}>{v.label}</Chip>68 ))}69 </div>7071 {tab === "trending" && <TrendingTab items={trending.items} />}72 {tab === "breaking" && <BreakingTab desk={desk} />}73 {tab === "silent" && (74 <Panel title="Silent changes · 48 h" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all silent →</Link>}>75 {x?.silent_changes?.length ? x.silent_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No silent change detected in this window.</Empty>}76 </Panel>77 )}78 {tab === "newly" && (79 <Panel title="Newly detected · first observation of a page, feed or entity" dense action={<Link href="/live" className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}>80 {x?.newly_detected?.length ? x.newly_detected.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>Nothing newly detected in this window.</Empty>}81 </Panel>82 )}83 {tab === "active" && <ActiveTab items={x?.most_active_sources ?? []} />}84 {tab === "unusual" && <UnusualTab items={x?.unusual_activity ?? []} />}85 {tab === "clusters" && <ClustersTab items={x?.clusters ?? []} />}86 {tab === "entities" && <EntitiesTab items={rank.items} />}87 {tab === "sources" && <SourcesTab items={sources.items} />}88 {tab === "categories" && <CategoriesTab byCategory={x?.by_category ?? []} byType={x?.by_type ?? []} labels={x?.event_types ?? {}} />}89 {tab === "countries" && <CountriesTab items={countries.items} />}90 </>91 );92}9394// ---------------------------------------------------------------------------------------9596function Dir({ d }: { d?: "up" | "down" | "flat" }) {97 const c = d === "up" ? "text-signal" : d === "down" ? "text-fg-subtle" : "text-fg-muted";98 return <span className={`font-mono ${c}`} title={d === "up" ? "Rising vs. previous window" : d === "down" ? "Falling vs. previous window" : "Flat vs. previous window"}>{d === "up" ? "↑" : d === "down" ? "↓" : "→"}</span>;99}100101function TrendingTab({ items }: { items: Awaited<ReturnType<typeof api.trending>>["items"] }) {102 return (103 <Panel title="Trending entities · 24 h" dense action={<span className="text-[11px] text-fg-subtle">score = volume × acceleration × signal, vs. the previous 24 h</span>}>104 {items.length === 0 ? (105 <Empty>Trending is computed from the last 24 h of events.</Empty>106 ) : (107 <Table head={["#", "Entity", "", "Events", "Sources", "1st-party", "Silent", "Avg signal", "Score"]}>108 {items.map((t, i) => (109 <tr key={t.id} className="hover:bg-panel-2/60">110 <Td mono className="text-fg-subtle">{i + 1}</Td>111 <Td>112 <Link href={`/entity/${t.id}`} className="font-medium hover:underline">{t.name}</Link>113 <div className="truncate font-mono text-[10.5px] text-fg-subtle">{t.type}{t.domain ? ` · ${t.domain}` : ""}</div>114 </Td>115 <Td mono><Dir d={t.direction} /></Td>116 <Td mono className="whitespace-nowrap">{fmtInt(t.events)}<span className="text-fg-subtle"> / {fmtInt(t.prev_events)}</span></Td>117 <Td mono>{t.sources}</Td>118 <Td mono className={t.first_party ? "text-signal" : "text-fg-subtle"}>{t.first_party ?? 0}</Td>119 <Td mono className={t.silent ? "text-silent" : "text-fg-subtle"}>{t.silent}</Td>120 <Td mono>{fmtScore(t.avg_signal)}</Td>121 <Td>122 <div className="flex w-28 items-center gap-2">123 <Bar value={t.score} tone={t.score >= 80 ? "hot" : t.score >= 60 ? "high" : "signal"} />124 <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(t.score)}</span>125 </div>126 </Td>127 </tr>128 ))}129 </Table>130 )}131 </Panel>132 );133}134135function ClusterRows({ items, empty }: { items: Cluster[]; empty: string }) {136 if (!items.length) return <Empty>{empty}</Empty>;137 return (138 <ul className="divide-y divide-line">139 {items.map((c) => {140 const e = c.event as EventItem | null | undefined;141 return (142 <li key={c.id} className="grid grid-cols-[auto_1fr] items-start gap-x-3 px-3 py-2 text-[13px]">143 <Score value={e?.signal_score ?? c.max_importance} kind="signal" size="sm" />144 <div className="min-w-0">145 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle">146 <StateBadge state={c.state} />147 {(e?.source ?? c.source) && <span className="truncate font-mono uppercase text-fg-muted">{(e?.source ?? c.source)?.name}</span>}148 {e?.country && <Flag code={e.country} />}149 <span className="ml-auto whitespace-nowrap font-mono tabular">{relTime(c.last_at)}</span>150 </div>151 <Link href={`/cluster/${c.primary_slug ?? c.slug ?? c.id}`} className="mt-0.5 block font-medium leading-snug hover:underline">{c.title}</Link>152 <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle">153 <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count ?? 1} source{(c.source_count ?? 1) === 1 ? "" : "s"}</span>154 {(c.first_party_count ?? 0) > 0 && <span className="text-signal">{c.first_party_count} first-party</span>}155 {(c.external_count ?? 0) > 0 && <span>{c.external_count} external</span>}156 {c.lead_time_ms && c.lead_time_ms > 0 ? <span title="WebSensor lead time before the first external report">lead {fmtOffset(c.lead_time_ms).replace("+", "")}</span> : null}157 {c.categories?.slice(0, 2).map((k) => <Chip key={k} href={`/category/${k}`}>{k}</Chip>)}158 </div>159 </div>160 </li>161 );162 })}163 </ul>164 );165}166167function BreakingTab({ desk }: { desk: Awaited<ReturnType<typeof api.breakingDesk>> }) {168 const d = desk ?? { breaking_now: [], developing: [], recently_confirmed: [], watching: [] };169 return (170 <div className="grid gap-4 xl:grid-cols-2">171 <Panel title={<span className="text-hot">Breaking now <span className="font-mono text-fg-subtle">{d.breaking_now.length}</span></span>} dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">desk →</Link>}>172 <ClusterRows items={d.breaking_now} empty="Nothing is breaking right now. Breaking requires strong signal, freshness and confirmation — not just recency." />173 </Panel>174 <Panel title={<span className="text-high">Developing <span className="font-mono text-fg-subtle">{d.developing.length}</span></span>} dense>175 <ClusterRows items={d.developing} empty="No developing story: signals are not accumulating fast enough anywhere." />176 </Panel>177 <Panel title={<span className="text-ok">Recently confirmed <span className="font-mono text-fg-subtle">{d.recently_confirmed.length}</span></span>} dense>178 <ClusterRows items={d.recently_confirmed} empty="No cluster reached independent confirmation in the last 48 h." />179 </Panel>180 <Panel title="Watching · high signal, not yet breaking" dense>181 {d.watching.length ? d.watching.slice(0, 12).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>Nothing to watch.</Empty>}182 </Panel>183 </div>184 );185}186187function ActiveTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["most_active_sources"] }) {188 const max = Math.max(1, ...items.map((s) => s.events_24h));189 return (190 <Panel title="Most active sources · 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}>191 {items.length === 0 ? (192 <Empty />193 ) : (194 <Table head={["#", "Source", "Domain", "Kind", "Events 24 h", "", "Max importance"]}>195 {items.map((s, i) => (196 <tr key={s.id} className="hover:bg-panel-2/60">197 <Td mono className="text-fg-subtle">{i + 1}</Td>198 <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td>199 <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td>200 <Td>{(s as { first_party?: boolean }).first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td>201 <Td mono>{fmtInt(s.events_24h)}</Td>202 <Td><div className="w-24 sm:w-40"><Bar value={s.events_24h} max={max} tone="signal" /></div></Td>203 <Td><Score value={s.max_importance} size="sm" /></Td>204 </tr>205 ))}206 </Table>207 )}208 </Panel>209 );210}211212function UnusualTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["unusual_activity"] }) {213 return (214 <Panel title="Unusual activity · last 2 h vs. 14-day baseline" dense action={<Link href="/radar" className="text-[11px] text-fg-subtle hover:text-fg">radar →</Link>}>215 {items.length === 0 ? (216 <Empty>Activity anomalies compare the last 2 h of raw changes with a 14-day baseline. Nothing is far above its baseline right now.</Empty>217 ) : (218 <Table head={["Source", "Domain", "Changes 2 h", "Baseline / day", "vs. baseline", "Activity score"]}>219 {items.map((s) => {220 const tone = s.activity_score >= 70 ? "hot" : s.activity_score >= 45 ? "high" : "signal";221 return (222 <tr key={s.id} className="hover:bg-panel-2/60">223 <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td>224 <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td>225 <Td mono>{s.changes_2h}</Td>226 <Td mono className="text-fg-subtle">{s.baseline_per_day}</Td>227 <Td mono className={`font-semibold ${tone === "hot" ? "text-hot" : tone === "high" ? "text-high" : "text-fg-muted"}`}>{s.pct_vs_baseline !== null && s.pct_vs_baseline !== undefined ? `+${Math.min(9999, Math.round(s.pct_vs_baseline)).toLocaleString("en-US")}%` : "—"}</Td>228 <Td>229 <div className="flex w-32 items-center gap-2">230 <Bar value={s.activity_score} tone={tone} />231 <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(s.activity_score)}</span>232 </div>233 </Td>234 </tr>235 );236 })}237 </Table>238 )}239 </Panel>240 );241}242243function ClustersTab({ items }: { items: Cluster[] }) {244 return (245 <Panel title="Event clusters · 48 h" dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">breaking desk →</Link>}>246 <ClusterRows items={items} empty="No multi-observation clusters yet. Related observations are grouped into clusters as they arrive." />247 </Panel>248 );249}250251function EntitiesTab({ items }: { items: Awaited<ReturnType<typeof api.rank>>["items"] }) {252 return (253 <Panel title="Entity rankings · 7 d" dense action={<Link href="/entities" className="text-[11px] text-fg-subtle hover:text-fg">all entities →</Link>}>254 {items.length === 0 ? (255 <Empty>Rankings appear once entities accumulate events.</Empty>256 ) : (257 <Table head={["#", "Entity", "Type", "24 h", "7 d", "Baseline / d", "Sources", "Silent", "Breaking", "Avg signal", "Confirmed", "Last", "Rank score"]}>258 {items.map((e) => (259 <tr key={e.id} className="hover:bg-panel-2/60">260 <Td mono className="text-fg-subtle">{e.rank}</Td>261 <Td>262 <Link href={`/entity/${e.id}`} className="font-medium hover:underline">{e.name}</Link>263 {e.domain && <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain}</div>}264 </Td>265 <Td><Chip>{e.type.replace(/_/g, " ")}</Chip></Td>266 <Td mono>{fmtInt(e.events_24h)}</Td>267 <Td mono>{fmtInt(e.events_7d)}</Td>268 <Td mono className="text-fg-subtle">{e.baseline_per_day.toFixed(1)}</Td>269 <Td mono>{e.sources}</Td>270 <Td mono className={e.silent_24h ? "text-silent" : "text-fg-subtle"}>{e.silent_24h}</Td>271 <Td mono className={e.breaking_24h ? "text-hot" : "text-fg-subtle"}>{e.breaking_24h}</Td>272 <Td mono>{fmtScore(e.avg_signal)}</Td>273 <Td mono className="text-fg-subtle">{Math.round(e.confirmed_ratio * 100)}%</Td>274 <Td mono className="text-fg-subtle">{relTime(e.last_at)}</Td>275 <Td>276 <div className="flex w-24 items-center gap-2">277 <Bar value={e.rank_score} tone={e.rank_score >= 80 ? "hot" : e.rank_score >= 60 ? "high" : "signal"} />278 <span className="w-7 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(e.rank_score)}</span>279 </div>280 </Td>281 </tr>282 ))}283 </Table>284 )}285 </Panel>286 );287}288289function SourcesTab({ items }: { items: Awaited<ReturnType<typeof api.sources>>["items"] }) {290 const top = [...items].sort((a, b) => (b.events_24h ?? 0) - (a.events_24h ?? 0)).slice(0, 50);291 const max = Math.max(1, ...top.map((s) => s.events_24h ?? 0));292 return (293 <Panel title="Sources · top 50 by events in 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}>294 {top.length === 0 ? (295 <Empty />296 ) : (297 <Table head={["Tier", "Source", "Country", "Kind", "Categories", "Sensors", "Events 24 h", "", "Total", "Last event"]}>298 {top.map((s) => (299 <tr key={s.id} className="hover:bg-panel-2/60">300 <Td><TierBadge tier={s.tier} /></Td>301 <Td>302 <Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link>303 <div className="truncate font-mono text-[10.5px] text-fg-subtle">{s.domain}</div>304 </Td>305 <Td><Flag code={s.country} /></Td>306 <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td>307 <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}</div></Td>308 <Td mono>{s.sensor_count ?? 0}</Td>309 <Td mono>{fmtInt(s.events_24h ?? 0)}</Td>310 <Td><div className="w-20 sm:w-32"><Bar value={s.events_24h ?? 0} max={max} /></div></Td>311 <Td mono className="text-fg-subtle">{fmtInt(s.event_count)}</Td>312 <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td>313 </tr>314 ))}315 </Table>316 )}317 </Panel>318 );319}320321function CategoriesTab({ byCategory, byType, labels }: { byCategory: { category: string; n: number }[]; byType: { event_type: string; n: number }[]; labels: Record<string, string> }) {322 const maxCat = Math.max(1, ...byCategory.map((t) => t.n));323 const maxType = Math.max(1, ...byType.map((t) => t.n));324 const channelOf = (c: string): string | undefined => CHANNELS.find((ch) => ch.query.category === c)?.label;325 return (326 <div className="grid gap-4 lg:grid-cols-2">327 <Panel title="Events by category · 7 d" dense>328 {byCategory.length ? (329 <ul className="divide-y divide-line">330 {byCategory.slice(0, 30).map((t) => (331 <li key={t.category} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]">332 <Link href={`/category/${t.category}`} className="truncate hover:underline" title={channelOf(t.category) ? `${channelOf(t.category)} desk` : t.category}>{t.category}</Link>333 <Bar value={t.n} max={maxCat} tone="signal" />334 <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span>335 </li>336 ))}337 </ul>338 ) : (339 <Empty />340 )}341 </Panel>342 <Panel title="Events by type · 7 d" dense>343 {byType.length ? (344 <ul className="divide-y divide-line">345 {byType.slice(0, 30).map((t) => (346 <li key={t.event_type} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]">347 <Link href={`/live?event_type=${t.event_type}`} className="truncate hover:underline" title={labels[t.event_type] ?? typeLabel(t.event_type)}>{typeLabel(t.event_type)}</Link>348 <Bar value={t.n} max={maxType} tone="info" />349 <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span>350 </li>351 ))}352 </ul>353 ) : (354 <Empty />355 )}356 </Panel>357 </div>358 );359}360361function CountriesTab({ items }: { items: Awaited<ReturnType<typeof api.countries>>["items"] }) {362 return (363 <Panel title={`Countries · ${items.length}`} dense action={<Link href="/country" className="text-[11px] text-fg-subtle hover:text-fg">country desks →</Link>}>364 {items.length === 0 ? (365 <Empty>Country desks appear once sources carry a country.</Empty>366 ) : (367 <ul className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">368 {items.map((c) => (369 <li key={c.country} className="bg-panel">370 <Link href={`/country/${c.slug}`} className="flex items-center gap-3 px-3 py-2 hover:bg-panel-2/60">371 <span className="w-6 text-center text-lg leading-none" aria-hidden>{c.flag || <Flag code={c.country} />}</span>372 <span className="min-w-0 flex-1">373 <span className="block truncate text-[13px] font-medium">{c.name}</span>374 <span className="block font-mono text-[10.5px] text-fg-subtle">{c.country} · {c.sources} source{c.sources === 1 ? "" : "s"}</span>375 </span>376 <span className="flex flex-col items-end font-mono text-[12px] tabular">377 <span>{fmtInt(c.events_24h)} <span className="text-[10px] text-fg-subtle">24 h</span></span>378 <span className={c.breaking_24h ? "text-hot" : "text-fg-subtle"}>{fmtInt(c.breaking_24h)} <span className="text-[10px] text-fg-subtle">brk</span></span>379 </span>380 </Link>381 </li>382 ))}383 </ul>384 )}385 </Panel>386 );387}388