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 | export async function RankingsTab({ b }: { b: CancerBundle }) { | 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 | if (rows.length === 0) { | 34 | if (rows.length === 0) { | ||
| 24 | return ( | 35 | return ( | ||
| 25 | <Section id="rankings" kicker="Rankings" title="Why this rank?"> | 36 | <Section id="rankings" kicker="Rankings" title="Why this rank?"> | ||
@@ -31,7 +42,7 @@ export async function RankingsTab({ b }: { b: CancerBundle }) { | |||||
| 31 | } | 42 | } | ||
| 32 | return ( | 43 | return ( | ||
| 33 | <div className="space-y-6"> | 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 | <div className="ci-table-wrap"> | 46 | <div className="ci-table-wrap"> | ||
| 36 | <table className="ci-table"> | 47 | <table className="ci-table"> | ||
| 37 | <thead> | 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 | </tr> | 69 | </tr> | ||
| 70 | </thead> | 70 | </thead> | ||
| 71 | <tbody> | 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 | <tr key={r.id}> | 73 | <tr key={r.id}> | ||
| 74 | <td className="ci-num">{r.year_end && r.year_end !== r.year ? `${r.year}–${r.year_end}` : r.year}</td> | 74 | <td className="ci-num">{r.year_end && r.year_end !== r.year ? `${r.year}–${r.year_end}` : r.year}</td> | ||
| 75 | <td>{humanize(r.sex)}</td> | 75 | <td>{humanize(r.sex)}</td> | ||
@@ -91,6 +91,11 @@ export async function StatisticsTab({ b }: { b: CancerBundle }) { | |||||
| 91 | </tbody> | 91 | </tbody> | ||
| 92 | </table> | 92 | </table> | ||
| 93 | </div> | 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 | <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} /> | 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 | </Section> | 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 | slug: 'trial_gap', | 256 | slug: 'trial_gap', | ||
| 257 | name: 'Trial Gap Index', | 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 | formula: 'percentile(mortality_count) − percentile(active_trials)', | 259 | formula: 'percentile(mortality_count) − percentile(active_trials)', | ||
| 260 | formulaVersion: 'ci-trial-gap-v1', | 260 | formulaVersion: 'ci-trial-gap-v1', | ||
| 261 | unit: 'percentile_points', | 261 | unit: 'percentile_points', | ||
@@ -270,7 +270,7 @@ export const METRIC_CATALOG: MetricSeed[] = [ | |||||
| 270 | { | 270 | { | ||
| 271 | slug: 'research_gap', | 271 | slug: 'research_gap', | ||
| 272 | name: 'Research Gap Index', | 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 | formula: 'percentile(mortality_count) − percentile(publications_5y)', | 274 | formula: 'percentile(mortality_count) − percentile(publications_5y)', | ||
| 275 | formulaVersion: 'ci-research-gap-v1', | 275 | formulaVersion: 'ci-research-gap-v1', | ||
| 276 | unit: 'percentile_points', | 276 | unit: 'percentile_points', | ||
| 277 | 277 | ||||