docker-fobi/docker-compose/logintrainer/src/app/leaderboard/[klasse]/page.tsx
Jesko Anschütz ea52d39e6c logintrainer
2025-11-04 21:35:45 +01:00

448 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}