SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
27.6 KB · 577 lines tsx
Raw Blame History
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