| 10 |
10 |
import Link from "next/link"; |
| 11 |
11 |
import { prisma } from "@airiskindex/db"; |
| 12 |
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 |
17 |
export const dynamic = "force-dynamic"; |
| 15 |
18 |
|
| 66 |
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 |
112 |
prisma.$queryRaw<Array<{ code: string; title: string; employment: number; sub: number }>>` |
| 71 |
113 |
SELECT o.code, o.title, o.employment::float8 AS employment, s.substitution::float8 AS sub |
| 72 |
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 |
140 |
function Tile({ |
| 89 |
141 |
label, |
| 90 |
142 |
value, |
| 113 |
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 |
169 |
const atRiskShare = (agg.wage_at_risk / agg.wage_bill) * 100; |
| 118 |
170 |
|
| 119 |
171 |
return ( |
| 179 |
231 |
/> |
| 180 |
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 |
370 |
<section className="mt-12 grid gap-6 lg:grid-cols-2"> |
| 183 |
371 |
<div className="min-w-0"> |
| 184 |
372 |
<h2 className="text-lg font-semibold tracking-tight"> |