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%
10.2 KB · 256 lines tsx
Raw Blame History
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