Web: latest-year rankings per scope, statistics table trimmed; gap-metric caveats
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 | |