448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
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<LeaderboardEntry[]> {
|
||
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<string, LeaderboardEntry>();
|
||
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<Params> }): Promise<Metadata> {
|
||
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<Params> }): Promise<ReactElement> {
|
||
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 (
|
||
<>
|
||
<AutoRefresh intervalMs={15000} />
|
||
<div className={styles.page}>
|
||
<div className={styles.stage}>
|
||
<div className={styles.board}>
|
||
<header className={styles.header}>
|
||
<div className={styles.badge}>Leaderboard</div>
|
||
<h1 className={styles.title}>Klasse {className}</h1>
|
||
<p className={styles.subtitle}>
|
||
Wer schnappt sich den Pokal?
|
||
<br /><br /><br /></p>
|
||
</header>
|
||
|
||
{leaderboard.length === 0 ? (
|
||
<div className={styles.emptyState}>
|
||
<p className={styles.emptyHeadline}>Noch keine Bestzeiten</p>
|
||
<p className={styles.emptyText}>
|
||
Sobald die ersten Logins gemessen werden, erscheint hier eure Hall of Fame.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<section className={styles.podium}>
|
||
<article className={`${styles.podiumCard} ${styles.second}`}>
|
||
{podium.second.disqualified && (
|
||
<div className={styles.podiumBadge}>Disqualified</div>
|
||
)}
|
||
<div className={styles.avatar}>{podium.second.initial}</div>
|
||
<h2 className={styles.podiumLabel}>{podium.second.label}</h2>
|
||
<p className={`${styles.podiumName} ${podium.second.muted ? styles.muted : ""}`}>
|
||
{podium.second.username}
|
||
</p>
|
||
<p className={`${styles.podiumScore} ${podium.second.muted ? styles.muted : ""}`}>
|
||
{podium.second.scoreText}
|
||
</p>
|
||
{podium.second.metaText && (
|
||
<div className={`${styles.metaWrapper} ${podium.second.muted ? styles.muted : ""}`}>
|
||
<span className={styles.podiumMeta}>{podium.second.metaText}</span>
|
||
{podium.second.disqualified && (
|
||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{podium.second.dateText && (
|
||
<p className={`${styles.podiumDate} ${podium.second.muted ? styles.muted : ""}`}>
|
||
{podium.second.dateText}
|
||
</p>
|
||
)}
|
||
</article>
|
||
<article className={`${styles.podiumCard} ${styles.first}`}>
|
||
{podium.first.disqualified && (
|
||
<div className={styles.podiumBadge}>Disqualified</div>
|
||
)}
|
||
<div className={styles.crown}>🏆</div>
|
||
<div className={styles.avatar}>{podium.first.initial}</div>
|
||
<h2 className={styles.podiumLabel}>{podium.first.label}</h2>
|
||
<p className={`${styles.podiumName} ${podium.first.muted ? styles.muted : ""}`}>
|
||
{podium.first.username}
|
||
</p>
|
||
<p className={`${styles.podiumScore} ${podium.first.muted ? styles.muted : ""}`}>
|
||
{podium.first.scoreText}
|
||
</p>
|
||
{podium.first.metaText && (
|
||
<div className={`${styles.metaWrapper} ${podium.first.muted ? styles.muted : ""}`}>
|
||
<span className={styles.podiumMeta}>{podium.first.metaText}</span>
|
||
{podium.first.disqualified && (
|
||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{podium.first.dateText && (
|
||
<p className={`${styles.podiumDate} ${podium.first.muted ? styles.muted : ""}`}>
|
||
{podium.first.dateText}
|
||
</p>
|
||
)}
|
||
</article>
|
||
<article className={`${styles.podiumCard} ${styles.third}`}>
|
||
{podium.third.disqualified && (
|
||
<div className={styles.podiumBadge}>Disqualified</div>
|
||
)}
|
||
<div className={styles.avatar}>{podium.third.initial}</div>
|
||
<h2 className={styles.podiumLabel}>{podium.third.label}</h2>
|
||
<p className={`${styles.podiumName} ${podium.third.muted ? styles.muted : ""}`}>
|
||
{podium.third.username}
|
||
</p>
|
||
<p className={`${styles.podiumScore} ${podium.third.muted ? styles.muted : ""}`}>
|
||
{podium.third.scoreText}
|
||
</p>
|
||
{podium.third.metaText && (
|
||
<div className={`${styles.metaWrapper} ${podium.third.muted ? styles.muted : ""}`}>
|
||
<span className={styles.podiumMeta}>{podium.third.metaText}</span>
|
||
{podium.third.disqualified && (
|
||
<span className={styles.diagonalStamp}>Disqualified</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{podium.third.dateText && (
|
||
<p className={`${styles.podiumDate} ${podium.third.muted ? styles.muted : ""}`}>
|
||
{podium.third.dateText}
|
||
</p>
|
||
)}
|
||
</article>
|
||
</section>
|
||
|
||
<section className={styles.listSection}>
|
||
{listColumns.map((column, columnIndex) => (
|
||
<div key={columnIndex} className={styles.listColumn}>
|
||
{column.map((slot) => (
|
||
<div
|
||
key={slot.rank}
|
||
className={`${styles.listItem} ${slot.disqualified ? styles.listDisqualified : ""}`}
|
||
>
|
||
<span className={styles.rankBadge}>{slot.rank}</span>
|
||
<div className={styles.listInfo}>
|
||
<span className={`${styles.listName} ${slot.muted ? styles.muted : ""}`}>
|
||
{slot.username}
|
||
</span>
|
||
{slot.metaText && (
|
||
<div className={styles.listMetaWrapper}>
|
||
<span className={`${styles.listMeta} ${slot.muted ? styles.muted : ""}`}>
|
||
{slot.metaText}
|
||
</span>
|
||
{slot.disqualified && (
|
||
<span className={styles.diagonalStampSmall}>Disqualified</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<span className={`${styles.listDate} ${slot.muted ? styles.muted : ""}`}>
|
||
{slot.dateText}
|
||
</span>
|
||
</div>
|
||
<span className={`${styles.listScore} ${slot.muted ? styles.muted : ""}`}>
|
||
{slot.scoreText}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
<aside className={styles.dopingCard} aria-label="Doping-Kontrolle">
|
||
<div className={styles.dopingHeader}>
|
||
<h2 className={styles.dopingTitle}>Doping-Kontrolle</h2>
|
||
<p className={styles.dopingSubtitle}>
|
||
Meldungen unter {formatMsPerChar(dopingThreshold)} werden geprüft.
|
||
</p>
|
||
</div>
|
||
{dopingCandidates.length === 0 ? (
|
||
<p className={styles.dopingClean}>
|
||
Alles sauber! Keine verdächtigen Tippgeschwindigkeiten.
|
||
</p>
|
||
) : (
|
||
<ul className={styles.dopingList}>
|
||
{dopingCandidates.map(({ entry, perCharMs }) => (
|
||
<li key={entry.username} className={styles.dopingItem}>
|
||
<div className={styles.dopingUser}>
|
||
<span className={styles.dopingInitial}>{initial(entry.username)}</span>
|
||
<div className={styles.dopingInfo}>
|
||
<span className={styles.dopingName}>{entry.username}</span>
|
||
<span className={styles.dopingDate}>{formatDate(entry.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
<span className={styles.dopingStat}>{formatMsPerChar(perCharMs)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|