| 10 | import Link from "next/link"; |
10 | import Link from "next/link"; |
| 11 | import { prisma } from "@airiskindex/db"; |
11 | import { prisma } from "@airiskindex/db"; |
| 12 | import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; |
12 | import { HIGH_EXPOSURE_THRESHOLD } from "@airiskindex/scoring"; |
| |
13 | +import { DecileColumns, ScatterPlot } from "@/components/insights-charts"; |
| |
14 | +import { DistributionChart } from "@/components/score-marks"; |
| |
15 | +import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups"; |
| 13 | |
16 | |
| 14 | export const dynamic = "force-dynamic"; |
17 | export const dynamic = "force-dynamic"; |
| 15 | |
18 | |
| 66 | FROM "TaskScore" WHERE "runId" = ${run.id} |
69 | FROM "TaskScore" WHERE "runId" = ${run.id} |
| 67 | `; |
70 | `; |
| 68 | |
71 | |
| 69 | − const [mostWorkersAtHigh, widestCI] = await Promise.all([ |
72 | + const [scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI] = await Promise.all([ |
| |
73 | + prisma.$queryRaw<Array<{ x: number; y: number; size: number | null; label: string }>>` |
| |
74 | + SELECT s.substitution::float8 AS x, s.augmentation::float8 AS y, |
| |
75 | + o.employment::float8 AS size, o.title AS label |
| |
76 | + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" |
| |
77 | + WHERE s."runId" = ${run.id} |
| |
78 | + `, |
| |
79 | + prisma.$queryRaw<Array<{ prefix: string; sub: number; n: number; emp: number }>>` |
| |
80 | + SELECT left(s."occupationCode", 2) AS prefix, |
| |
81 | + (sum(s.substitution * COALESCE(o.employment, 0)) / NULLIF(sum(COALESCE(o.employment, 0)), 0))::float8 AS sub, |
| |
82 | + count(*)::int AS n, |
| |
83 | + sum(COALESCE(o.employment, 0))::float8 AS emp |
| |
84 | + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" |
| |
85 | + WHERE s."runId" = ${run.id} |
| |
86 | + GROUP BY 1 HAVING sum(COALESCE(o.employment, 0)) > 0 |
| |
87 | + ORDER BY 2 DESC |
| |
88 | + `, |
| |
89 | + prisma.$queryRaw<Array<{ decile: number; sub: number; lo: number; hi: number }>>` |
| |
90 | + WITH d AS ( |
| |
91 | + SELECT s.substitution, o.employment::float8 AS emp, o."medianWageCents", |
| |
92 | + ntile(10) OVER (ORDER BY o."medianWageCents") AS decile |
| |
93 | + FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" |
| |
94 | + WHERE s."runId" = ${run.id} AND o."medianWageCents" IS NOT NULL AND o.employment IS NOT NULL |
| |
95 | + ) |
| |
96 | + SELECT decile::int, (sum(substitution * emp) / sum(emp))::float8 AS sub, |
| |
97 | + min("medianWageCents")::float8 AS lo, max("medianWageCents")::float8 AS hi |
| |
98 | + FROM d GROUP BY decile ORDER BY decile |
| |
99 | + `, |
| |
100 | + prisma.occupationScore.findMany({ |
| |
101 | + where: { runId: run.id }, |
| |
102 | + orderBy: { substitution: "desc" }, |
| |
103 | + take: 20, |
| |
104 | + include: { |
| |
105 | + occupation: { select: { code: true, title: true, medianWageCents: true, employment: true } }, |
| |
106 | + }, |
| |
107 | + }), |
| |
108 | + prisma.occupationScore.findMany({ |
| |
109 | + where: { runId: run.id }, |
| |
110 | + select: { substitution: true }, |
| |
111 | + }), |
| 70 | prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>` |
112 | prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>` |
| 71 | SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub |
113 | SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub |
| 72 | FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" |
114 | FROM "OccupationScore" s JOIN "Occupation" o ON o.code = s."occupationCode" |
| 82 | `, |
124 | `, |
| 83 | ]); |
125 | ]); |
| 84 | |
126 | |
| 85 | − return { run, agg, taskShare, mostWorkersAtHigh, widestCI }; |
127 | + return { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI }; |
| |
128 | +} |
| |
129 | + |
| |
130 | +function toBins(values: number[], binCount = 20): number[] { |
| |
131 | + const bins = Array.from({ length: binCount }, () => 0); |
| |
132 | + for (const value of values) { |
| |
133 | + bins[Math.min(binCount - 1, Math.floor((value / 100) * binCount))] += 1; |
| |
134 | + } |
| |
135 | + return bins; |
| 86 | } |
136 | } |
| 87 | |
137 | |
| |
138 | +const wageShort = (cents: number): string => `$${Math.round(cents / 100_000)}k`; |
| |
139 | + |
| 88 | function Tile({ |
140 | function Tile({ |
| 89 | label, |
141 | label, |
| 90 | value, |
142 | value, |
| 113 | </main> |
165 | </main> |
| 114 | ); |
166 | ); |
| 115 | } |
167 | } |
| 116 | − const { run, agg, taskShare, mostWorkersAtHigh, widestCI } = data; |
168 | + const { run, agg, taskShare, scatter, groups, deciles, top20, allScores, mostWorkersAtHigh, widestCI } = data; |
| 117 | const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100; |
169 | const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100; |
| 118 | |
170 | |
| 119 | return ( |
171 | return ( |
| 179 | /> |
231 | /> |
| 180 | </div> |
232 | </div> |
| 181 | |
233 | |
| |
234 | + {/* The signature chart: substitution × augmentation */} |
| |
235 | + <section className="mt-12"> |
| |
236 | + <h2 className="text-xl font-semibold tracking-tight"> |
| |
237 | + Substitution × augmentation — every occupation |
| |
238 | + </h2> |
| |
239 | + <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]"> |
| |
240 | + The core claim of the methodology in one picture: replacement and assistance are |
| |
241 | + different axes. Each dot is one of {scatter.length.toLocaleString("en-US")} occupations; |
| |
242 | + dot area reflects U.S. employment. Hover a dot for its name. |
| |
243 | + </p> |
| |
244 | + <div className="card mt-4 p-4 sm:p-6"> |
| |
245 | + <ScatterPlot points={scatter} /> |
| |
246 | + </div> |
| |
247 | + </section> |
| |
248 | + |
| |
249 | + <section className="mt-10 grid gap-4 lg:grid-cols-2"> |
| |
250 | + <div className="card min-w-0 p-5 sm:p-6"> |
| |
251 | + <h2 className="text-lg font-semibold tracking-tight">The shape of the index</h2> |
| |
252 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> |
| |
253 | + Distribution of all {allScores.length.toLocaleString("en-US")} occupation substitution |
| |
254 | + scores; the marker is the employment-weighted mean. |
| |
255 | + </p> |
| |
256 | + <div className="mt-4"> |
| |
257 | + <DistributionChart |
| |
258 | + bins={toBins(allScores.map((s) => s.substitution))} |
| |
259 | + marker={agg.emp_weighted_sub} |
| |
260 | + markerLabel={`workers' mean · ${agg.emp_weighted_sub.toFixed(0)}`} |
| |
261 | + /> |
| |
262 | + </div> |
| |
263 | + </div> |
| |
264 | + <div className="card min-w-0 p-5 sm:p-6"> |
| |
265 | + <h2 className="text-lg font-semibold tracking-tight">Pressure by wage decile</h2> |
| |
266 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> |
| |
267 | + Employment-weighted mean substitution per median-wage decile — where on the pay scale |
| |
268 | + the pressure sits. |
| |
269 | + </p> |
| |
270 | + <div className="mt-4"> |
| |
271 | + <DecileColumns |
| |
272 | + columns={deciles.map((d) => ({ |
| |
273 | + label: `${wageShort(d.lo)}–${wageShort(d.hi)}`, |
| |
274 | + value: d.sub, |
| |
275 | + }))} |
| |
276 | + /> |
| |
277 | + </div> |
| |
278 | + </div> |
| |
279 | + </section> |
| |
280 | + |
| |
281 | + <section className="mt-10"> |
| |
282 | + <h2 className="text-xl font-semibold tracking-tight">By occupation group</h2> |
| |
283 | + <p className="mt-1 text-sm text-[var(--ink-2)]"> |
| |
284 | + Employment-weighted mean substitution per SOC major group. |
| |
285 | + </p> |
| |
286 | + <div className="card mt-4 overflow-hidden"> |
| |
287 | + {groups.map((group) => ( |
| |
288 | + <Link |
| |
289 | + key={group.prefix} |
| |
290 | + href={`/occupations#g${group.prefix}`} |
| |
291 | + className="block border-b border-[var(--grid)] px-4 py-3 last:border-b-0 hover:bg-[var(--wash)] sm:px-5" |
| |
292 | + > |
| |
293 | + <div className="flex items-baseline gap-3"> |
| |
294 | + <span className="min-w-0 truncate text-sm"> |
| |
295 | + {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix} |
| |
296 | + </span> |
| |
297 | + <span className="ml-auto shrink-0 text-xs text-[var(--muted)]"> |
| |
298 | + {group.n} occ. · {millions(group.emp)} workers |
| |
299 | + </span> |
| |
300 | + <span className="w-8 shrink-0 text-right text-sm font-semibold tabular-nums"> |
| |
301 | + {group.sub.toFixed(0)} |
| |
302 | + </span> |
| |
303 | + </div> |
| |
304 | + <div aria-hidden="true" className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]"> |
| |
305 | + <div |
| |
306 | + className="h-full rounded-r-[3px] bg-[var(--seq)]" |
| |
307 | + style={{ width: `${Math.min(100, group.sub)}%` }} |
| |
308 | + /> |
| |
309 | + </div> |
| |
310 | + </Link> |
| |
311 | + ))} |
| |
312 | + </div> |
| |
313 | + </section> |
| |
314 | + |
| |
315 | + <section className="mt-10"> |
| |
316 | + <div className="flex flex-wrap items-baseline justify-between gap-2"> |
| |
317 | + <h2 className="text-xl font-semibold tracking-tight">Top 20 by substitution</h2> |
| |
318 | + <Link href="/ranking" className="text-xs text-[var(--muted)] underline hover:text-[var(--ink)]"> |
| |
319 | + full index → |
| |
320 | + </Link> |
| |
321 | + </div> |
| |
322 | + <div className="card mt-4 overflow-x-auto"> |
| |
323 | + <table className="w-full min-w-[640px] text-left text-sm"> |
| |
324 | + <caption className="sr-only">Top 20 occupations by substitution score</caption> |
| |
325 | + <thead className="border-b border-[var(--grid)] text-xs uppercase tracking-wide text-[var(--muted)]"> |
| |
326 | + <tr> |
| |
327 | + <th className="px-4 py-3 font-medium">#</th> |
| |
328 | + <th className="px-4 py-3 font-medium">Occupation</th> |
| |
329 | + <th className="px-4 py-3 font-medium">Substitution (CI)</th> |
| |
330 | + <th className="px-4 py-3 font-medium">Exposure</th> |
| |
331 | + <th className="px-4 py-3 font-medium">Augment.</th> |
| |
332 | + <th className="px-4 py-3 font-medium">Median wage</th> |
| |
333 | + <th className="px-4 py-3 font-medium">Employed</th> |
| |
334 | + </tr> |
| |
335 | + </thead> |
| |
336 | + <tbody className="tabular-nums"> |
| |
337 | + {top20.map((row, index) => ( |
| |
338 | + <tr key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0 hover:bg-[var(--wash)]"> |
| |
339 | + <td className="px-4 py-2.5 text-[var(--muted)]">{index + 1}</td> |
| |
340 | + <td className="max-w-xs px-4 py-2.5"> |
| |
341 | + <Link href={`/occupations/${row.occupationCode}`} className="hover:underline"> |
| |
342 | + {row.occupation.title} |
| |
343 | + </Link> |
| |
344 | + </td> |
| |
345 | + <td className="px-4 py-2.5 font-semibold"> |
| |
346 | + {row.substitution.toFixed(1)} |
| |
347 | + <span className="ml-1 font-normal text-[var(--muted)]"> |
| |
348 | + ({row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)}) |
| |
349 | + </span> |
| |
350 | + </td> |
| |
351 | + <td className="px-4 py-2.5">{row.exposure.toFixed(0)}</td> |
| |
352 | + <td className="px-4 py-2.5">{row.augmentation.toFixed(0)}</td> |
| |
353 | + <td className="px-4 py-2.5"> |
| |
354 | + {row.occupation.medianWageCents != null |
| |
355 | + ? `$${Math.round(row.occupation.medianWageCents / 100).toLocaleString("en-US")}` |
| |
356 | + : "—"} |
| |
357 | + </td> |
| |
358 | + <td className="px-4 py-2.5"> |
| |
359 | + {row.occupation.employment != null |
| |
360 | + ? row.occupation.employment.toLocaleString("en-US") |
| |
361 | + : "—"} |
| |
362 | + </td> |
| |
363 | + </tr> |
| |
364 | + ))} |
| |
365 | + </tbody> |
| |
366 | + </table> |
| |
367 | + </div> |
| |
368 | + </section> |
| |
369 | + |
| 182 | <section className="mt-12 grid gap-6 lg:grid-cols-2"> |
370 | <section className="mt-12 grid gap-6 lg:grid-cols-2"> |
| 183 | <div className="min-w-0"> |
371 | <div className="min-w-0"> |
| 184 | <h2 className="text-lg font-semibold tracking-tight"> |
372 | <h2 className="text-lg font-semibold tracking-tight"> |