|
| 1 | +import type { Metadata } from "next"; |
| 2 | +import Link from "next/link"; |
| 3 | +import { PERFECT_SCORE } from "@/constants"; |
| 4 | +import { getDoctorFace } from "@/utils/get-doctor-face"; |
| 5 | +import { getScoreColorClass } from "@/utils/get-score-color-class"; |
| 6 | + |
| 7 | +const SCORE_BAR_WIDTH = 20; |
| 8 | +const REVALIDATE_SECONDS = 60 * 60; |
| 9 | +const COMMAND = "npx -y react-doctor@latest ."; |
| 10 | +const BENCHMARKS_REPO_URL = "https://github.com/millionco/react-doctor-benchmarks"; |
| 11 | +const LEADERBOARD_URL = |
| 12 | + "https://raw.githubusercontent.com/millionco/react-doctor-benchmarks/main/results/leaderboard.json"; |
| 13 | +const BOX_TOP = "\u250C\u2500\u2500\u2500\u2500\u2500\u2510"; |
| 14 | +const BOX_BOTTOM = "\u2514\u2500\u2500\u2500\u2500\u2500\u2518"; |
| 15 | + |
| 16 | +interface LeaderboardEntry { |
| 17 | + slug: string; |
| 18 | + name: string; |
| 19 | + githubUrl: string; |
| 20 | + packageName: string; |
| 21 | + score: number; |
| 22 | + errorCount: number; |
| 23 | + warningCount: number; |
| 24 | + fileCount: number; |
| 25 | + commitSha: string; |
| 26 | + scannedAt: string; |
| 27 | +} |
| 28 | + |
| 29 | +interface LeaderboardFile { |
| 30 | + schemaVersion: number; |
| 31 | + generatedAt: string; |
| 32 | + doctorVersion: string; |
| 33 | + source: { repo: string; path: string; docs: string }; |
| 34 | + entries: LeaderboardEntry[]; |
| 35 | +} |
| 36 | + |
| 37 | +export const metadata: Metadata = { |
| 38 | + title: "Leaderboard - React Doctor", |
| 39 | + description: |
| 40 | + "Scores for popular open-source React projects, diagnosed by React Doctor. Updated automatically from public benchmarks.", |
| 41 | +}; |
| 42 | + |
| 43 | +const formatGeneratedAt = (isoTimestamp: string): string => { |
| 44 | + const parsedDate = new Date(isoTimestamp); |
| 45 | + if (Number.isNaN(parsedDate.getTime())) return isoTimestamp; |
| 46 | + return `${parsedDate.toISOString().replace("T", " ").slice(0, 16)} UTC`; |
| 47 | +}; |
| 48 | + |
| 49 | +const fetchLeaderboard = async (): Promise<LeaderboardFile | null> => { |
| 50 | + try { |
| 51 | + const response = await fetch(LEADERBOARD_URL, { next: { revalidate: REVALIDATE_SECONDS } }); |
| 52 | + if (!response.ok) return null; |
| 53 | + return (await response.json()) as LeaderboardFile; |
| 54 | + } catch { |
| 55 | + return null; |
| 56 | + } |
| 57 | +}; |
| 58 | + |
| 59 | +const ScoreBar = ({ score }: { score: number }) => { |
| 60 | + const filledCount = Math.round((score / PERFECT_SCORE) * SCORE_BAR_WIDTH); |
| 61 | + const emptyCount = SCORE_BAR_WIDTH - filledCount; |
| 62 | + const colorClass = getScoreColorClass(score); |
| 63 | + |
| 64 | + return ( |
| 65 | + <span className="text-xs sm:text-sm"> |
| 66 | + <span className={colorClass}>{"\u2588".repeat(filledCount)}</span> |
| 67 | + <span className="text-neutral-700">{"\u2591".repeat(emptyCount)}</span> |
| 68 | + </span> |
| 69 | + ); |
| 70 | +}; |
| 71 | + |
| 72 | +const LeaderboardRow = ({ entry, rank }: { entry: LeaderboardEntry; rank: number }) => { |
| 73 | + const colorClass = getScoreColorClass(entry.score); |
| 74 | + |
| 75 | + return ( |
| 76 | + <div className="group grid grid-cols-[2rem_1fr_auto] items-center border-b border-white/5 py-2 transition-colors hover:bg-white/2 sm:grid-cols-[2.5rem_minmax(0,1fr)_auto_auto] sm:py-2.5"> |
| 77 | + <span className="text-right text-neutral-600">{rank}</span> |
| 78 | + |
| 79 | + <a |
| 80 | + href={entry.githubUrl} |
| 81 | + target="_blank" |
| 82 | + rel="noopener noreferrer" |
| 83 | + className="ml-2 truncate text-white transition-colors hover:text-blue-400 sm:ml-4" |
| 84 | + > |
| 85 | + {entry.name} |
| 86 | + <span className="ml-2 hidden text-sm text-neutral-500 sm:inline">{entry.packageName}</span> |
| 87 | + </a> |
| 88 | + |
| 89 | + <span className="ml-4 hidden sm:inline"> |
| 90 | + <ScoreBar score={entry.score} /> |
| 91 | + </span> |
| 92 | + |
| 93 | + <span className="ml-4 text-right"> |
| 94 | + <span className={`${colorClass} font-medium`}>{entry.score}</span> |
| 95 | + <span className="text-neutral-600">/{PERFECT_SCORE}</span> |
| 96 | + </span> |
| 97 | + </div> |
| 98 | + ); |
| 99 | +}; |
| 100 | + |
| 101 | +const LeaderboardPage = async () => { |
| 102 | + const leaderboard = await fetchLeaderboard(); |
| 103 | + const sortedEntries = leaderboard |
| 104 | + ? leaderboard.entries.toSorted((leftEntry, rightEntry) => rightEntry.score - leftEntry.score) |
| 105 | + : []; |
| 106 | + const topScore = sortedEntries[0]?.score ?? 0; |
| 107 | + const [eyes, mouth] = getDoctorFace(topScore); |
| 108 | + const topScoreColor = getScoreColorClass(topScore); |
| 109 | + |
| 110 | + return ( |
| 111 | + <div className="mx-auto min-h-screen w-full max-w-3xl bg-[#0a0a0a] p-6 pb-32 font-mono text-base leading-relaxed text-neutral-300 sm:p-8 sm:pb-40 sm:text-lg"> |
| 112 | + <div className="mb-8"> |
| 113 | + <Link |
| 114 | + href="/" |
| 115 | + className="inline-flex items-center gap-2 text-neutral-500 transition-colors hover:text-neutral-300" |
| 116 | + > |
| 117 | + <img src="/favicon.svg" alt="React Doctor" width={20} height={20} /> |
| 118 | + <span>react-doctor</span> |
| 119 | + </Link> |
| 120 | + </div> |
| 121 | + |
| 122 | + {leaderboard && ( |
| 123 | + <div className="mb-2"> |
| 124 | + <pre className={`${topScoreColor} leading-tight`}> |
| 125 | + {` ${BOX_TOP}\n \u2502 ${eyes} \u2502\n \u2502 ${mouth} \u2502\n ${BOX_BOTTOM}`} |
| 126 | + </pre> |
| 127 | + </div> |
| 128 | + )} |
| 129 | + |
| 130 | + <div className="mb-1 text-xl text-white">Leaderboard</div> |
| 131 | + <div className="mb-2 text-neutral-500">Scores for popular open-source React projects.</div> |
| 132 | + |
| 133 | + {leaderboard && ( |
| 134 | + <div className="mb-8 text-sm text-neutral-600"> |
| 135 | + {sortedEntries.length} repos scanned with v{leaderboard.doctorVersion} on{" "} |
| 136 | + {formatGeneratedAt(leaderboard.generatedAt)}. |
| 137 | + </div> |
| 138 | + )} |
| 139 | + |
| 140 | + {leaderboard ? ( |
| 141 | + <div className="mb-8"> |
| 142 | + {sortedEntries.map((entry, innerIndex) => ( |
| 143 | + <LeaderboardRow key={entry.slug} entry={entry} rank={innerIndex + 1} /> |
| 144 | + ))} |
| 145 | + </div> |
| 146 | + ) : ( |
| 147 | + <div className="mb-8 text-red-400"> |
| 148 | + Could not load the leaderboard right now. Check{" "} |
| 149 | + <a |
| 150 | + href={BENCHMARKS_REPO_URL} |
| 151 | + target="_blank" |
| 152 | + rel="noopener noreferrer" |
| 153 | + className="underline underline-offset-2 hover:text-red-300" |
| 154 | + > |
| 155 | + the benchmarks repo |
| 156 | + </a> |
| 157 | + . |
| 158 | + </div> |
| 159 | + )} |
| 160 | + |
| 161 | + <div className="min-h-[1.4em]" /> |
| 162 | + |
| 163 | + <div className="text-neutral-500">Run it on your codebase:</div> |
| 164 | + <div className="mt-2"> |
| 165 | + <span className="border border-white/20 px-3 py-1.5 text-white">{COMMAND}</span> |
| 166 | + </div> |
| 167 | + |
| 168 | + <div className="min-h-[1.4em]" /> |
| 169 | + <div className="min-h-[1.4em]" /> |
| 170 | + |
| 171 | + <div className="text-neutral-500"> |
| 172 | + {"+ "} |
| 173 | + <a |
| 174 | + href={BENCHMARKS_REPO_URL} |
| 175 | + target="_blank" |
| 176 | + rel="noopener noreferrer" |
| 177 | + className="text-green-400 transition-colors hover:text-green-300 hover:underline" |
| 178 | + > |
| 179 | + Add your project |
| 180 | + </a> |
| 181 | + <span className="text-neutral-600">{" - open a PR on react-doctor-benchmarks"}</span> |
| 182 | + </div> |
| 183 | + </div> |
| 184 | + ); |
| 185 | +}; |
| 186 | + |
| 187 | +export default LeaderboardPage; |
0 commit comments