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%

Web: latest-year rankings per scope, statistics table trimmed; gap-metric caveats

Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent 4419793

3 changed files +21 −5

modified apps/web/src/components/cancer/tabs/rankings.tsx +13 −2
@@ -19,7 +19,18 @@ function Delta({ rank, prev }: { rank: number; prev: number | null }) {
19 19 }
20 20
21 21 export async function RankingsTab({ b }: { b: CancerBundle }) {
22 − const rows = await rankingsForCancer(b.cancer.id);
22 + const allRows = await rankingsForCancer(b.cancer.id);
23 + // One row per (metric, geography, sex, age, level): the latest year only. Historical years stay on the ranking pages.
24 + const latestByScope = new Map<string, (typeof allRows)[number]>();
25 + for (const r of allRows) {
26 + const year = Number(/year=(\d{4})/.exec(r.scope_key)?.[1] ?? 0);
27 + const key = `${r.metric_slug}|${r.scope_key.replace(/year=[^|]*/, '')}`;
28 + const cur = latestByScope.get(key);
29 + const curYear = cur ? Number(/year=(\d{4})/.exec(cur.scope_key)?.[1] ?? 0) : -1;
30 + if (!cur || year > curYear) latestByScope.set(key, r);
31 + }
32 + const rows = [...latestByScope.values()];
33 + const hiddenYears = allRows.length - rows.length;
23 34 if (rows.length === 0) {
24 35 return (
25 36 <Section id="rankings" kicker="Rankings" title="Why this rank?">
@@ -31,7 +42,7 @@ export async function RankingsTab({ b }: { b: CancerBundle }) {
31 42 }
32 43 return (
33 44 <div className="space-y-6">
34 − <Section id="rankings" kicker="Rankings" title="Why this rank?" description={`${fmtInt(rows.length)} current ranking rows. Each row lists the scope, the formula version, the value and the exact inputs that produced it.`}>
45 + <Section id="rankings" kicker="Rankings" title="Why this rank?" description={`${fmtInt(rows.length)} current ranking rows (latest year per scope${hiddenYears > 0 ? `; ${fmtInt(hiddenYears)} earlier yearly snapshots are on the ranking pages` : ''}). Each row lists the scope, the formula version, the value and the exact inputs that produced it.`}>
35 46 <div className="ci-table-wrap">
36 47 <table className="ci-table">
37 48 <thead>
modified apps/web/src/components/cancer/tabs/statistics.tsx +6 −1
@@ -69,7 +69,7 @@ export async function StatisticsTab({ b }: { b: CancerBundle }) {
69 69 </tr>
70 70 </thead>
71 71 <tbody>
72 − {g.rows.map((r) => (
72 + {[...g.rows].sort((a, c) => c.year - a.year || a.sex.localeCompare(c.sex)).slice(0, 12).map((r) => (
73 73 <tr key={r.id}>
74 74 <td className="ci-num">{r.year_end && r.year_end !== r.year ? `${r.year}–${r.year_end}` : r.year}</td>
75 75 <td>{humanize(r.sex)}</td>
@@ -91,6 +91,11 @@ export async function StatisticsTab({ b }: { b: CancerBundle }) {
91 91 </tbody>
92 92 </table>
93 93 </div>
94 + {g.rows.length > 12 ? (
95 + <p className="mt-1 text-[12px] text-ink-3">
96 + Showing the 12 most recent rows of {g.rows.length}; every year is plotted above and available via <a className="ci-link" href={`/api/v1/cancers/${b.cancer.id}/statistics`}>the API</a>.
97 + </p>
98 + ) : null}
94 99 <Freshness dataUpdatedAt={g.rows.reduce((m, r) => (r.updated_at > m ? r.updated_at : m), g.rows[0]!.updated_at)} sourceVersion={prov.get(g.rows[0]!.provenance_id)?.dataset_version ?? null} />
95 100 </Section>
96 101 );
modified packages/database/src/seed-data/metrics.ts +2 −2
@@ -255,7 +255,7 @@ export const METRIC_CATALOG: MetricSeed[] = [
255 255 {
256 256 slug: 'trial_gap',
257 257 name: 'Trial Gap Index',
258 − description: 'Burden percentile minus active-trial percentile within the same scope. Positive values flag cancers with high mortality burden but comparatively few active trials. A quantitative signal, not an accusation (CLAUDE.md §324).',
258 + description: 'Burden percentile minus active-trial percentile within the same scope. Positive values flag cancers with high mortality burden but comparatively few active trials. A quantitative signal, not an accusation (CLAUDE.md §324). Caveat (Phase 1): trial counts aggregate a cancer and its NCIt descendants only; registrations phrased at a broader level (e.g. "colorectal cancer" for colon and rectal cancer) are attributed to the broader entity, which can overstate the gap of narrower top-level sites.',
259 259 formula: 'percentile(mortality_count) − percentile(active_trials)',
260 260 formulaVersion: 'ci-trial-gap-v1',
261 261 unit: 'percentile_points',
@@ -270,7 +270,7 @@ export const METRIC_CATALOG: MetricSeed[] = [
270 270 {
271 271 slug: 'research_gap',
272 272 name: 'Research Gap Index',
273 − description: 'Burden percentile minus research-activity percentile (publications, last 5 years) within the same scope. Positive values flag cancers with high mortality burden but comparatively little literature.',
273 + description: 'Burden percentile minus research-activity percentile (publications, last 5 years) within the same scope. Positive values flag cancers with high mortality burden but comparatively little literature. Caveat (Phase 1): literature counts are query-based per entity (query stored on each row); they are not aggregated over descendants.',
274 274 formula: 'percentile(mortality_count) − percentile(publications_5y)',
275 275 formulaVersion: 'ci-research-gap-v1',
276 276 unit: 'percentile_points',
277 277