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%
1// File: page.tsx2// Path: apps/web/app/page.tsx3// Project: AI Risk Index — airiskindex.io4// Author: Simon-Pierre Boucher5// Contact: contact@spboucher.ai6// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.7//8// Description: Home page: hero, live search, ranking (most/least exposed), group averages.910import Link from "next/link";11import { prisma } from "@airiskindex/db";12import { OccupationSearch } from "@/components/occupation-search";13import { ScoreBar } from "@/components/score-marks";14import { SOC_MAJOR_GROUPS } from "@/lib/soc-groups";1516export const dynamic = "force-dynamic";1718const CONCEPTS = [19 {20 name: "Exposure",21 description:22 "AI is technically capable of performing the task. High exposure alone does not mean job loss — it means the occupation's tasks are changing.",23 },24 {25 name: "Substitution",26 description:27 "AI actually replaces the human performing the task, once cost, adoption velocity and real-world barriers are accounted for. This is the headline score.",28 },29 {30 name: "Augmentation",31 description:32 "AI assists the human, increasing productivity. For most occupations today, measured usage is augmentation, not replacement.",33 },34] as const;3536interface RankRow {37 occupationCode: string;38 substitution: number;39 substitutionLow: number;40 substitutionHigh: number;41 exposure: number;42 augmentation: number;43 occupation: { code: string; title: string };44}4546async function loadHome() {47 try {48 const [occupations, tasks, run] = await Promise.all([49 prisma.occupation.count(),50 prisma.task.count(),51 prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } }),52 ]);53 if (!run) return { occupations, tasks, run: null, scored: 0, top: [], bottom: [], groups: [] };5455 const [scored, top, bottom, groups] = await Promise.all([56 prisma.occupationScore.count({ where: { runId: run.id } }),57 prisma.occupationScore.findMany({58 where: { runId: run.id },59 orderBy: { substitution: "desc" },60 take: 15,61 include: { occupation: { select: { code: true, title: true } } },62 }),63 prisma.occupationScore.findMany({64 where: { runId: run.id },65 orderBy: { substitution: "asc" },66 take: 15,67 include: { occupation: { select: { code: true, title: true } } },68 }),69 prisma.$queryRaw<Array<{ prefix: string; avg: number; n: bigint }>>`70 SELECT left("occupationCode", 2) AS prefix,71 avg(substitution)::float AS avg,72 count(*) AS n73 FROM "OccupationScore"74 WHERE "runId" = ${run.id}75 GROUP BY 176 HAVING count(*) >= 377 ORDER BY 2 DESC78 `,79 ]);80 return { occupations, tasks, run, scored, top, bottom, groups };81 } catch {82 return { occupations: 0, tasks: 0, run: null, scored: 0, top: [], bottom: [], groups: [] };83 }84}8586function RankList({ rows, startRank }: { rows: RankRow[]; startRank?: number }): JSX.Element {87 return (88 <ol className="overflow-hidden card">89 {rows.map((row, index) => (90 <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">91 <Link92 href={`/occupations/${row.occupationCode}`}93 className="group relative block px-5 py-3.5 transition-colors hover:bg-[var(--wash)]"94 >95 <div className="flex items-baseline gap-3">96 {startRank !== undefined && (97 <span className="w-6 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]">98 {startRank + index}99 </span>100 )}101 <span className="min-w-0 truncate font-medium">{row.occupation.title}</span>102 <span className="ml-auto pl-3 text-sm font-semibold tabular-nums">103 {row.substitution.toFixed(0)}104 </span>105 </div>106 <div className={`mt-2 ${startRank !== undefined ? "pl-9" : ""}`}>107 <ScoreBar108 band={{109 low: row.substitutionLow,110 score: row.substitution,111 high: row.substitutionHigh,112 }}113 thick={8}114 />115 </div>116 <div className="pointer-events-none absolute right-5 top-1 z-10 hidden rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-3 py-2 text-xs text-[var(--ink-2)] shadow-sm group-hover:block">117 CI {row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)} · exposure{" "}118 {row.exposure.toFixed(0)} · augmentation {row.augmentation.toFixed(0)}119 </div>120 </Link>121 </li>122 ))}123 </ol>124 );125}126127export default async function HomePage(): Promise<JSX.Element> {128 const { occupations, tasks, run, scored, top, bottom, groups } = await loadHome();129130 return (131 <main className="hero-wash mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">132 <p className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface-1)] px-3 py-1 text-xs font-medium text-[var(--ink-2)]">133 <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-[var(--seq)]" />134 The transparent, task-based AI job-exposure index135 </p>136 <h1 className="mt-5 max-w-2xl text-4xl font-bold leading-[1.08] tracking-tight sm:text-6xl">137 How is your occupation exposed to AI — task by task?138 </h1>139 <p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--ink-2)]">140 Every occupation is scored from its individual tasks by a multi-model rater panel — three141 sub-scores, confidence intervals from rater disagreement, versioned methodology, public142 API. Built for adaptation planning, not headlines.143 </p>144145 <div className="mt-8">146 <OccupationSearch />147 </div>148149 <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">150 {[151 [occupations.toLocaleString("en-US"), "occupations (O*NET 30.3)"],152 [tasks.toLocaleString("en-US"), "task statements"],153 [scored.toLocaleString("en-US"), "occupations scored"],154 [run ? run.indexVersion : "—", "methodology version"],155 ].map(([value, label]) => (156 <div157 key={label as string}158 className="card card-hover p-4"159 >160 <p className="text-2xl font-semibold">{value}</p>161 <p className="mt-1 text-xs text-[var(--muted)]">{label}</p>162 </div>163 ))}164 </div>165166 {run && top.length > 0 && (167 <>168 <section id="ranking" className="mt-14">169 <div className="flex flex-wrap items-baseline justify-between gap-2">170 <h2 className="text-xl font-semibold tracking-tight">Substitution ranking</h2>171 <p className="text-xs text-[var(--muted)]">172 run {run.indexVersion} · {run.createdAt.toISOString().slice(0, 10)} ·{" "}173 <Link href="/ranking" className="underline hover:text-[var(--ink)]">174 full index — all {scored.toLocaleString("en-US")} ranked →175 </Link>176 </p>177 </div>178 <p className="mt-1 max-w-2xl text-sm text-[var(--ink-2)]">179 Composite substitution pressure, 0–100. The whisker marks the confidence interval180 from rater disagreement — a wide band is a claim we hold loosely.181 </p>182 <div className="mt-6 grid gap-6 lg:grid-cols-2">183 <div className="min-w-0">184 <h3 className="mb-2 text-sm font-medium text-[var(--ink-2)]">Most exposed</h3>185 <RankList rows={top as RankRow[]} startRank={1} />186 </div>187 <div className="min-w-0">188 <h3 className="mb-2 text-sm font-medium text-[var(--ink-2)]">Least exposed</h3>189 <RankList rows={bottom as RankRow[]} />190 </div>191 </div>192 </section>193194 {groups.length > 0 && (195 <section className="mt-14">196 <h2 className="text-xl font-semibold tracking-tight">By occupation group</h2>197 <p className="mt-1 text-sm text-[var(--ink-2)]">198 Mean substitution score across scored occupations in each SOC major group.199 </p>200 <div className="mt-5 overflow-hidden card">201 {groups.map((group) => (202 <Link203 key={group.prefix}204 href={`/occupations#g${group.prefix}`}205 className="block border-b border-[var(--grid)] px-5 py-3 last:border-b-0 hover:bg-[var(--wash)]"206 >207 <div className="flex items-baseline gap-3">208 <span className="min-w-0 truncate text-sm">209 {SOC_MAJOR_GROUPS[group.prefix] ?? group.prefix}210 </span>211 <span className="ml-auto text-xs text-[var(--muted)]">212 {Number(group.n)} occupations213 </span>214 <span className="w-8 text-right text-sm font-semibold tabular-nums">215 {group.avg.toFixed(0)}216 </span>217 </div>218 <div219 aria-hidden="true"220 className="mt-1.5 h-[6px] rounded-r-[3px] bg-[var(--seq-track)]"221 >222 <div223 className="h-full rounded-r-[3px] bg-[var(--seq)]"224 style={{ width: `${Math.min(100, group.avg)}%` }}225 />226 </div>227 </Link>228 ))}229 </div>230 </section>231 )}232 </>233 )}234235 <section id="concepts" className="mt-16">236 <h2 className="text-xl font-semibold tracking-tight">237 Three scores, never collapsed into one238 </h2>239 <div className="mt-5 grid gap-4 sm:grid-cols-3">240 {CONCEPTS.map((concept) => (241 <div242 key={concept.name}243 className="card card-hover p-5"244 >245 <h3 className="font-semibold">{concept.name}</h3>246 <p className="mt-2 text-sm leading-relaxed text-[var(--ink-2)]">247 {concept.description}248 </p>249 </div>250 ))}251 </div>252 </section>253 </main>254 );255}256