SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%

feat(web): sharper detail page — sub-score dot plot on shared axis, weight pills; fix insights int overflow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 6 days ago (Aug 5, 2026) parent 4d256e4

Showing 3 changed files with +117 and −31

modified apps/web/app/insights/page.tsx +2 −2
@@ -48,8 +48,8 @@ async function loadInsights() {
48 48 const [agg] = await prisma.$queryRaw<Aggregates[]>`
49 49 SELECT count(*)::int AS n,
50 50 sum(o.employment)::float8 AS total_emp,
51 sum(o.employment * o."medianWageCents" / 100.0)::float8 AS wage_bill,
52 sum(o.employment * o."medianWageCents" / 100.0 * s.substitution / 100.0)::float8 AS wage_at_risk,
51 + sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0)::float8 AS wage_bill,
52 + sum(o.employment::float8 * o."medianWageCents"::float8 / 100.0 * s.substitution / 100.0)::float8 AS wage_at_risk,
53 53 (sum(o.employment * s.substitution) / sum(o.employment))::float8 AS emp_weighted_sub,
54 54 (sum(o.employment * s.augmentation) / sum(o.employment))::float8 AS emp_weighted_aug,
55 55 sum(CASE WHEN s.substitution >= 70 THEN o.employment ELSE 0 END)::float8 AS emp_high_sub,
modified apps/web/app/occupations/[code]/page.tsx +61 −29
@@ -18,7 +18,7 @@ import {
18 18 pressure,
19 19 type DimensionKey,
20 20 } from "@airiskindex/scoring";
21 import { ScoreBar, ScoreTile, ShareMeter } from "@/components/score-marks";
21 +import { ScoreBar, ShareMeter, SubScoreDotPlot } from "@/components/score-marks";
22 22 import { formatWage, socGroupName } from "@/lib/soc-groups";
23 23
24 24 export const dynamic = "force-dynamic";
@@ -197,30 +197,58 @@ export default async function OccupationPage({
197 197 ) : (
198 198 <>
199 199 <section className="mt-10">
200 <div className="grid gap-4 sm:grid-cols-3">
201 <ScoreTile
202 label="Substitution"
203 band={{
204 low: score.substitutionLow,
205 score: score.substitution,
206 high: score.substitutionHigh,
207 }}
208 hint="Composite pressure that AI replaces the human on this occupation's tasks — capability discounted by cost, barriers and adoption."
209 />
210 <ScoreTile
211 label="Exposure"
212 band={{ low: score.exposureLow, score: score.exposure, high: score.exposureHigh }}
213 hint="Technical capability only: could AI perform these tasks, regardless of whether anyone deploys it."
214 />
215 <ScoreTile
216 label="Augmentation"
217 band={{
218 low: score.augmentationLow,
219 score: score.augmentation,
220 high: score.augmentationHigh,
221 }}
222 hint="How much AI assists a human doing this work. High augmentation with moderate substitution reads as a changing job, not a disappearing one."
223 />
200 + <div className="card p-5 sm:p-6">
201 + <div className="flex flex-wrap items-baseline justify-between gap-2">
202 + <h2 className="text-lg font-semibold tracking-tight">Sub-scores</h2>
203 + <p className="text-xs text-[var(--muted)]">
204 + 0–100 · band = confidence interval from rater disagreement
205 + </p>
206 + </div>
207 + <div className="mt-4">
208 + <SubScoreDotPlot
209 + rows={[
210 + {
211 + label: "Substitution",
212 + band: {
213 + low: score.substitutionLow,
214 + score: score.substitution,
215 + high: score.substitutionHigh,
216 + },
217 + },
218 + {
219 + label: "Exposure",
220 + band: {
221 + low: score.exposureLow,
222 + score: score.exposure,
223 + high: score.exposureHigh,
224 + },
225 + },
226 + {
227 + label: "Augmentation",
228 + band: {
229 + low: score.augmentationLow,
230 + score: score.augmentation,
231 + high: score.augmentationHigh,
232 + },
233 + },
234 + ]}
235 + />
236 + </div>
237 + <div className="mt-5 grid gap-4 border-t border-[var(--grid)] pt-4 text-xs leading-relaxed text-[var(--muted)] sm:grid-cols-3">
238 + <p>
239 + <strong className="font-medium text-[var(--ink-2)]">Substitution</strong> — the
240 + headline: capability discounted by cost, barriers and adoption.
241 + </p>
242 + <p>
243 + <strong className="font-medium text-[var(--ink-2)]">Exposure</strong> —
244 + technical capability alone, regardless of whether anyone deploys it.
245 + </p>
246 + <p>
247 + <strong className="font-medium text-[var(--ink-2)]">Augmentation</strong> — how
248 + much AI assists without replacing. High here + moderate substitution = a
249 + changing job, not a disappearing one.
250 + </p>
251 + </div>
224 252 </div>
225 253 <div className="mt-4">
226 254 <ShareMeter
@@ -248,14 +276,18 @@ export default async function OccupationPage({
248 276 <div className="mt-5 overflow-hidden card">
249 277 {dimensions.map((dim) => (
250 278 <div key={dim.dimension} className="border-b border-[var(--grid)] px-5 py-3.5 last:border-b-0">
251 <div className="flex items-baseline gap-2">
279 + <div className="flex flex-wrap items-baseline gap-2">
252 280 <span className="text-sm font-medium">
253 281 {DIMENSION_LABELS[dim.dimension]}
254 282 </span>
255 <span className="text-xs text-[var(--muted)]">
256 weight {(dim.weight * 100).toFixed(0)}%
257 {dim.inverted && " · inverted — strong barriers lower the score"}
283 + <span className="rounded-full bg-[var(--seq-track)] px-2 py-0.5 text-[10px] font-semibold tabular-nums">
284 + w {(dim.weight * 100).toFixed(0)}%
258 285 </span>
286 + {dim.inverted && (
287 + <span className="text-xs text-[var(--muted)]">
288 + inverted — strong barriers lower the score
289 + </span>
290 + )}
259 291 <span className="ml-auto text-sm font-semibold tabular-nums">
260 292 {dim.pressure.toFixed(0)}
261 293 </span>
modified apps/web/components/score-marks.tsx +54 −0
@@ -95,6 +95,60 @@ export function ScoreTile({
95 95 );
96 96 }
97 97
98 +/**
99 + * Dot plot of labeled scores on a shared 0–100 axis: hairline gridlines,
100 + * CI band wash, ≥10px point marker with a 2px surface ring. Rows are
101 + * direct-labeled, single hue — no legend needed.
102 + */
103 +export function SubScoreDotPlot({
104 + rows,
105 +}: {
106 + rows: Array<{ label: string; band: Band }>;
107 +}): JSX.Element {
108 + return (
109 + <div>
110 + {rows.map((row) => (
111 + <div key={row.label} className="flex items-center gap-3 py-2.5">
112 + <span className="w-24 shrink-0 text-sm font-medium text-[var(--ink-2)] sm:w-28">
113 + {row.label}
114 + </span>
115 + <div aria-hidden="true" className="relative h-[26px] min-w-0 flex-1">
116 + {[0, 25, 50, 75, 100].map((tick) => (
117 + <div
118 + key={tick}
119 + className="absolute inset-y-0 w-px bg-[var(--grid)]"
120 + style={{ left: pct(tick) }}
121 + />
122 + ))}
123 + <div
124 + className="absolute top-1/2 h-[10px] -translate-y-1/2 rounded-full bg-[var(--seq-track)]"
125 + style={{ left: pct(row.band.low), width: pct(row.band.high - row.band.low) }}
126 + />
127 + <div
128 + className="absolute top-1/2 h-[13px] w-[13px] -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[var(--surface-1)] bg-[var(--seq)]"
129 + style={{ left: pct(row.band.score) }}
130 + />
131 + </div>
132 + <span className="w-9 shrink-0 text-right text-lg font-semibold tabular-nums">
133 + {row.band.score.toFixed(0)}
134 + </span>
135 + </div>
136 + ))}
137 + <div className="flex items-center gap-3" aria-hidden="true">
138 + <span className="w-24 shrink-0 sm:w-28" />
139 + <div className="relative h-4 min-w-0 flex-1 text-[10px] text-[var(--muted)]">
140 + <span className="absolute left-0">0</span>
141 + <span className="absolute left-1/4 -translate-x-1/2">25</span>
142 + <span className="absolute left-1/2 -translate-x-1/2">50</span>
143 + <span className="absolute left-3/4 -translate-x-1/2">75</span>
144 + <span className="absolute right-0">100</span>
145 + </div>
146 + <span className="w-9 shrink-0" />
147 + </div>
148 + </div>
149 + );
150 +}
151 +
98 152 /** Meter: fill + same-ramp track (marks-and-anatomy §Figures). */
99 153 export function ShareMeter({ share, label }: { share: number; label: string }): JSX.Element {
100 154 const value = Math.round(share * 100);
101 155