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%
4.9 KB · 93 lines tsx
Raw Blame History
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