feat: add time controls, correspondence games, and tournament mode

**Zeitmodi (Blitz/Rapid):**
- 5, 10, 30 Min Zeitkontrolle bei Spielerstellung
- Server-seitige Uhren: Zeit wird bei jedem Zug abgezogen
- Timeout-Erkennung via claimTimeout Socket-Event
- Schachuhr-Anzeige in GamePage (rot bei < 30s)
- Turnierspiele erhalten automatisch Zeitkontrolle

**Tagespartien (Correspondence):**
- Neue DB-Tabelle correspondence_game
- REST API: erstellen, beitreten, Zug machen, aufgeben
- Seite /correspondence: Liste aktiver Tagespartien
- Seite /correspondence/[code]: Spielen mit interaktivem Brett
- Einladungslink teilen, Gegner tritt via Link bei

**Turniermodus (Round-Robin):**
- Neue DB-Tabellen: tournament, tournament_player, tournament_round
- Round-Robin Paarungen mit Circle-Methode
- Turnier erstellen: /tournament/create
- Turnierliste: /tournament
- Turnierseite /tournament/[code]: Standings, Runden, Spiele
- Automatische Aktivisierung der nächsten Runde
- Turnierspiele werden als normale activeGames erstellt
- Ergebnisse aktualisieren Punkte automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-14 09:44:35 +02:00
parent 09e5271dfb
commit aeb5fe313e
25 changed files with 1653 additions and 16 deletions

View File

@@ -0,0 +1,249 @@
"use client";
import { useSession } from "@/context/session";
import {
fetchCorrespondenceGame,
joinCorrespondenceGame,
makeCorrespondenceMove,
resignCorrespondenceGame,
} from "@/lib/correspondence";
import type { CorrespondenceGame } from "@michess/types";
import { Chess } from "chess.js";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Chessboard } from "react-chessboard";
import type { Square } from "chess.js";
import { APP_URL } from "@/config";
export default function CorrespondenceGamePage() {
const { user } = useSession();
const params = useParams();
const code = params.code as string;
const [game, setGame] = useState<CorrespondenceGame | null>(null);
const [chess] = useState(() => new Chess());
const [fen, setFen] = useState("start");
const [loading, setLoading] = useState(true);
const [moveFrom, setMoveFrom] = useState<string | null>(null);
const [joining, setJoining] = useState(false);
const [resigning, setResigning] = useState(false);
const [copied, setCopied] = useState(false);
const loadGame = useCallback(async () => {
const g = await fetchCorrespondenceGame(code);
if (!g) return;
setGame(g);
chess.reset();
if (g.pgn) chess.loadPgn(g.pgn);
setFen(chess.fen());
}, [code, chess]);
useEffect(() => {
loadGame().then(() => setLoading(false));
}, [loadGame]);
const isWhite = game?.white?.id === user?.id;
const isBlack = game?.black?.id === user?.id;
const isPlayer = isWhite || isBlack;
const myTurn = isPlayer && !game?.winner && (
(chess.turn() === "w" && isWhite) || (chess.turn() === "b" && isBlack)
);
async function handleSquareClick(square: Square) {
if (!myTurn || game?.winner) return;
if (moveFrom === null) {
const piece = chess.get(square);
if (!piece) return;
const isMyPiece = (chess.turn() === "w" && piece.color === "w") || (chess.turn() === "b" && piece.color === "b");
if (!isMyPiece) return;
setMoveFrom(square);
return;
}
if (moveFrom === square) {
setMoveFrom(null);
return;
}
const updated = await makeCorrespondenceMove(code, moveFrom, square);
setMoveFrom(null);
if (updated) {
setGame(updated);
chess.reset();
if (updated.pgn) chess.loadPgn(updated.pgn);
setFen(chess.fen());
} else {
// Try as from-square instead
const piece = chess.get(square);
if (piece) setMoveFrom(square);
}
}
function handleDrop(from: Square, to: Square) {
if (!myTurn || game?.winner) return false;
makeCorrespondenceMove(code, from, to).then((updated) => {
if (updated) {
setGame(updated);
chess.reset();
if (updated.pgn) chess.loadPgn(updated.pgn);
setFen(chess.fen());
}
});
return true;
}
async function handleJoin() {
if (!user?.id) return;
setJoining(true);
const updated = await joinCorrespondenceGame(code);
if (updated) setGame(updated);
setJoining(false);
}
async function handleResign() {
if (!isPlayer || game?.winner) return;
setResigning(true);
const updated = await resignCorrespondenceGame(code);
if (updated) setGame(updated);
setResigning(false);
}
function copyInvite() {
navigator.clipboard.writeText(`${APP_URL}/correspondence/${code}`).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}
function getMoveList() {
const history = chess.history({ verbose: true });
const pairs: { w: string; b?: string }[] = [];
for (let i = 0; i < history.length; i += 2) {
pairs.push({ w: history[i].san, b: history[i + 1]?.san });
}
return pairs;
}
if (loading) {
return (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!game) {
return <div className="flex justify-center py-16 text-error">Partie nicht gefunden.</div>;
}
const boardOrientation = isBlack ? "black" : "white";
const winnerName = game.winner === "white" ? game.white?.name : game.winner === "black" ? game.black?.name : null;
return (
<div className="flex flex-wrap justify-center gap-6 px-4 py-8">
<div>
<Chessboard
boardWidth={480}
position={fen}
boardOrientation={boardOrientation}
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
isDraggablePiece={({ piece }) =>
isPlayer && !game.winner &&
((chess.turn() === "w" && piece.startsWith("w") && isWhite) ||
(chess.turn() === "b" && piece.startsWith("b") && isBlack))
}
onSquareClick={handleSquareClick}
onPieceDrop={handleDrop}
customSquareStyles={moveFrom ? { [moveFrom]: { background: "rgba(255,255,0,0.4)" } } : {}}
/>
</div>
<div className="flex flex-col gap-4 max-w-sm w-full">
{/* Players */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4 gap-1">
<div className="flex justify-between items-center">
<span className="font-bold">{game.white?.name ?? "?"}</span>
<span className="text-xs opacity-60">Weiß</span>
</div>
<div className="text-center text-sm opacity-50">vs</div>
<div className="flex justify-between items-center">
<span className="font-bold">{game.black?.name ?? "Wartet auf Gegner"}</span>
<span className="text-xs opacity-60">Schwarz</span>
</div>
</div>
</div>
{/* Status / winner banner */}
{game.winner ? (
<div className="alert alert-success">
<span>
{game.winner === "draw"
? "Remis!"
: `${winnerName} gewinnt (${game.endReason === "checkmate" ? "Matt" : game.endReason === "resign" ? "Aufgabe" : game.endReason})!`}
</span>
</div>
) : !game.black?.id ? (
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4 gap-2">
<p className="text-sm">Warte auf Gegner. Einladungslink:</p>
<button
className={"btn btn-sm btn-outline" + (copied ? " btn-success" : "")}
onClick={copyInvite}
>
{copied ? "Kopiert!" : `${APP_URL.replace(/^https?:\/\//, "")}/correspondence/${code}`}
</button>
{!isPlayer && user?.id && (
<button
className={"btn btn-primary btn-sm" + (joining ? " loading" : "")}
onClick={handleJoin}
disabled={joining}
>
Beitreten
</button>
)}
</div>
</div>
) : (
<div className="alert alert-info">
<span>
{myTurn ? "Du bist dran!" : `${chess.turn() === "w" ? game.white?.name : game.black?.name} ist dran.`}
</span>
</div>
)}
{/* Move list */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h3 className="font-semibold text-sm mb-1">Züge</h3>
<div className="max-h-48 overflow-y-auto">
<table className="table table-xs w-full">
<tbody>
{getMoveList().map((pair, i) => (
<tr key={i}>
<td className="opacity-50 w-6">{i + 1}.</td>
<td>{pair.w}</td>
<td>{pair.b ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Resign button */}
{isPlayer && !game.winner && game.black?.id && (
<button
className={"btn btn-error btn-outline btn-sm" + (resigning ? " loading" : "")}
onClick={handleResign}
disabled={resigning}
>
Aufgeben
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useSession } from "@/context/session";
import { createCorrespondenceGame, fetchMyCorrespondenceGames } from "@/lib/correspondence";
import type { CorrespondenceGame } from "@michess/types";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function CorrespondencePage() {
const { user } = useSession();
const [games, setGames] = useState<CorrespondenceGame[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [daysPerMove, setDaysPerMove] = useState(3);
useEffect(() => {
if (!user?.id) return;
fetchMyCorrespondenceGames().then((g) => {
setGames(g);
setLoading(false);
});
}, [user?.id]);
async function handleCreate() {
if (!user?.id) return;
setCreating(true);
const game = await createCorrespondenceGame(daysPerMove);
if (game) {
setGames((prev) => [game, ...prev]);
}
setCreating(false);
}
function getTurnLabel(game: CorrespondenceGame): string {
if (game.winner) {
if (game.winner === "draw") return "Remis";
return `${game.winner === "white" ? game.white?.name : game.black?.name} hat gewonnen`;
}
if (!game.black?.id) return "Warte auf Gegner...";
const chess = new (require("chess.js").Chess)();
if (game.pgn) chess.loadPgn(game.pgn);
const turn = chess.turn();
const turnName = turn === "w" ? game.white?.name : game.black?.name;
if (turn === "w" && game.white?.id === user?.id) return "Du bist dran";
if (turn === "b" && game.black?.id === user?.id) return "Du bist dran";
return `${turnName} ist dran`;
}
if (!user?.id) {
return (
<div className="flex flex-col items-center py-16 gap-4">
<p className="text-lg">Bitte einloggen um Tagespartien zu spielen.</p>
<Link href="/auth/login" className="btn btn-primary">Einloggen</Link>
</div>
);
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tagespartien</h1>
</div>
<div className="card bg-base-200 shadow">
<div className="card-body gap-3">
<h2 className="card-title text-lg">Neue Partie erstellen</h2>
<div className="flex items-center gap-3">
<label className="text-sm">Tage pro Zug:</label>
<select
className="select select-bordered select-sm"
value={daysPerMove}
onChange={(e) => setDaysPerMove(parseInt(e.target.value))}
>
<option value={1}>1 Tag</option>
<option value={2}>2 Tage</option>
<option value={3}>3 Tage</option>
<option value={5}>5 Tage</option>
<option value={7}>7 Tage</option>
</select>
<button
className={"btn btn-primary btn-sm" + (creating ? " loading" : "")}
onClick={handleCreate}
disabled={creating}
>
Erstellen
</button>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : games.length === 0 ? (
<div className="text-center opacity-60 py-8">Keine aktiven Tagespartien.</div>
) : (
<div className="flex flex-col gap-3">
{games.map((game) => (
<div key={game.code} className="card bg-base-200 shadow-sm">
<div className="card-body flex-row items-center justify-between py-3 px-4">
<div>
<div className="font-semibold">
{game.white?.name ?? "?"} vs {game.black?.name ?? "Warte auf Gegner"}
</div>
<div className="text-sm opacity-70">{getTurnLabel(game)}</div>
</div>
<Link href={`/correspondence/${game.code}`} className="btn btn-sm btn-primary">
Spielen
</Link>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -14,11 +14,11 @@ export default function Home() {
<div className="flex flex-col gap-8 w-full max-w-5xl px-4 py-8">
{/* Hauptaktionen */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card bg-base-200 border-2 border-primary shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title text-primary"> Spiel erstellen</h2>
<h2 className="card-title text-primary">Spiel erstellen</h2>
<p className="text-sm opacity-70">Erstelle eine Partie und teile den Einladungslink mit Freunden</p>
<div className="mt-2">
<CreateGame />
@@ -28,7 +28,7 @@ export default function Home() {
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">🤖 Gegen KI spielen</h2>
<h2 className="card-title">Gegen KI spielen</h2>
<p className="text-sm opacity-70">Fordere Stockfish auf verschiedenen Stufen heraus von Anfänger bis Meister</p>
<div className="mt-auto pt-4">
<Link href="/ai" className="btn btn-secondary w-full">Jetzt spielen</Link>
@@ -40,7 +40,7 @@ export default function Home() {
<div className="card-body gap-3">
{isLoggedIn ? (
<>
<h2 className="card-title">👥 Freunde</h2>
<h2 className="card-title">Freunde</h2>
<p className="text-sm opacity-70">Freunde hinzufügen, verwalten und zu einer Partie einladen</p>
<div className="mt-auto pt-4">
<Link href="/friends" className="btn btn-outline w-full">Freunde verwalten</Link>
@@ -48,7 +48,7 @@ export default function Home() {
</>
) : (
<>
<h2 className="card-title">🔗 Einladung annehmen</h2>
<h2 className="card-title">Einladung annehmen</h2>
<p className="text-sm opacity-70 mb-2">Einladungslink oder Code eingeben um beizutreten</p>
<JoinGame />
</>
@@ -56,6 +56,26 @@ export default function Home() {
</div>
</div>
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Tagespartien</h2>
<p className="text-sm opacity-70">Spiele in deinem eigenen Tempo Züge bis zu 3 Tage Zeit</p>
<div className="mt-auto pt-4">
<Link href="/correspondence" className="btn btn-outline w-full">Tagespartien</Link>
</div>
</div>
</div>
<div className="card bg-base-200 shadow-lg">
<div className="card-body gap-3">
<h2 className="card-title">Turnier</h2>
<p className="text-sm opacity-70">Spiele Round-Robin Turniere gegen mehrere Gegner</p>
<div className="mt-auto pt-4">
<Link href="/tournament" className="btn btn-outline w-full">Turniere</Link>
</div>
</div>
</div>
</div>
{/* Öffentliche Spiele + Beitreten */}

View File

@@ -0,0 +1,211 @@
"use client";
import { useSession } from "@/context/session";
import { fetchTournament, joinTournament, startTournament } from "@/lib/tournament";
import type { Tournament, TournamentRound } from "@michess/types";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
export default function TournamentDetailPage() {
const { user } = useSession();
const params = useParams();
const code = params.code as string;
const [tournament, setTournament] = useState<Tournament | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function load() {
const t = await fetchTournament(code);
setTournament(t);
}
useEffect(() => {
load().then(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
// Auto-refresh every 10 seconds when active
useEffect(() => {
if (tournament?.status === "active") {
intervalRef.current = setInterval(load, 10000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournament?.status]);
async function handleJoin() {
if (!user?.id) return;
setActionLoading(true);
const updated = await joinTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleStart() {
if (!user?.id) return;
setActionLoading(true);
const updated = await startTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
function statusBadge(status: Tournament["status"]) {
if (status === "waiting") return <span className="badge badge-warning">Wartet auf Spieler</span>;
if (status === "active") return <span className="badge badge-success">Aktiv</span>;
return <span className="badge badge-ghost">Beendet</span>;
}
function resultLabel(r: TournamentRound) {
if (!r.result) return <span className="opacity-40"></span>;
if (r.result === "draw") return <span className="badge badge-ghost badge-sm">Remis</span>;
return (
<span className="badge badge-success badge-sm">
{r.result === "white" ? r.whiteName : r.blackName}
</span>
);
}
if (loading) {
return (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!tournament) {
return <div className="flex justify-center py-16 text-error">Turnier nicht gefunden.</div>;
}
const isHost = tournament.hostId === user?.id;
const isJoined = tournament.players?.some((p) => p.userId === user?.id);
const canStart = isHost && tournament.status === "waiting" && (tournament.players?.length ?? 0) >= 2;
// Group rounds
const rounds: Record<number, TournamentRound[]> = {};
for (const r of tournament.rounds ?? []) {
if (!rounds[r.round!]) rounds[r.round!] = [];
rounds[r.round!].push(r);
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{tournament.name}</h1>
<div className="flex items-center gap-2 mt-1">
{statusBadge(tournament.status)}
{tournament.timeControl
? <span className="text-sm opacity-60">{tournament.timeControl} Min/Spieler</span>
: <span className="text-sm opacity-60">Keine Uhr</span>}
{tournament.status === "active" && (
<span className="text-sm opacity-60">Runde {tournament.currentRound}/{tournament.totalRounds}</span>
)}
</div>
</div>
<div className="flex gap-2">
{tournament.status === "waiting" && user?.id && !isJoined && (
<button
className={"btn btn-secondary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleJoin}
disabled={actionLoading}
>
Beitreten
</button>
)}
{canStart && (
<button
className={"btn btn-primary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleStart}
disabled={actionLoading}
>
Turnier starten
</button>
)}
</div>
</div>
{/* Standings */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h2 className="font-bold text-lg mb-2">Tabelle</h2>
{!tournament.players?.length ? (
<p className="text-sm opacity-60">Noch keine Spieler.</p>
) : (
<table className="table table-sm w-full">
<thead>
<tr>
<th>#</th>
<th>Spieler</th>
<th className="text-right">Punkte</th>
<th className="text-right">Partien</th>
</tr>
</thead>
<tbody>
{tournament.players.map((p, i) => (
<tr key={p.userId} className={p.userId === user?.id ? "bg-base-300" : ""}>
<td>{i + 1}</td>
<td>{p.userName}</td>
<td className="text-right font-mono">{p.score}</td>
<td className="text-right opacity-60">{p.gamesPlayed}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Rounds */}
{Object.keys(rounds).length > 0 && (
<div className="flex flex-col gap-4">
{Object.entries(rounds).map(([roundNum, games]) => (
<div key={roundNum} className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h3 className="font-semibold mb-2">
Runde {roundNum}
{parseInt(roundNum) === tournament.currentRound && tournament.status === "active" && (
<span className="badge badge-success badge-sm ml-2">Aktuell</span>
)}
</h3>
<div className="flex flex-col gap-2">
{games.map((g) => {
const isMyGame = g.whiteId === user?.id || g.blackId === user?.id;
return (
<div key={g.id} className="flex items-center justify-between gap-2 text-sm">
<span>
<span className={g.whiteId === user?.id ? "font-bold" : ""}>{g.whiteName}</span>
<span className="opacity-40 mx-2">vs</span>
<span className={g.blackId === user?.id ? "font-bold" : ""}>{g.blackName}</span>
</span>
<div className="flex items-center gap-2">
{resultLabel(g)}
{isMyGame && g.gameCode && !g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-primary">
Spielen
</Link>
)}
{g.gameCode && g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
Ansehen
</Link>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useSession } from "@/context/session";
import { createTournament } from "@/lib/tournament";
import { useRouter } from "next/navigation";
import type { FormEvent } from "react";
import { useState } from "react";
export default function CreateTournamentPage() {
const { user } = useSession();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!user?.id) return;
setLoading(true);
setError(null);
const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem("name") as HTMLInputElement).value.trim();
const timeControlVal = parseInt((form.elements.namedItem("timeControl") as HTMLSelectElement).value);
const maxPlayers = parseInt((form.elements.namedItem("maxPlayers") as HTMLSelectElement).value);
if (!name) {
setError("Bitte einen Namen eingeben.");
setLoading(false);
return;
}
const tournament = await createTournament(name, timeControlVal > 0 ? timeControlVal : undefined, maxPlayers);
if (tournament?.code) {
router.push(`/tournament/${tournament.code}`);
} else {
setError("Turnier konnte nicht erstellt werden.");
setLoading(false);
}
}
if (!user?.id) {
return (
<div className="flex flex-col items-center py-16 gap-4">
<p className="text-lg">Bitte einloggen um ein Turnier zu erstellen.</p>
</div>
);
}
return (
<div className="flex flex-col gap-6 w-full max-w-md px-4 py-8">
<h1 className="text-2xl font-bold">Turnier erstellen</h1>
<form className="card bg-base-200 shadow" onSubmit={handleSubmit}>
<div className="card-body gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Name</span>
</label>
<input
type="text"
name="name"
className="input input-bordered"
placeholder="z.B. MiChess Open 2026"
maxLength={128}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Bedenkzeit</span>
</label>
<select name="timeControl" className="select select-bordered">
<option value="0">Keine Uhr</option>
<option value="5">5 Min (Blitz)</option>
<option value="10">10 Min (Blitz)</option>
<option value="30">30 Min (Rapid)</option>
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Max. Spieler</span>
</label>
<select name="maxPlayers" className="select select-bordered" defaultValue="8">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="12">12</option>
<option value="16">16</option>
</select>
</div>
{error && <div className="alert alert-error text-sm">{error}</div>}
<button
type="submit"
className={"btn btn-primary" + (loading ? " loading" : "")}
disabled={loading}
>
Erstellen
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useSession } from "@/context/session";
import { fetchTournaments, joinTournament } from "@/lib/tournament";
import type { Tournament } from "@michess/types";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function TournamentPage() {
const { user } = useSession();
const [tournaments, setTournaments] = useState<Tournament[]>([]);
const [loading, setLoading] = useState(true);
const [joiningCode, setJoiningCode] = useState<string | null>(null);
useEffect(() => {
fetchTournaments().then((t) => {
setTournaments(t);
setLoading(false);
});
}, []);
async function handleJoin(code: string) {
if (!user?.id) return;
setJoiningCode(code);
const updated = await joinTournament(code);
if (updated) {
setTournaments((prev) => prev.map((t) => (t.code === code ? updated : t)));
}
setJoiningCode(null);
}
function statusBadge(status: Tournament["status"]) {
if (status === "waiting") return <span className="badge badge-warning badge-sm">Wartet</span>;
if (status === "active") return <span className="badge badge-success badge-sm">Aktiv</span>;
return <span className="badge badge-ghost badge-sm">Beendet</span>;
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Turniere</h1>
{user?.id && (
<Link href="/tournament/create" className="btn btn-primary btn-sm">
Turnier erstellen
</Link>
)}
</div>
{loading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : tournaments.length === 0 ? (
<div className="text-center opacity-60 py-8">Keine aktiven Turniere.</div>
) : (
<div className="flex flex-col gap-3">
{tournaments.map((t) => {
const isJoined = t.players?.some((p) => p.userId === user?.id);
return (
<div key={t.code} className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-bold">{t.name}</span>
{statusBadge(t.status)}
</div>
<div className="text-xs opacity-60">
{t.players?.length ?? 0}/{t.maxPlayers} Spieler
{t.timeControl ? ` · ${t.timeControl} Min` : " · Keine Uhr"}
{t.status === "active" ? ` · Runde ${t.currentRound}/${t.totalRounds}` : ""}
</div>
</div>
<div className="flex gap-2">
{t.status === "waiting" && user?.id && !isJoined && (
<button
className={"btn btn-sm btn-secondary" + (joiningCode === t.code ? " loading" : "")}
onClick={() => handleJoin(t.code!)}
disabled={joiningCode === t.code}
>
Beitreten
</button>
)}
<Link href={`/tournament/${t.code}`} className="btn btn-sm btn-outline">
Details
</Link>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -54,6 +54,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const [navFen, setNavFen] = useState<string | null>(null);
const [navIndex, setNavIndex] = useState<number | null>(null);
const [clockWhite, setClockWhite] = useState<number | null>(null);
const [clockBlack, setClockBlack] = useState<number | null>(null);
const clockRef = useRef<{ whiteTimeMs: number; blackTimeMs: number; clientLastMoveAt: number } | null>(null);
const lobbyRef = useRef(lobby);
const [playBtnLoading, setPlayBtnLoading] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [chatMessages, setChatMessages] = useState<Message[]>([
@@ -65,6 +70,30 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const chatListRef = useRef<HTMLUListElement>(null);
const moveListRef = useRef<HTMLDivElement>(null);
// Keep lobbyRef in sync so clock interval can read latest lobby without re-registering
useEffect(() => { lobbyRef.current = lobby; }, [lobby]);
// Clock interval — only active when timeControl is set
useEffect(() => {
if (!initialLobby.timeControl) return;
const interval = setInterval(() => {
if (!clockRef.current) return;
const l = lobbyRef.current;
if (l.endReason || l.winner) return;
const elapsed = Date.now() - clockRef.current.clientLastMoveAt;
const turn = l.actualGame.turn();
const white = turn === "w" ? Math.max(0, clockRef.current.whiteTimeMs - elapsed) : clockRef.current.whiteTimeMs;
const black = turn === "b" ? Math.max(0, clockRef.current.blackTimeMs - elapsed) : clockRef.current.blackTimeMs;
setClockWhite(white);
setClockBlack(black);
if ((turn === "w" && white <= 0) || (turn === "b" && black <= 0)) {
socket.emit("claimTimeout");
}
}, 100);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialLobby.timeControl]);
const [abandonSeconds, setAbandonSeconds] = useState(60);
useEffect(() => {
if (
@@ -114,7 +143,14 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
updateCustomSquares,
makeMove,
setNavFen,
setNavIndex
setNavIndex,
onClockUpdate: ({ whiteTimeMs, blackTimeMs, lastMoveAt }) => {
clockRef.current = { whiteTimeMs, blackTimeMs, clientLastMoveAt: Date.now() };
setClockWhite(whiteTimeMs);
setClockBlack(blackTimeMs);
// suppress unused-warning on lastMoveAt — stored via clientLastMoveAt above
void lastMoveAt;
}
});
return () => {
@@ -503,6 +539,12 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
};
}
function formatClock(ms: number | null): string {
if (ms === null) return "--:--";
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function claimAbandoned(type: "win" | "draw") {
if (
lobby.side === "s" ||
@@ -568,6 +610,29 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
{getPlayerHtml("bottom")}
</div>
{initialLobby.timeControl && (
<div className="flex flex-col items-end justify-between gap-1 pl-2">
{/* Top clock = opponent */}
<div className={
"font-mono text-lg font-bold px-2 py-1 rounded " +
(lobby.side === "b"
? (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300")
: (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300"))
}>
{lobby.side === "b" ? formatClock(clockWhite) : formatClock(clockBlack)}
</div>
{/* Bottom clock = self */}
<div className={
"font-mono text-lg font-bold px-2 py-1 rounded " +
(lobby.side === "b"
? (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300")
: (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300"))
}>
{lobby.side === "b" ? formatClock(clockBlack) : formatClock(clockWhite)}
</div>
</div>
)}
<div className="flex flex-1 flex-col gap-1">
<div className="mb-2 flex w-full flex-col items-end gap-1">
{lobby.endReason ? "Archived link:" : "Invite friends:"}

View File

@@ -16,6 +16,7 @@ export function initSocket(
makeMove: Function;
setNavFen: Dispatch<SetStateAction<string | null>>;
setNavIndex: Dispatch<SetStateAction<number | null>>;
onClockUpdate?: (data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => void;
}
) {
socket.on("connect", () => {
@@ -34,6 +35,18 @@ export function initSocket(
actions.updateLobby({ type: "updateLobby", payload: latestGame });
syncSide(user, latestGame, lobby, actions);
if (latestGame.whiteTimeMs !== undefined && latestGame.lastMoveAt && actions.onClockUpdate) {
actions.onClockUpdate({
whiteTimeMs: latestGame.whiteTimeMs,
blackTimeMs: latestGame.blackTimeMs ?? 0,
lastMoveAt: latestGame.lastMoveAt
});
}
});
socket.on("clockUpdate", (data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => {
actions.onClockUpdate?.(data);
});
socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
@@ -75,6 +88,8 @@ export function initSocket(
}
} else if (reason === "checkmate") {
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
} else if (reason === "timeout") {
m.message = `Zeit abgelaufen! ${winnerSide === "white" ? "Weiß" : "Schwarz"} gewinnt.`;
} else {
let message = "The game has ended in a draw";
if (reason === "repetition") {

View File

@@ -19,10 +19,11 @@ export default function CreateGame() {
const target = e.target as HTMLFormElement;
const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement)
.value;
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement).value;
const timeControlVal = parseInt((target.elements.namedItem("createTimeControl") as HTMLSelectElement).value);
const timeControl = timeControlVal > 0 ? timeControlVal : undefined;
const game = await createGame(startingSide, unlisted.checked);
const game = await createGame(startingSide, unlisted.checked, timeControl);
if (game) {
router.push(`/${game.code}`);
@@ -38,6 +39,16 @@ export default function CreateGame() {
<span className="label-text text-sm">Nur per Einladung</span>
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" name="createUnlisted" id="createUnlisted" />
</label>
<select
className="select select-bordered select-sm w-full"
name="createTimeControl"
id="createTimeControl"
>
<option value="0">Keine Uhr</option>
<option value="5">5 Min (Blitz)</option>
<option value="10">10 Min (Blitz)</option>
<option value="30">30 Min (Rapid)</option>
</select>
<div className="flex gap-2">
<select
className="select select-bordered select-sm flex-1"

View File

@@ -0,0 +1,46 @@
import { API_URL } from "@/config";
import type { CorrespondenceGame } from "@michess/types";
export const fetchMyCorrespondenceGames = async (): Promise<CorrespondenceGame[]> => {
const res = await fetch(`${API_URL}/v1/correspondence`, { credentials: "include" });
if (!res.ok) return [];
return res.json();
};
export const fetchCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}`, { credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const joinCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/join`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const makeCorrespondenceMove = async (code: string, from: string, to: string, promotion?: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/move`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to, promotion })
});
if (!res.ok) return null;
return res.json();
};
export const resignCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence/${code}/resign`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const createCorrespondenceGame = async (daysPerMove = 3): Promise<CorrespondenceGame | null> => {
const res = await fetch(`${API_URL}/v1/correspondence`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ daysPerMove })
});
if (!res.ok) return null;
return res.json();
};

View File

@@ -1,7 +1,7 @@
import { API_URL } from "@/config";
import type { Game } from "@michess/types";
export const createGame = async (side: string, unlisted: boolean) => {
export const createGame = async (side: string, unlisted: boolean, timeControl?: number) => {
try {
const res = await fetch(`${API_URL}/v1/games`, {
method: "POST",
@@ -9,7 +9,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ side, unlisted }),
body: JSON.stringify({ side, unlisted, timeControl: timeControl || null }),
cache: "no-store"
});

View File

@@ -0,0 +1,36 @@
import { API_URL } from "@/config";
import type { Tournament } from "@michess/types";
export const fetchTournaments = async (): Promise<Tournament[]> => {
const res = await fetch(`${API_URL}/v1/tournaments`, { cache: "no-store" });
if (!res.ok) return [];
return res.json();
};
export const fetchTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}`, { cache: "no-store" });
if (!res.ok) return null;
return res.json();
};
export const createTournament = async (name: string, timeControl?: number, maxPlayers?: number): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments`, {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, timeControl, maxPlayers })
});
if (!res.ok) return null;
return res.json();
};
export const joinTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/join`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};
export const startTournament = async (code: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/start`, { method: "POST", credentials: "include" });
if (!res.ok) return null;
return res.json();
};