spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { Section, Note } from '@/components/ui/section';2import { EmptyState } from '@/components/ui/empty-state';3import { Freshness } from '@/components/ui/freshness';4import { ClaimBadge } from '@/components/ui/badge';5import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';6import { survivalFor } from '@/lib/queries/epidemiology';7import { loadProvenance, toInfo } from '@/lib/queries/provenance';8import { fmtInt, fmtPct, fmtNum, humanize } from '@/lib/format';9import type { CancerBundle } from '../load';1011export async function SurvivalTab({ b }: { b: CancerBundle }) {12 const rows = await survivalFor(b.cancer.id);13 if (rows.length === 0) {14 return (15 <Section id="survival" kicker="Survival" title="Population survival">16 <EmptyState knows={[{ label: 'Statistics tab', href: `/cancer/${b.cancer.slug}/statistics` }, { label: 'Methodology: 5-year relative survival', href: '/methodology#five_year_survival' }]}>17 No survival observation is attached. Survival statistics require stage, staging system, diagnosis period, survival type and population — they are only imported with that full context (SEER, once credentials and terms are settled).18 </EmptyState>19 </Section>20 );21 }22 const prov = await loadProvenance(rows.map((r) => r.provenance_id));23 const groups = new Map<string, typeof rows>();24 for (const r of rows) {25 const k = `${r.survival_type}|${r.stage ?? 'all stages'}|${r.geography_name ?? ''}`;26 if (!groups.has(k)) groups.set(k, []);27 groups.get(k)!.push(r);28 }29 return (30 <div className="space-y-8">31 <Note tone="warn">Population survival is not an individual prognosis. Figures apply to the cohort, period, stage and population declared by the source; treatment landscapes change after the diagnosis period shown.</Note>32 {[...groups.entries()].map(([k, rs]) => {33 const f = rs[0]!;34 const firstProv = toInfo(prov.get(f.provenance_id)) ?? { sourceSlug: f.source_slug, sourceName: f.source_name };35 return (36 <Section key={k} id={k.replace(/[^a-z0-9]+/gi, '-')} kicker={f.geography_name ?? 'Population'} title={`${humanize(f.survival_type)} survival${f.stage ? ` — ${f.stage}` : ' — all stages'}`} description={f.staging_system ? `Staging system: ${f.staging_system}` : undefined}>37 <TableProvenance p={firstProv} claim={<ClaimBadge kind="observed" />}>38 {rs.length} observation{rs.length === 1 ? '' : 's'} · diagnosis period, follow-up and method as published; hover a row badge for its dataset and version.39 </TableProvenance>40 <div className="ci-table-wrap">41 <table className="ci-table">42 <thead>43 <tr>44 <th scope="col">Diagnosis period</th>45 <th scope="col">Sex</th>46 <th scope="col">Age group</th>47 <th scope="col" className="num">48 Follow-up (months)49 </th>50 <th scope="col" className="num">51 Survival (%)52 </th>53 <th scope="col" className="num">54 95% CI55 </th>56 <th scope="col" className="num">57 Median (months)58 </th>59 <th scope="col" className="num">60 Cohort (n)61 </th>62 <th scope="col">Method</th>63 <th scope="col">Source</th>64 </tr>65 </thead>66 <tbody>67 {rs.map((r) => (68 <tr key={r.id}>69 <td>{r.diagnosis_period ?? '—'}</td>70 <td>{humanize(r.sex)}</td>71 <td>{r.age_group === 'all' ? 'All ages' : r.age_group}</td>72 <td className="num">{r.duration_months}</td>73 <td className="num font-medium">{fmtPct(r.probability)}</td>74 <td className="num text-ink-3">{r.lower_ci != null && r.upper_ci != null ? `${fmtPct(r.lower_ci)}–${fmtPct(r.upper_ci)}` : '—'}</td>75 <td className="num">{r.median_months != null ? fmtNum(r.median_months, 1) : '—'}</td>76 <td className="num">{r.cohort_size != null ? fmtInt(r.cohort_size) : '—'}</td>77 <td className="text-[12px] text-ink-3">{r.method ?? '—'}</td>78 <td>79 <SourceBadge compact p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} />80 </td>81 </tr>82 ))}83 </tbody>84 </table>85 </div>86 <Freshness dataUpdatedAt={rs[0]!.updated_at} sourceVersion={prov.get(rs[0]!.provenance_id)?.dataset_version ?? null} />87 </Section>88 );89 })}90 </div>91 );92}93