spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1import { ExternalLink } from 'lucide-react';2import Link from 'next/link';3import { Bars } from '@/components/charts/bars';4import { EventList } from '@/components/events/event-row';5import { Chip, ConfidenceBadge, SensorTierBadge, StatusBadge, CountryChip } from '@/components/ui/badges';6import { LiveAgo } from '@/components/ui/live';7import { Empty, Note, Stat, StatGrid } from '@/components/ui/section';8import { cn } from '@/lib/cn';9import { countryName } from '@/lib/countries';10import { fmtDate, fmtDateTime, fmtDayLabel, fmtDuration, fmtInt, fmtPct, fmtPrice, fmtScore, humanize, pathOf, plural } from '@/lib/format';11import { mergePeople, roleRank, type MergedPerson } from '@/lib/profile';12import { routes, SURFACE_LABELS, TIMELINE_FILTERS } from '@/lib/site';13import type { HistoryPayload, Job, JobsPage, Location, Person, Plan, Product, Sensor, Signal, TimelinePayload } from '@/lib/types';14import { SourceChip, WikidataProducts } from './profile-panels';1516/* ------------------------------------------------------------------------------------------------------------ timeline */17export function TimelinePanel({ slug, data, filter }: { slug: string; data: TimelinePayload | null; filter: string }) {18 const groups = new Map<string, TimelinePayload['items']>();19 for (const e of data?.items ?? []) {20 const d = e.day ?? e.detected_at.slice(0, 10);21 if (!groups.has(d)) groups.set(d, []);22 groups.get(d)!.push(e);23 }24 const days = [...groups.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1));25 return (26 <div>27 <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-2 md:mx-0 md:flex-wrap md:px-0" role="tablist" aria-label="Timeline filter">28 {TIMELINE_FILTERS.map((f) => (29 <Link key={f} href={`${routes.company(slug, 'timeline')}${f === 'all' ? '' : `&filter=${f}`}`} scroll={false} className="chip-btn" data-on={filter === f} role="tab" aria-selected={filter === f}>30 {f === 'all' ? 'All' : humanize(f)}31 </Link>32 ))}33 </div>34 {!data ? (35 <Empty title="Timeline temporarily unavailable." />36 ) : days.length === 0 ? (37 <Empty>38 No {filter === 'all' ? '' : `${filter} `}events detected for this company yet — sensors are attached and observing.39 </Empty>40 ) : (41 <div className="mt-2 space-y-6">42 {days.map(([day, items]) => (43 <section key={day} aria-label={day}>44 <h3 className="sticky top-[calc(var(--header-h)+44px)] z-10 -mx-4 flex items-baseline gap-2 bg-canvas/95 px-4 py-1.5 text-sm font-semibold backdrop-blur md:static md:mx-0 md:px-0">45 {fmtDayLabel(day)} <span className="tnum text-xs font-normal text-ink-3">{items.length} {plural(items.length, 'event')}</span>46 </h3>47 <EventList events={items} variant="timeline" showCompany={false} />48 </section>49 ))}50 </div>51 )}52 </div>53 );54}5556/* ------------------------------------------------------------------------------------------------------------ signals */57export function SignalsPanel({ signals }: { signals: Signal[] }) {58 if (!signals.length) return <Empty title="No signals detected for this company yet.">Signals are pattern detections over the company’s own baselines and are labelled as such — never as facts.</Empty>;59 return (60 <ul className="divide-y divide-rule border-y border-rule">61 {signals.map((s) => (62 <li key={s.id} className="py-3">63 <div className="flex flex-wrap items-center gap-2">64 <Chip tone="accent">signal</Chip>65 <span className="text-[15px] font-medium text-ink">{s.title}</span>66 <span className="ml-auto text-xs text-ink-3">67 strength <span className="tnum text-ink-2">{Math.round(s.strength * 100)}</span> · confidence <span className="tnum text-ink-2">{Math.round(s.confidence * 100)} %</span> · {s.window_days} d window68 </span>69 </div>70 {s.explanation && <p className="mt-1 text-sm text-ink-2">{s.explanation}</p>}71 <p className="mt-1 text-[11px] text-ink-3">detected {fmtDateTime(s.detected_at)}</p>72 </li>73 ))}74 </ul>75 );76}7778/* ------------------------------------------------------------------------------------------------------------ jobs */79export function JobsPanel({ slug, data, status, ai }: { slug: string; data: JobsPage | null; status: string; ai: boolean }) {80 const summary = data?.meta?.summary ?? data?.summary;81 const base = routes.company(slug, 'jobs');82 const link = (patch: { status?: string; ai?: boolean }) => {83 const st = patch.status ?? status;84 const a = patch.ai ?? ai;85 return `${base}${st !== 'open' ? `&status=${st}` : ''}${a ? '&ai=1' : ''}`;86 };87 return (88 <div className="space-y-5">89 {summary ? (90 <StatGrid cols={5}>91 <Stat label="Open listings" value={fmtInt(summary.open)} size="sm" />92 <Stat label="New (7 d)" value={fmtInt(summary.new_7d)} size="sm" />93 <Stat label="No longer listed (7 d)" value={fmtInt(summary.removed_7d)} size="sm" />94 <Stat label="AI-related open" value={fmtInt(summary.ai_open)} size="sm" hint={summary.open ? `${Math.round((summary.ai_open / summary.open) * 100)} % of open` : undefined} />95 <Stat label="Remote share" value={summary.remote_ratio === null ? '—' : fmtPct(summary.remote_ratio, 0, true)} size="sm" />96 </StatGrid>97 ) : (98 <Note>Job summary unavailable.</Note>99 )}100 {summary && (summary.by_department.length > 0 || summary.by_country.length > 0) && (101 <div className="grid gap-6 md:grid-cols-2">102 <div>103 <p className="eyebrow mb-2">By department</p>104 <Bars dense rows={summary.by_department.slice(0, 8).map((d) => ({ key: d.department, label: d.department, value: d.n }))} />105 </div>106 <div>107 <p className="eyebrow mb-2">By country</p>108 <Bars dense rows={summary.by_country.slice(0, 8).map((d) => ({ key: d.country, label: countryName(d.country), value: d.n }))} />109 </div>110 </div>111 )}112 <div className="flex flex-wrap items-center gap-1.5">113 {[114 ['open', 'Open'],115 ['removed', 'No longer listed'],116 ['all', 'All'],117 ].map(([v, l]) => (118 <Link key={v} href={link({ status: v })} scroll={false} className="chip-btn" data-on={status === v}>119 {l}120 </Link>121 ))}122 <Link href={link({ ai: !ai })} scroll={false} className="chip-btn ml-2" data-on={ai} aria-pressed={ai}>123 AI-related only124 </Link>125 {data && <span className="tnum ml-auto text-xs text-ink-3">{fmtInt(data.total)} listings</span>}126 </div>127 {!data ? (128 <Empty title="Job listings temporarily unavailable." />129 ) : data.items.length === 0 ? (130 <Empty>No monitored listings match this filter.</Empty>131 ) : (132 <JobsTable items={data.items} />133 )}134 <Note>Counts reflect listings visible on the monitored careers surfaces only. A listing that is no longer visible is reported as “no longer listed” — it is not evidence of a hiring decision.</Note>135 </div>136 );137}138139function JobsTable({ items }: { items: Job[] }) {140 return (141 <div className="table-scroll">142 <table className="data-table">143 <thead>144 <tr>145 <th>Title</th>146 <th>Department</th>147 <th>Location</th>148 <th>Type</th>149 <th>First seen</th>150 <th>Status</th>151 </tr>152 </thead>153 <tbody>154 {items.map((j) => (155 <tr key={j.id}>156 <td className="primary wrap">157 {j.url ? (158 <a href={j.url} target="_blank" rel="noopener noreferrer" className="row-link inline-flex items-center gap-1">159 {j.title} <ExternalLink className="size-3 text-ink-3" aria-hidden />160 </a>161 ) : (162 j.title163 )}164 {j.is_ai && (165 <Chip tone="accent" className="ml-1.5">166 AI167 </Chip>168 )}169 </td>170 <td className="text-ink-2">{j.department ?? '—'}</td>171 <td className="text-ink-2">172 {j.location_text ?? '—'}173 {j.remote && <span className="ml-1 text-[11px] text-ink-3">· remote</span>}174 </td>175 <td className="text-xs text-ink-3">176 {j.employment_type ? humanize(j.employment_type) : '—'}177 {j.seniority ? ` · ${j.seniority}` : ''}178 </td>179 <td className="tnum text-xs text-ink-3">{fmtDate(j.first_seen_at)}</td>180 <td>181 <StatusBadge status={j.status} />182 {j.removed_at && <span className="ml-1 text-[11px] text-ink-3">{fmtDate(j.removed_at)}</span>}183 </td>184 </tr>185 ))}186 </tbody>187 </table>188 </div>189 );190}191192/* ------------------------------------------------------------------------------------------------------------ products */193export function ProductsPanel({ data, wikidataProducts = [], wikidataSource = null }: { data: { listed: Product[]; removed: Product[] } | null; wikidataProducts?: string[]; wikidataSource?: { url: string | null; retrieved_at: string } | null }) {194 if (!data) return <Empty title="Products temporarily unavailable." />;195 if (!data.listed.length && !data.removed.length) {196 if (wikidataProducts.length)197 return (198 <div className="space-y-4">199 <WikidataProducts products={wikidataProducts} source={wikidataSource} />200 <Empty compact title="No product catalogue surface has been reconciled for this company yet.">Once a catalogue page is monitored, listed products appear here with first-seen / last-seen dates.</Empty>201 </div>202 );203 return <Empty>No product catalog surface has been reconciled for this company yet.</Empty>;204 }205 const Row = ({ p }: { p: Product }) => (206 <li className="py-2.5">207 <div className="flex flex-wrap items-center gap-2">208 {p.url ? (209 <a href={p.url} target="_blank" rel="noopener noreferrer" className="text-[15px] font-medium text-ink hover:text-accent">210 {p.name}211 </a>212 ) : (213 <span className="text-[15px] font-medium text-ink">{p.name}</span>214 )}215 {p.category && <Chip>{p.category}</Chip>}216 <StatusBadge status={p.status} />217 <span className="ml-auto text-[11px] text-ink-3">218 first seen {fmtDate(p.first_seen_at)}219 {p.removed_at ? ` · no longer listed ${fmtDate(p.removed_at)}` : ` · last seen ${fmtDate(p.last_seen_at)}`}220 </span>221 </div>222 {p.description && <p className="mt-0.5 text-sm text-ink-2">{p.description}</p>}223 </li>224 );225 return (226 <div className="space-y-6">227 <div>228 <p className="eyebrow mb-1">229 Listed <span className="tnum normal-case tracking-normal">({data.listed.length})</span>230 </p>231 <ul className="divide-y divide-rule border-y border-rule">{data.listed.map((p) => <Row key={p.id} p={p} />)}</ul>232 </div>233 {data.removed.length > 0 && (234 <div>235 <p className="eyebrow mb-1">236 No longer listed <span className="tnum normal-case tracking-normal">({data.removed.length})</span>237 </p>238 <ul className="divide-y divide-rule border-y border-rule">{data.removed.map((p) => <Row key={p.id} p={p} />)}</ul>239 <Note className="mt-2">A product that disappears from the public catalog is recorded as “no longer listed”; the platform does not infer discontinuation without a first-party statement.</Note>240 </div>241 )}242 </div>243 );244}245246/* ------------------------------------------------------------------------------------------------------------ pricing */247export function PricingPanel({ data }: { data: { current: Plan[]; history: Plan[] } | null }) {248 if (!data) return <Empty title="Pricing temporarily unavailable." />;249 if (!data.current.length && !data.history.length) return <Empty>No public pricing page is monitored for this company yet.</Empty>;250 return (251 <div className="space-y-6">252 <div>253 <p className="eyebrow mb-2">Current plans</p>254 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">255 {data.current.map((p) => (256 <div key={p.id} className="border border-rule p-3">257 <p className="text-sm font-medium text-ink">{p.plan_name}</p>258 <p className="tnum mt-1 text-2xl font-semibold tracking-tight">{p.contact_sales ? <span className="text-base font-medium text-ink-2">Contact sales</span> : fmtPrice(p.price, p.currency, p.price_text)}</p>259 {!p.contact_sales && (260 <p className="text-xs text-ink-3">261 {p.billing_period ? `per ${p.billing_period}` : ''}262 {p.unit ? ` · per ${p.unit}` : ''}263 </p>264 )}265 {p.features.length > 0 && (266 <ul className="mt-2 space-y-0.5 text-xs text-ink-2">267 {p.features.slice(0, 6).map((f) => (268 <li key={f}>· {f}</li>269 ))}270 </ul>271 )}272 <p className="mt-2 text-[11px] text-ink-3">273 v{p.version_no} · since {fmtDate(p.valid_from)}274 </p>275 </div>276 ))}277 </div>278 </div>279 {data.history.length > 0 && (280 <div>281 <p className="eyebrow mb-2">Version history</p>282 <div className="table-scroll">283 <table className="data-table">284 <thead>285 <tr>286 <th>Plan</th>287 <th className="num">Version</th>288 <th className="num">Price</th>289 <th>Billing</th>290 <th>Valid from</th>291 <th>Valid to</th>292 <th>Source</th>293 </tr>294 </thead>295 <tbody>296 {[...data.history].sort((a, b) => b.valid_from.localeCompare(a.valid_from)).map((p) => (297 <tr key={p.id}>298 <td className="primary">{p.plan_name}</td>299 <td className="num tnum">v{p.version_no}</td>300 <td className="num tnum">{p.contact_sales ? 'Contact sales' : fmtPrice(p.price, p.currency, p.price_text)}</td>301 <td className="text-ink-2">{p.billing_period ?? '—'}</td>302 <td className="tnum text-xs">{fmtDate(p.valid_from)}</td>303 <td className="tnum text-xs">{p.valid_to ? fmtDate(p.valid_to) : '—'}</td>304 <td>305 {p.source_url ? (306 <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="link text-xs">307 {pathOf(p.source_url)}308 </a>309 ) : (310 '—'311 )}312 </td>313 </tr>314 ))}315 </tbody>316 </table>317 </div>318 <Note className="mt-2">Every version of every plan is preserved; a price change creates a new version rather than overwriting the previous one.</Note>319 </div>320 )}321 </div>322 );323}324325/* ------------------------------------------------------------------------------------------------------------ locations */326export function LocationsPanel({ data, map }: { data: { items: Location[]; countries: string[] } | null; map?: React.ReactNode }) {327 if (!data) return <Empty title="Locations temporarily unavailable." />;328 if (!data.items.length) return <Empty>No locations surface has been reconciled for this company yet.</Empty>;329 const listed = data.items.filter((l) => l.status === 'listed');330 const gone = data.items.filter((l) => l.status !== 'listed');331 return (332 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">333 <div>334 <p className="eyebrow mb-1">335 Listed <span className="tnum normal-case tracking-normal">({listed.length} in {data.countries.length} {plural(data.countries.length, 'country', 'countries')})</span>336 </p>337 <ul className="divide-y divide-rule border-y border-rule text-sm">338 {listed.map((l) => (339 <li key={l.id} className="flex flex-wrap items-center gap-2 py-2">340 <Chip>{l.kind}</Chip>341 <span className="text-ink">{[l.city, l.region].filter(Boolean).join(', ') || l.name}</span>342 <CountryChip code={l.country} name={countryName(l.country)} />343 <span className="ml-auto text-[11px] text-ink-3">since {fmtDate(l.first_seen_at)}</span>344 </li>345 ))}346 </ul>347 {gone.length > 0 && (348 <>349 <p className="eyebrow mb-1 mt-4">No longer listed ({gone.length})</p>350 <ul className="divide-y divide-rule border-y border-rule text-sm text-ink-2">351 {gone.map((l) => (352 <li key={l.id} className="flex flex-wrap items-center gap-2 py-2">353 <Chip>{l.kind}</Chip>354 <span>{[l.city, l.region].filter(Boolean).join(', ') || l.name}</span>355 <CountryChip code={l.country} />356 <span className="ml-auto text-[11px] text-ink-3">until {fmtDate(l.removed_at)}</span>357 </li>358 ))}359 </ul>360 </>361 )}362 <Note className="mt-2">Only cities/regions/countries are recorded; exact street addresses are never inferred.</Note>363 </div>364 <div className="min-w-0">{map}</div>365 </div>366 );367}368369/* ------------------------------------------------------------------------------------------------------------ leadership */370/**371 * Page-observed people and Wikidata-sourced executives merged by name (both chips kept when they agree), CEO / chair /372 * founders first. The "no longer listed" group stays separate with wording that depends on the source.373 */374export function PeoplePanel({ data }: { data: { listed: Person[]; no_longer_listed: Person[] } | null }) {375 if (!data) return <Empty title="Leadership temporarily unavailable." />;376 if (!data.listed.length && !data.no_longer_listed.length) return <Empty>No leadership page is monitored for this company yet, and Wikidata records no current executives for it.</Empty>;377 const listed = mergePeople(data.listed);378 const gone = mergePeople(data.no_longer_listed);379 const nPage = listed.filter((p) => p.sources.includes('page')).length;380 const nWd = listed.filter((p) => p.sources.includes('wikidata')).length;381 const Row = ({ p }: { p: MergedPerson }) => {382 const onlyWd = p.sources.length === 1 && p.sources[0] === 'wikidata';383 const when = p.status === 'listed' ? (onlyWd ? `recorded on Wikidata · retrieved ${fmtDate(p.last_seen_at)}` : `listed since ${fmtDate(p.first_seen_at)}`) : onlyWd ? `no longer recorded as current on Wikidata · ${fmtDate(p.removed_at ?? p.last_seen_at)}` : `no longer listed since ${fmtDate(p.removed_at ?? p.last_seen_at)}`;384 return (385 <li className="flex flex-wrap items-center gap-x-2 gap-y-1 py-2 text-sm" data-person-source={p.sources.join('+')}>386 <span className="font-medium text-ink">{p.name}</span>387 <span className="text-ink-2">{p.title ?? 'title not stated'}</span>388 {p.is_executive && roleRank(p.title) > 3 && <Chip>executive</Chip>}389 {p.sources.map((s) => (390 <SourceChip key={s} source={s} url={s === 'page' ? p.source_url : null} retrievedAt={p.last_seen_at} />391 ))}392 <span className="ml-auto text-[11px] text-ink-3">393 {when}394 {p.source_url && !onlyWd && (395 <>396 {' · '}397 <a href={p.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">398 source ↗399 </a>400 </>401 )}402 {p.source_url && onlyWd && (403 <>404 {' · '}405 <a href={p.source_url} target="_blank" rel="noopener nofollow noreferrer" className="hover:text-accent">406 statement ↗407 </a>408 </>409 )}410 </span>411 </li>412 );413 };414 return (415 <div className="space-y-6">416 <div>417 <p className="eyebrow mb-1 flex flex-wrap items-baseline gap-x-2">418 Currently listed <span className="tnum normal-case tracking-normal">({listed.length})</span>419 <span className="tnum font-normal normal-case tracking-normal text-ink-3">420 {nPage > 0 && `${nPage} on the monitored leadership page`}421 {nPage > 0 && nWd > 0 && ' · '}422 {nWd > 0 && `${nWd} recorded on Wikidata`}423 </span>424 </p>425 {listed.length ? <ul className="divide-y divide-rule border-y border-rule">{listed.map((p) => <Row key={p.id} p={p} />)}</ul> : <Empty compact title="No one is currently listed." />}426 </div>427 {gone.length > 0 && (428 <div>429 <p className="eyebrow mb-1">430 No longer listed <span className="tnum normal-case tracking-normal">({gone.length})</span>431 </p>432 <ul className="divide-y divide-rule border-y border-rule">{gone.map((p) => <Row key={p.id} p={p} />)}</ul>433 </div>434 )}435 <Note>436 Leadership data is limited to public professional context: the company’s own leadership page (observed by a sensor) and Wikidata position statements (with their retrieval time). A profile that disappears from the page is “no longer listed on the monitored leadership page”; a Wikidata position with an end date is “no longer recorded as current” — the platform never states why.437 </Note>438 </div>439 );440}441442/* ------------------------------------------------------------------------------------------------------------ sensors */443export function SensorsTable({ items, className, withCompany = false }: { items: (Sensor & { company?: { slug: string; display_name: string } })[]; className?: string; withCompany?: boolean }) {444 if (!items.length) return <Empty>No sensors attached yet.</Empty>;445 return (446 <div className={cn('table-scroll', className)}>447 <table className="data-table">448 <thead>449 <tr>450 {withCompany && <th>Company</th>}451 <th>Surface</th>452 <th>URL</th>453 <th>Connector</th>454 <th>Status</th>455 <th>Tier</th>456 <th className="num">Quality</th>457 <th>Last checked</th>458 <th className="num">Obs.</th>459 <th className="num">Changes</th>460 <th className="num">Events</th>461 </tr>462 </thead>463 <tbody>464 {items.map((s) => {465 const stale = s.last_success_at && Date.now() - new Date(s.last_success_at).getTime() > 2 * 86_400_000;466 return (467 <tr key={s.id}>468 {withCompany && <td className="primary">{s.company ? <Link href={routes.company(s.company.slug)} className="row-link">{s.company.display_name}</Link> : '—'}</td>}469 <td className="primary">470 <Link href={routes.sensor(s.id)} className="row-link">471 {SURFACE_LABELS[s.surface] ?? s.surface}472 </Link>473 </td>474 <td className="mono text-xs text-ink-2">475 <a href={s.url} target="_blank" rel="noopener noreferrer" className="hover:text-accent">476 {pathOf(s.url)}477 </a>478 </td>479 <td className="mono text-xs text-ink-3">{s.connector_id}</td>480 <td>481 <StatusBadge status={s.status} />482 {s.last_failure_class && <span className="mono ml-1 text-[10px] text-danger">{s.last_failure_class}</span>}483 </td>484 <td>485 <SensorTierBadge tier={s.tier} />486 </td>487 <td className="num tnum">{fmtScore(s.quality_score)}</td>488 <td className={cn('text-xs', stale ? 'text-warning' : 'text-ink-3')}>489 <LiveAgo at={s.last_success_at ?? s.last_run_at} tick={30000} absoluteFallback={false} prefix={stale ? 'last success ' : ''} />490 </td>491 <td className="num tnum">{fmtInt(s.observation_count)}</td>492 <td className="num tnum">{fmtInt(s.change_count)}</td>493 <td className="num tnum">{fmtInt(s.event_count)}</td>494 </tr>495 );496 })}497 </tbody>498 </table>499 </div>500 );501}502503/* ------------------------------------------------------------------------------------------------------------ history viewer */504export function HistoryPanel({ data }: { data: HistoryPayload | null }) {505 if (!data) return <Empty title="Historical page viewer temporarily unavailable." />;506 const sensors = data.sensors.filter((s) => s.versions.length > 0);507 if (!sensors.length) return <Empty>No snapshot versions stored yet.</Empty>;508 return (509 <div className="space-y-5">510 <Note>Every monitored page keeps its normalised versions. Open a version to read it as observed, or diff any two versions block by block.</Note>511 {sensors.map((s) => (512 <details key={s.id} className="border-y border-rule py-2" open={sensors.length <= 3}>513 <summary className="flex cursor-pointer flex-wrap items-center gap-2 py-1 text-sm">514 <span className="font-medium text-ink">{SURFACE_LABELS[s.surface] ?? s.surface}</span>515 <span className="mono text-xs text-ink-3">{pathOf(s.url)}</span>516 <SensorTierBadge tier={s.tier} />517 <span className="tnum ml-auto text-xs text-ink-3">518 {s.versions.length} {plural(s.versions.length, 'version')} · every {fmtDuration(s.current_interval_s)}519 </span>520 </summary>521 <div className="table-scroll mt-1">522 <table className="data-table compact">523 <thead>524 <tr>525 <th className="num">v</th>526 <th>Fetched</th>527 <th>Title</th>528 <th className="num">Blocks</th>529 <th className="num">Text</th>530 <th>Hash</th>531 <th>Diff</th>532 </tr>533 </thead>534 <tbody>535 {s.versions.map((v, i) => {536 const prev = s.versions[i + 1];537 return (538 <tr key={v.id}>539 <td className="num tnum">{v.version_no}</td>540 <td className="tnum text-xs">541 <Link href={routes.snapshot(v.id)} className="link">542 {fmtDateTime(v.fetched_at)}543 </Link>544 </td>545 <td className="wrap text-ink-2">{v.title ?? '—'}</td>546 <td className="num tnum">{fmtInt(v.block_count)}</td>547 <td className="num tnum">{fmtInt(v.text_length)}</td>548 <td className="mono text-[11px] text-ink-3">{v.content_hash.replace('sha256:', '').slice(0, 10)}</td>549 <td className="text-xs">550 {prev ? (551 <Link href={routes.snapshotDiff(prev.id, v.id)} className="link">552 vs v{prev.version_no}553 </Link>554 ) : (555 <span className="text-ink-3">first version</span>556 )}557 </td>558 </tr>559 );560 })}561 </tbody>562 </table>563 </div>564 </details>565 ))}566 </div>567 );568}569570export function ConfidenceLegend() {571 return (572 <p className="flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">573 Confidence labels: {['VERIFIED', 'HIGH_CONFIDENCE', 'LIKELY', 'INFERRED', 'LOW_CONFIDENCE'].map((l) => <ConfidenceBadge key={l} label={l} />)}574 </p>575 );576}577