spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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