SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
19.8 KB · 351 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';7import { SourceBadge } from '@/components/ui/source-badge';8import { recentApprovals, newPhase3Recruiting, trialPulseByPhase, newTrialsByCancer, rankingMoves, recentPublications, recentIngests, recentEvents } from '@/lib/queries/pulse';9import { fmtDate, fmtInt, fmtValue, humanize, phaseLabel, scopeLabel, truncate } from '@/lib/format';1011export const metadata: Metadata = { title: 'Pulse — what changed in cancer', description: 'New oncology approvals, newly recruiting Phase III trials, registration momentum, ranking moves, recent publications and dataset updates — every item is a dated source record.' };12export const revalidate = 900;1314const APPROVAL_DAYS = 90;15const TRIAL_DAYS = 30;16const PUB_DAYS = 60;1718function Delta({ current, previous }: { current: number; previous: number }) {19  if (previous === 0) return <span className="text-ink-4" title="No studies in the previous window">—</span>;20  const pct = ((current - previous) / previous) * 100;21  const cls = pct > 0 ? 'text-ok' : pct < 0 ? 'text-danger' : 'text-ink-3';22  return (23    <span className={`ci-num ${cls}`} title={`${fmtInt(current)} vs ${fmtInt(previous)} in the previous ${TRIAL_DAYS} days`}>24      {pct > 0 ? '+' : ''}25      {pct.toFixed(0)} %26    </span>27  );28}2930export default async function PulsePage() {31  const [approvals, phase3, pulse, byCancer, moves, pubs, ingests, events] = await Promise.all([recentApprovals(APPROVAL_DAYS, 40), newPhase3Recruiting(TRIAL_DAYS, 25), trialPulseByPhase(TRIAL_DAYS), newTrialsByCancer(TRIAL_DAYS, 10), rankingMoves(3, 20), recentPublications(PUB_DAYS, 15), recentIngests(14, 60), recentEvents(30, 30)]);32  const all = pulse.rows.find((r) => r.phase === 'ALL');33  const byAuthority = new Map<string, number>();34  for (const a of approvals) byAuthority.set(`${a.authority} · ${a.jurisdiction}`, (byAuthority.get(`${a.authority} · ${a.jurisdiction}`) ?? 0) + 1);35  const lastIngest = ingests.find((r) => r.status === 'succeeded' || r.status === 'partial');3637  return (38    <div className="pb-8">39      <PageHeader kicker="Pulse" title="What changed in cancer" lede="A dated feed assembled from records already in the index: regulatory decisions, newly registered Phase III studies, registration momentum, ranking moves, recent publications and dataset refreshes. Nothing here is written by a model; every line links to its source record.">40        <p className="mt-3 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">41          <ClaimBadge kind="observed" /> <ClaimBadge kind="regulatory" /> <ClaimBadge kind="computed" />42          <span>43            Windows: approvals {APPROVAL_DAYS} days · trials {TRIAL_DAYS} days · publications {PUB_DAYS} days · datasets 14 days.44          </span>45          <Link href="/data-updates" className="ci-link">46            Data update log →47          </Link>48          <Link href={`/year/${new Date().getUTCFullYear()}`} className="ci-link">49            Year in cancer →50          </Link>51        </p>52      </PageHeader>5354      <div className="grid gap-10 lg:grid-cols-[1.35fr_1fr]">55        <div className="space-y-10">56          <Section id="approvals" kicker="Regulatory" title={`New oncology approval records (${fmtInt(approvals.length)})`} description={`Approval and marketing records dated within the last ${APPROVAL_DAYS} days, all authorities ingested. One line is one authority's decision for one application or product identifier; the cancer is shown only when the record names exactly one.`} actions={<Link href="/approvals" className="ci-link">All approvals →</Link>}>57            {approvals.length ? (58              <>59                <p className="mb-2 flex flex-wrap gap-1.5 text-[12px]">60                  {[...byAuthority.entries()].map(([k, n]) => (61                    <Badge key={k} tone="outline">62                      {k}: {fmtInt(n)}63                    </Badge>64                  ))}65                </p>66                <ul className="divide-y divide-rule text-[13.5px]">67                  {approvals.map((a) => (68                    <li key={a.id} className="grid gap-x-3 gap-y-0.5 py-2 sm:grid-cols-[92px_1fr]">69                      <span className="ci-mono text-[12px] text-ink-3">{fmtDate(a.approval_date)}</span>70                      <span className="min-w-0">71                        <Link href={`/drug/${a.drug_slug}`} className="ci-link font-medium">72                          {a.drug_name}73                        </Link>{' '}74                        <Badge tone="outline">75                          {a.authority} · {a.jurisdiction}76                        </Badge>{' '}77                        <StatusBadge status={a.status} />{' '}78                        {a.cancer_slug ? (79                          <Link href={`/cancer/${a.cancer_slug}`} className="ci-link">80                            {a.cancer_name}81                          </Link>82                        ) : a.tumor_agnostic ? (83                          <Badge tone="accent">tumor-agnostic</Badge>84                        ) : (85                          <span className="text-ink-3">cancer not stated in this record</span>86                        )}87                        <span className="mt-0.5 block text-[12.5px] text-ink-2" title={a.indication}>88                          {truncate(a.indication, 180)}89                        </span>90                        <span className="mt-0.5 flex items-center gap-1.5 text-[11.5px] text-ink-3">91                          {a.approval_type ? <span>{a.approval_type}</span> : null}92                          <SourceBadge p={{ sourceSlug: a.source_slug }} compact />93                        </span>94                      </span>95                    </li>96                  ))}97                </ul>98              </>99            ) : (100              <EmptyState compact title="No approval record dated in this window">Approval feeds are refreshed weekly (openFDA, Health Canada DPD); records appear here as soon as they carry a date within the window.</EmptyState>101            )}102          </Section>103104          <Section id="phase3" kicker="Clinical research" title={`Newly registered Phase III studies now recruiting (${fmtInt(phase3.length)})`} description={`Interventional studies with Phase III among their phases, first posted on ClinicalTrials.gov in the last ${TRIAL_DAYS} days and recruiting. Cancer = first reconciled condition.`} actions={<Link href="/trials?status=RECRUITING&phase=PHASE3" className="ci-link">All recruiting Phase III →</Link>}>105            {phase3.length ? (106              <div className="ci-table-wrap">107                <table className="ci-table">108                  <thead>109                    <tr>110                      <th>First posted</th>111                      <th>NCT</th>112                      <th>Title</th>113                      <th>Cancer</th>114                      <th>Sponsor</th>115                      <th className="num">Enrollment (n)</th>116                    </tr>117                  </thead>118                  <tbody>119                    {phase3.map((t) => (120                      <tr key={t.id}>121                        <td className="whitespace-nowrap text-[12.5px]">{fmtDate(t.first_posted_date)}</td>122                        <td>123                          <Link href={`/trial/${t.nct_id}`} className="ci-mono ci-link">124                            {t.nct_id}125                          </Link>126                        </td>127                        <td className="min-w-[260px] max-w-[460px]">128                          <Link href={`/trial/${t.nct_id}`} className="text-ink no-underline hover:text-accent">129                            {t.brief_title}130                          </Link>131                          {t.acronym ? <span className="ml-1 text-[12px] text-ink-3">({t.acronym})</span> : null}132                        </td>133                        <td>{t.cancer_slug ? <Link href={`/cancer/${t.cancer_slug}`} className="ci-link">{t.cancer_name}</Link> : <span className="text-ink-4">unmapped</span>}</td>134                        <td className="max-w-[200px] truncate text-[12.5px]" title={t.lead_sponsor ?? ''}>135                          {t.lead_sponsor ?? '—'}136                          {t.lead_sponsor_class === 'INDUSTRY' ? <span className="ml-1 text-[11px] text-ink-3">industry</span> : null}137                        </td>138                        <td className="num">{t.enrollment_count == null ? '—' : fmtInt(t.enrollment_count)}</td>139                      </tr>140                    ))}141                  </tbody>142                </table>143              </div>144            ) : (145              <EmptyState compact title="No Phase III study first posted in this window">The ClinicalTrials.gov connector runs daily; newly posted studies appear after the next run.</EmptyState>146            )}147            <Freshness dataUpdatedAt={phase3[0]?.updated_at ?? null} extra="source: clinicaltrials · first_posted_date as posted by the registrant" />148          </Section>149150          <Section id="moves" kicker="Rankings" title="Largest ranking moves" description="Top-level cancers whose rank changed by three places or more between the current snapshot and the previous one of the same metric and scope. A move usually follows a dataset refresh (new registry year, new trials) or a formula change; the ranking page explains each rank.">151            {moves.length ? (152              <div className="ci-table-wrap">153                <table className="ci-table">154                  <thead>155                    <tr>156                      <th>Metric</th>157                      <th>Scope</th>158                      <th>Cancer</th>159                      <th className="num">Rank</th>160                      <th className="num">Previous</th>161                      <th className="num">Value</th>162                      <th>Snapshot</th>163                    </tr>164                  </thead>165                  <tbody>166                    {moves.map((m) => (167                      <tr key={`${m.metric_slug}-${m.scope_key}-${m.cancer_id}`}>168                        <td>169                          <Link href={`/rankings/${m.metric_slug}?scope=${encodeURIComponent(m.scope_key)}`} className="ci-link">170                            {m.metric_name}171                          </Link>172                        </td>173                        <td className="text-[12px] text-ink-3">{scopeLabel(m.scope_key)}</td>174                        <td>175                          <Link href={`/cancer/${m.slug}/rankings`} className="ci-link">176                            {m.canonical_name}177                          </Link>178                        </td>179                        <td className="num font-medium">{m.rank}</td>180                        <td className="num">181                          {m.previous_rank} <span className={m.previous_rank > m.rank ? 'text-ok' : 'text-danger'}>{m.previous_rank > m.rank ? '▲' : '▼'}</span>182                        </td>183                        <td className="num">{fmtValue(m.value, m.unit)}</td>184                        <td className="whitespace-nowrap text-[12px] text-ink-3">{fmtDate(m.generated_at)}</td>185                      </tr>186                    ))}187                  </tbody>188                </table>189              </div>190            ) : (191              <EmptyState compact title="No ranking moved by three places or more">Ranks are compared with the previous snapshot of the same metric and scope; the first snapshot has no previous rank.</EmptyState>192            )}193          </Section>194195          <Section id="publications" kicker="Literature" title={`Recently published records indexed (${fmtInt(pubs.length)})`} description={`PubMed records with a publication date in the last ${PUB_DAYS} days already linked to an index entity; randomized trials, systematic reviews and meta-analyses first. Publication dates are as recorded by PubMed.`}>196            {pubs.length ? (197              <ul className="divide-y divide-rule text-[13.5px]">198                {pubs.map((p) => (199                  <li key={p.id} className="grid gap-x-3 py-2 sm:grid-cols-[92px_1fr]">200                    <span className="ci-mono text-[12px] text-ink-3">{fmtDate(p.pub_date)}</span>201                    <span className="min-w-0">202                      <Link href={p.pmid ? `/publication/${p.pmid}` : '#'} className="ci-link">203                        {p.title}204                      </Link>205                      <span className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11.5px] text-ink-3">206                        {p.journal ? <span>{p.journal}</span> : null}207                        {p.publication_types.filter((t) => /trial|review|meta/i.test(t)).slice(0, 2).map((t) => (208                          <Badge key={t} tone="outline">209                            {t}210                          </Badge>211                        ))}212                        {p.pmid ? <span className="ci-mono">PMID {p.pmid}</span> : null}213                      </span>214                    </span>215                  </li>216                ))}217              </ul>218            ) : (219              <EmptyState compact title="No publication dated in this window">Literature is linked through entity queries (PubMed) and connector references; recent records appear after the nightly PubMed run.</EmptyState>220            )}221          </Section>222        </div>223224        <aside className="space-y-10">225          <Section id="registrations" kicker="Momentum" title="Trial registrations, last 30 days" description="New oncology studies first posted on ClinicalTrials.gov per phase, compared with the preceding 30 days (all study types; a study with two phases counts in both).">226            {pulse.rows.length ? (227              <>228                {all ? (229                  <p className="mb-2 text-[13.5px]">230                    <span className="ci-num font-display text-2xl text-ink">{fmtInt(all.current)}</span> <span className="text-ink-2">new studies</span> <Delta current={all.current} previous={all.previous} />231                  </p>232                ) : null}233                <div className="ci-table-wrap">234                  <table className="ci-table">235                    <thead>236                      <tr>237                        <th>Phase</th>238                        <th className="num">Last 30 d</th>239                        <th className="num">Previous 30 d</th>240                        <th className="num">Change</th>241                      </tr>242                    </thead>243                    <tbody>244                      {pulse.rows245                        .filter((r) => r.phase !== 'ALL')246                        .map((r) => (247                          <tr key={r.phase}>248                            <td>{r.phase === 'NA' ? 'Not applicable' : phaseLabel(r.phase)}</td>249                            <td className="num">{fmtInt(r.current)}</td>250                            <td className="num">{fmtInt(r.previous)}</td>251                            <td className="num">252                              <Delta current={r.current} previous={r.previous} />253                            </td>254                          </tr>255                        ))}256                    </tbody>257                  </table>258                </div>259                <Freshness dataUpdatedAt={pulse.asOf} extra="trial_pulse · ci-trial-pulse-v1 · source: clinicaltrials" />260              </>261            ) : (262              <EmptyState compact title="Registration pulse not yet computed" />263            )}264          </Section>265266          <Section id="by-cancer" kicker="Momentum" title="Most new studies by cancer" description="Top-level cancers by studies first posted in the last 30 days (conditions reconciled to the taxonomy, descendants included), with the preceding 30 days.">267            {byCancer.length ? (268              <div className="ci-table-wrap">269                <table className="ci-table">270                  <thead>271                    <tr>272                      <th>Cancer</th>273                      <th className="num">Last 30 d</th>274                      <th className="num">Previous</th>275                    </tr>276                  </thead>277                  <tbody>278                    {byCancer.map((c) => (279                      <tr key={c.id}>280                        <td>281                          <Link href={`/cancer/${c.slug}/trials`} className="ci-link">282                            {c.canonical_name}283                          </Link>284                        </td>285                        <td className="num">{fmtInt(c.current)}</td>286                        <td className="num text-ink-3">{fmtInt(c.previous)}</td>287                      </tr>288                    ))}289                  </tbody>290                </table>291              </div>292            ) : (293              <EmptyState compact title="Data not yet available" />294            )}295          </Section>296297          <Section id="datasets" kicker="Data" title="Dataset refreshes, last 14 days" description="Connector runs (excluding dry runs and probes) with the number of source records created or updated and the dataset version reported by the source." actions={<Link href="/data-updates" className="ci-link">Full log →</Link>}>298            {ingests.length ? (299              <ul className="divide-y divide-rule text-[13px]">300                {ingests.slice(0, 12).map((r) => (301                  <li key={r.id} className="flex flex-wrap items-baseline justify-between gap-x-3 py-1.5">302                    <span className="min-w-0">303                      <Link href={`/source/${r.connector_id}`} className="ci-link">304                        {r.source_name ?? r.connector_id}305                      </Link>{' '}306                      <StatusBadge status={r.status} />307                      {r.dataset_version ? <span className="ci-mono ml-1 text-[11px] text-ink-3">{r.dataset_version}</span> : null}308                    </span>309                    <span className="ci-num text-[12px] text-ink-3">310                      {fmtDate(r.started_at)} · +{fmtInt(r.records_created)} / ~{fmtInt(r.records_updated)}311                    </span>312                  </li>313                ))}314              </ul>315            ) : (316              <EmptyState compact title="No connector run in the last 14 days" />317            )}318            <Freshness dataUpdatedAt={lastIngest?.finished_at ?? null} extra="ingest_runs" />319          </Section>320321          <Section id="events" kicker="Changes" title="Entity change events" description="Non-bulk changes recorded by connectors in the last 30 days (approvals added, aliases, merges, deprecations).">322            {events.length ? (323              <ul className="divide-y divide-rule text-[13px]">324                {events.slice(0, 20).map((e) => (325                  <li key={e.id} className="py-1.5">326                    <span className="ci-mono mr-2 text-[11.5px] text-ink-3">{fmtDate(e.created_at)}</span>327                    <Badge tone="outline">{humanize(e.kind)}</Badge>{' '}328                    {e.entity_href ? (329                      <Link href={e.entity_href} className="ci-link">330                        {e.entity_name ?? e.entity_id}331                      </Link>332                    ) : (333                      <span>{e.entity_name ?? e.entity_id}</span>334                    )}335                    <span className="block text-[12px] text-ink-2">{truncate(e.summary, 160)}</span>336                  </li>337                ))}338              </ul>339            ) : (340              <EmptyState compact title="No non-bulk change event in the last 30 days" />341            )}342          </Section>343        </aside>344      </div>345      <div className="mt-8">346        <Note>Guideline changes, safety communications and trial-result events are not yet connected (no guideline or pharmacovigilance connector is live); this page will list them as event types once a licensed source is ingested. Nothing is inferred from press releases.</Note>347      </div>348    </div>349  );350}351