import type { Metadata } from "next"; import type { ReactElement } from "react"; import AutoRefresh from "@/components/AutoRefresh"; import { prisma } from "@/lib/prisma"; import styles from "./Leaderboard.module.css"; export const dynamic = "force-dynamic"; export const revalidate = 0; type Params = { klasse: string; }; type RankingMethod = "time_password" | "time_per_char"; type LeaderboardEntry = { username: string; durationMs: number; createdAt: Date; passwordLength: number | null; scoreMs: number; }; type PodiumSlot = { label: string; username: string; scoreText: string; metaText: string; dateText: string; initial: string; muted: boolean; disqualified: boolean; }; type ListSlot = { rank: number; username: string; scoreText: string; metaText: string; dateText: string; muted: boolean; disqualified: boolean; }; function parseClass(raw: string): string { return decodeURIComponent(raw); } function getRankingMethod(): RankingMethod { return "time_per_char"; } function getDopingThreshold(): number { const raw = process.env.DOPING_THRESHOLD_MS_PER_CHAR; const parsed = raw === undefined ? Number.NaN : Number.parseFloat(raw); if (!Number.isFinite(parsed) || parsed <= 0) { return 200; } return parsed; } function formatDuration(value: number): string { const seconds = value / 1000; const digits = seconds >= 10 ? 0 : 1; return `${seconds.toFixed(digits)}s`; } function formatMsPerChar(value: number): string { const rounded = value >= 100 ? Math.round(value) : Number(value.toFixed(1)); return `${rounded}ms / Zeichen`; } function formatDate(value: Date): string { return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(value); } function initial(username: string): string { const letter = username.trim().charAt(0); return letter ? letter.toUpperCase() : "?"; } async function loadLeaderboard(className: string, method: RankingMethod): Promise { const attempts = await prisma.loginAttempt.findMany({ where: { success: true, className, loginDurationMs: { not: null }, }, select: { username: true, loginDurationMs: true, createdAt: true, passwordLength: true, }, orderBy: { createdAt: "desc" }, }); const bestByUser = new Map(); for (const attempt of attempts) { if (attempt.loginDurationMs === null) continue; const passwordLength = attempt.passwordLength ?? null; if (method === "time_per_char") { if (passwordLength === null || passwordLength <= 0) continue; } const scoreMs = method === "time_per_char" ? attempt.loginDurationMs / (passwordLength ?? 1) : attempt.loginDurationMs; const existing = bestByUser.get(attempt.username); if ( !existing || scoreMs < existing.scoreMs || (scoreMs === existing.scoreMs && attempt.createdAt.getTime() < existing.createdAt.getTime()) ) { bestByUser.set(attempt.username, { username: attempt.username, durationMs: attempt.loginDurationMs, createdAt: attempt.createdAt, passwordLength, scoreMs, }); } } return Array.from(bestByUser.values()) .sort((a, b) => { if (a.scoreMs !== b.scoreMs) return a.scoreMs - b.scoreMs; if (a.durationMs !== b.durationMs) return a.durationMs - b.durationMs; return a.createdAt.getTime() - b.createdAt.getTime(); }); } function podiumEntry(entry: LeaderboardEntry | undefined, label: string, method: RankingMethod): PodiumSlot { if (!entry) { return { label, username: "Noch frei", scoreText: "–", metaText: "Wartet auf Eintrag", dateText: "", initial: "★", muted: true, disqualified: false, }; } const scoreText = method === "time_per_char" ? `${formatDuration(entry.scoreMs)} / Zeichen` : formatDuration(entry.scoreMs); const metaParts: string[] = []; if (method === "time_per_char") { metaParts.push(`Gesamt ${formatDuration(entry.durationMs)}`); } const passwordLength = entry.passwordLength ?? 0; if (passwordLength > 0) { metaParts.push(`${passwordLength} Zeichen`); } const disqualified = passwordLength > 0 && passwordLength < 11; return { label, username: entry.username, scoreText, metaText: metaParts.join(" · "), dateText: formatDate(entry.createdAt), initial: initial(entry.username), muted: false, disqualified, }; } function listEntry(entry: LeaderboardEntry | undefined, rank: number, method: RankingMethod): ListSlot { if (!entry) { return { rank, username: "Noch frei", scoreText: "–", metaText: "", dateText: "Wartet auf Eintrag", muted: true, disqualified: false, }; } const scoreText = method === "time_per_char" ? `${formatDuration(entry.scoreMs)} / Zeichen` : formatDuration(entry.scoreMs); const metaParts: string[] = []; if (method === "time_per_char") { metaParts.push(`Gesamt ${formatDuration(entry.durationMs)}`); } const passwordLength = entry.passwordLength ?? 0; if (passwordLength > 0) { metaParts.push(`${passwordLength} Zeichen`); } const disqualified = passwordLength > 0 && passwordLength < 11; return { rank, username: entry.username, scoreText, metaText: metaParts.join(" · "), dateText: formatDate(entry.createdAt), muted: false, disqualified, }; } export async function generateMetadata({ params }: { params: Promise }): Promise { const { klasse } = await params; const className = parseClass(klasse); return { title: `Leaderboard ${className}`, description: `Arcade-Highscore für die Klasse ${className}.`, }; } export default async function LeaderboardPage({ params }: { params: Promise }): Promise { const { klasse } = await params; const className = parseClass(klasse); const rankingMethod = getRankingMethod(); const leaderboardEntries = await loadLeaderboard(className, rankingMethod); const leaderboard = leaderboardEntries.slice(0, 12); const champion = leaderboard[0]; const second = leaderboard[1]; const third = leaderboard[2]; const podium = { first: podiumEntry(champion, "1. Platz", rankingMethod), second: podiumEntry(second, "2. Platz", rankingMethod), third: podiumEntry(third, "3. Platz", rankingMethod), }; const totalListSlots = 9; const listSlots = Array.from({ length: totalListSlots }, (_, index) => { const rank = index + 4; return listEntry(leaderboard[rank - 1], rank, rankingMethod); }); const columnSize = Math.ceil(listSlots.length / 3); const listColumns = Array.from({ length: 3 }, (_, columnIndex) => listSlots.slice(columnIndex * columnSize, (columnIndex + 1) * columnSize) ).filter((column) => column.length > 0); const dopingThreshold = getDopingThreshold(); const dopingCandidates: Array<{ entry: LeaderboardEntry; perCharMs: number }> = leaderboardEntries .filter((entry) => { const length = entry.passwordLength ?? 0; if (length <= 0) return false; const perChar = entry.durationMs / length; return perChar < dopingThreshold; }) .map((entry) => ({ entry, perCharMs: entry.durationMs / Math.max(entry.passwordLength ?? 1, 1), })) .sort((a, b) => a.perCharMs - b.perCharMs); return ( <>
Leaderboard

Klasse {className}

Wer schnappt sich den Pokal?


{leaderboard.length === 0 ? (

Noch keine Bestzeiten

Sobald die ersten Logins gemessen werden, erscheint hier eure Hall of Fame.

) : ( <>
{podium.second.disqualified && (
Disqualified
)}
{podium.second.initial}

{podium.second.label}

{podium.second.username}

{podium.second.scoreText}

{podium.second.metaText && (
{podium.second.metaText} {podium.second.disqualified && ( Disqualified )}
)} {podium.second.dateText && (

{podium.second.dateText}

)}
{podium.first.disqualified && (
Disqualified
)}
🏆
{podium.first.initial}

{podium.first.label}

{podium.first.username}

{podium.first.scoreText}

{podium.first.metaText && (
{podium.first.metaText} {podium.first.disqualified && ( Disqualified )}
)} {podium.first.dateText && (

{podium.first.dateText}

)}
{podium.third.disqualified && (
Disqualified
)}
{podium.third.initial}

{podium.third.label}

{podium.third.username}

{podium.third.scoreText}

{podium.third.metaText && (
{podium.third.metaText} {podium.third.disqualified && ( Disqualified )}
)} {podium.third.dateText && (

{podium.third.dateText}

)}
{listColumns.map((column, columnIndex) => (
{column.map((slot) => (
{slot.rank}
{slot.username} {slot.metaText && (
{slot.metaText} {slot.disqualified && ( Disqualified )}
)} {slot.dateText}
{slot.scoreText}
))}
))}
)}
); }