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%
5.1 KB · 136 lines tsx
Raw Blame History
1//  File:    page.tsx2//  Path:    apps/web/app/ranking/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: Full index — every scored occupation ranked, paginated by 50.910import Link from "next/link";11import { prisma } from "@airiskindex/db";12import { ScoreBar } from "@/components/score-marks";13import { formatWage } from "@/lib/soc-groups";1415export const dynamic = "force-dynamic";1617export const metadata = {18  title: "Full index — AI Risk Index",19  description: "Every scored occupation ranked by substitution pressure, with confidence intervals.",20};2122const PER_PAGE = 50;2324export default async function RankingPage({25  searchParams,26}: {27  searchParams: { page?: string };28}): Promise<JSX.Element> {29  const run = await prisma.scoreRun.findFirst({ orderBy: { createdAt: "desc" } });30  const total = run ? await prisma.occupationScore.count({ where: { runId: run.id } }) : 0;31  const pages = Math.max(1, Math.ceil(total / PER_PAGE));32  const page = Math.min(pages, Math.max(1, Number(searchParams.page ?? "1") || 1));3334  const rows = run35    ? await prisma.occupationScore.findMany({36        where: { runId: run.id },37        orderBy: { substitution: "desc" },38        skip: (page - 1) * PER_PAGE,39        take: PER_PAGE,40        include: {41          occupation: {42            select: { code: true, title: true, medianWageCents: true, employment: true },43          },44        },45      })46    : [];4748  const pager = (49    <div className="flex flex-wrap items-center justify-between gap-3 text-sm">50      <div className="flex gap-2">51        <Link52          href={`/ranking?page=${page - 1}`}53          aria-disabled={page <= 1}54          className={`card px-4 py-2 font-medium ${page <= 1 ? "pointer-events-none opacity-40" : "card-hover"}`}55        >56          ← Previous57        </Link>58        <Link59          href={`/ranking?page=${page + 1}`}60          aria-disabled={page >= pages}61          className={`card px-4 py-2 font-medium ${page >= pages ? "pointer-events-none opacity-40" : "card-hover"}`}62        >63          Next →64        </Link>65      </div>66      <p className="text-[var(--muted)]">67        Page {page} / {pages} · ranks {(page - 1) * PER_PAGE + 1}–68        {Math.min(total, page * PER_PAGE)} of {total.toLocaleString("en-US")}69      </p>70    </div>71  );7273  return (74    <main className="mx-auto max-w-4xl px-4 py-10 sm:px-6 sm:py-14">75      <p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--muted)]">76        Full index{run ? ` · run ${run.indexVersion} · ${run.createdAt.toISOString().slice(0, 10)}` : ""}77      </p>78      <h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">79        All {total.toLocaleString("en-US")} scored occupations80      </h1>81      <p className="mt-3 max-w-2xl text-[var(--ink-2)]">82        Ranked by composite substitution pressure (0–100). The whisker is the confidence interval83        from rater disagreement. Wages are U.S. national medians (BLS OEWS).84      </p>8586      <div className="mt-8">{pager}</div>8788      <ol className="card mt-4 overflow-hidden">89        {rows.map((row, index) => {90          const rank = (page - 1) * PER_PAGE + index + 1;91          const wage = formatWage(row.occupation.medianWageCents);92          return (93            <li key={row.occupationCode} className="border-b border-[var(--grid)] last:border-b-0">94              <Link95                href={`/occupations/${row.occupationCode}`}96                className="block px-4 py-3.5 transition-colors hover:bg-[var(--wash)] sm:px-5"97              >98                <div className="flex items-baseline gap-3">99                  <span className="w-8 shrink-0 text-right text-sm tabular-nums text-[var(--muted)]">100                    {rank}101                  </span>102                  <span className="min-w-0 truncate font-medium">{row.occupation.title}</span>103                  <span className="ml-auto shrink-0 pl-3 text-sm font-semibold tabular-nums">104                    {row.substitution.toFixed(0)}105                  </span>106                </div>107                <div className="mt-2 pl-11">108                  <ScoreBar109                    band={{110                      low: row.substitutionLow,111                      score: row.substitution,112                      high: row.substitutionHigh,113                    }}114                    thick={8}115                  />116                  <p className="mt-1.5 text-xs text-[var(--muted)]">117                    CI {row.substitutionLow.toFixed(0)}–{row.substitutionHigh.toFixed(0)} ·118                    exposure {row.exposure.toFixed(0)} · augmentation{" "}119                    {row.augmentation.toFixed(0)}120                    {wage && <> · {wage}</>}121                    {row.occupation.employment != null && (122                      <> · {row.occupation.employment.toLocaleString("en-US")} employed</>123                    )}124                  </p>125                </div>126              </Link>127            </li>128          );129        })}130      </ol>131132      <div className="mt-4">{pager}</div>133    </main>134  );135}136