Apply velvet-table redesign, fix game lifecycle and history bugs

Frontend:
- Dark green/gold "velvet table" visual redesign across the whole app
  (Auth, Lobby, GameList, GameTable, History, GameOver, modals), with
  Playfair Display/DM Sans typography and a centralized Tailwind palette.
- Desktop game table fit-scales to fill the window; mobile gets
  overlapping hand/trick layouts and larger touch-friendly cards.
- Standings sidebar now groups completed rounds by series with a
  per-series subtotal row, struck-through tips on missed bids.
- History page rewritten into a scoreboard-style detail view (player
  totals beside names, series grouped 2-up on desktop / stacked on
  mobile) and gained game names, completed/abandoned status, and a
  button to reopen a prematurely-ended game back into the lobby.

Backend:
- Fix started games being deleted from memory (and vanishing from
  everyone's lobby) when all players disconnect; only `end_game` tears
  down a started game now.
- Fix a crash writing a timezone-aware datetime into the naive
  `ended_at` Postgres column.
- Add `reopen_game`/`restore_game` to un-end a prematurely-ended game
  from history and resume it from the lobby.
- Let any seated player end an abandoned game once the host is
  offline, not just the host, so the game isn't stuck forever.
- Expose SERIES_PER_GAME/ROUNDS_PER_SERIES as named constants on the
  engine so the persistence layer derives game-completion rules from
  bridzik.py instead of re-encoding them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-01 00:11:42 +02:00
co-authored by Claude Sonnet 5
parent 30c32b7714
commit 2c2f07c2ec
28 changed files with 1472 additions and 395 deletions
+264 -80
View File
@@ -4,17 +4,26 @@ import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules';
import { computeTotal } from '../lib/standings';
import { useIsDesktop } from '../lib/useIsDesktop';
import { useFitScale } from '../lib/useFitScale';
import Hand from '../components/Hand';
import GuessControls from '../components/GuessControls';
import Trick from '../components/Trick';
import Standings from '../components/Standings';
import PlayerCircle from '../components/PlayerCircle';
import FaceDownCards from '../components/FaceDownCards';
import GameOver from './GameOver';
import type { StashData } from '../types';
import type { PlayerInfo, StashData } from '../types';
const TRICK_LINGER_MS = 3000;
export default function GameTable() {
const navigate = useNavigate();
const desktop = useIsDesktop();
// Zooms the whole desktop board to fill the window (full width + height), so
// cards, circles, text and the header all scale together. Up to 2.6×.
const { containerRef, scale, contentWidth } = useFitScale([desktop], 860, 2.6);
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
const hand = useGameStore((s) => s.hand);
@@ -33,11 +42,11 @@ export default function GameTable() {
return () => {
if (lingerTimer.current) clearTimeout(lingerTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
if (!gameStatus || !myPlayer) {
return <p className="text-center text-gray-400 pt-20">Nacitava sa...</p>;
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa</p>;
}
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
@@ -47,6 +56,7 @@ export default function GameTable() {
active_round_stashes,
active_stash,
standings = [],
standings_guesses = [],
} = status;
if (completed) {
@@ -57,106 +67,280 @@ export default function GameTable() {
const isPlayPhase = active_stash !== undefined;
const myTurnToPlay = isPlayPhase && active_player === myOrder;
// Show active stash if it has cards; otherwise show the lingered completed trick.
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
const displayedStash = activeCards > 0 ? active_stash : lingeredStash ?? undefined;
const displayedStash: StashData | null =
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null;
// Highlight only cards the engine would accept
const playableKeys = myTurnToPlay && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined;
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
// Seat mapping relative to "Ty": left / across / right.
const seat = (offset: number): PlayerInfo | undefined =>
players.find((p) => p.order === (myOrder + offset) % 4);
const leftP = seat(1);
const topP = seat(2);
const rightP = seat(3);
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0);
const guessOf = (o?: number): number | null => {
if (o === undefined) return null;
const g = active_round_guesses?.[String(o)];
return g === undefined ? null : g;
};
const activeOf = (o?: number) => o !== undefined && active_player === o;
// Exact cards still in a player's hand: started with cards_in_round, lost one
// per completed trick, minus one more if they've already played this trick.
const completedTricks = (active_round_stashes ?? []).reduce((a, b) => a + b, 0);
const cardsInHandOf = (o?: number) => {
if (o === undefined) return 0;
const playedCurrent = active_stash?.cards[String(o)] ? 1 : 0;
return Math.max(0, cards_in_round - completedTricks - playedCurrent);
};
const handleLeave = () => leaveGame(navigate);
const handleEnd = () => {
if (window.confirm('Naozaj ukoncit celu hru pre vsetkych?')) {
if (window.confirm('Naozaj ukončiť celú hru pre všetkých?')) {
emit.endGame(gameStatus.gid);
}
};
// The host can always end the game; other players only when the host is
// currently offline, so an abandoned game isn't stuck forever waiting for
// a host who won't come back, but it isn't open to casual misuse otherwise.
const hostConnected = players.find((p) => p.order === 0)?.connected ?? false;
const canEnd = myOrder === 0 || !hostConnected;
return (
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">
<span>Seria {series_number} / Kolo {round_number + 1}</span>
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
</div>
<div className="flex gap-3">
{myOrder === 0 && (
<button onClick={handleEnd} className="text-xs text-red-400 hover:text-red-300">
Ukoncit hru
</button>
)}
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
Odist
</button>
</div>
</div>
// ── shared pieces ────────────────────────────────────────────────
const bannerText = activeOf(myOrder)
? isPlayPhase
? 'Zahraj kartu'
: 'Zadaj tip'
: `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
{/* Turn indicator */}
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
{active_player === myOrder ? (
<span className="text-yellow-300 font-semibold">
{!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'}
</span>
) : (
<span className="text-gray-400">
Na rade: <span className="text-white">{activePlayerName}</span>
</span>
)}
</div>
const banner = (
<div className="flex items-center justify-center gap-2">
<span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
<span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</span>
</div>
);
{/* Guesses summary (always shown when available) */}
{active_round_guesses && (
<div className="bg-slate-800/60 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 mb-1">Tipy:</p>
<div className="flex gap-3 flex-wrap">
{players.map((p) => {
const guess = active_round_guesses[String(p.order)];
const wins = active_round_stashes?.[p.order] ?? 0;
return (
<span key={p.order} className="text-sm">
<span className="text-gray-300">{p.name}:</span>{' '}
{guess !== undefined ? (
<span className="text-white font-semibold">
{isPlayPhase ? `${wins}/${guess}` : guess}
</span>
) : (
<span className="text-gray-500">?</span>
)}
</span>
);
})}
const opponents = players
.filter((p) => p.order !== myOrder)
.sort((a, b) => a.order - b.order);
const totalsRow = (compact: boolean) => (
<div className="flex items-center justify-between gap-2">
{opponents.map((p) => (
<div key={p.order} className="text-center">
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
{p.name}
</div>
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, p.order)}
</div>
</div>
)}
))}
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
{myPlayer.name}
</div>
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, myOrder)}
</div>
</div>
</div>
);
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */}
{isPlayPhase && (
<Trick stash={displayedStash ?? null} />
)}
// Center of the oval: trick during play, guess controls during bidding.
const ovalContent = isPlayPhase ? (
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
) : (
active_round_guesses !== undefined && active_player !== undefined ? (
<GuessControls
cardsInRound={cards_in_round}
guesses={active_round_guesses}
myOrder={myOrder}
activePlayer={active_player}
activePlayerName={activePlayerName}
/>
) : null
);
{/* Guess phase controls */}
{!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && (
<GuessControls
cardsInRound={cards_in_round}
guesses={active_round_guesses}
myOrder={myOrder}
activePlayer={active_player}
activePlayerName={activePlayerName}
/>
)}
const topSeat = (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle
name={topP?.name ?? '—'}
won={wonOf(topP?.order)}
guess={guessOf(topP?.order)}
active={activeOf(topP?.order)}
size={desktop ? 64 : 52}
/>
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div>
);
{/* Hand */}
<div>
<p className="text-xs text-gray-400 mb-1 text-center">Tvoje karty</p>
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} />
const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle
name={p?.name ?? '—'}
won={wonOf(p?.order)}
guess={guessOf(p?.order)}
active={activeOf(p?.order)}
size={desktop ? 60 : 48}
/>
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div>
);
const meSeat = (
<div className="flex justify-center">
<PlayerCircle
name={myPlayer.name}
won={wonOf(myOrder)}
guess={guessOf(myOrder)}
active={activeOf(myOrder)}
size={desktop ? 70 : 58}
/>
</div>
);
const handArea = (
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
);
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
if (desktop) {
return (
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
{/* Design canvas — fixed height, width spans the viewport; scaled as one
unit so the whole board (and header) zooms with the window. */}
<div
className="flex"
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
{/* main */}
<div className="flex-1 min-w-0 flex flex-col">
{/* header */}
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
Bridžik
</span>
<div className="w-px h-[22px] bg-[#1a3a22]" />
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex-1 flex items-center justify-center">{banner}</div>
{totalsRow(true)}
<div className="w-px h-[22px] bg-[#1a3a22]" />
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
Odísť
</button>
</div>
{/* game content — players hug the edges so the felt uses full width */}
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
{topSeat}
<div className="flex items-center justify-center gap-16">
{sideSeat(leftP)}
<div
className="flex items-center justify-center rounded-full"
style={{
width: 620,
height: 372,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
{sideSeat(rightP)}
</div>
{meSeat}
</div>
{handArea}
</div>
{/* sidebar */}
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
</div>
</div>
);
}
// ── MOBILE LAYOUT ────────────────────────────────────────────────
return (
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
{/* header */}
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
<div className="flex items-center justify-between mb-2.5">
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex items-center gap-3">
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
Odísť
</button>
</div>
</div>
{totalsRow(false)}
</div>
{/* Standings */}
<Standings standings={standings} players={players} />
{/* turn banner */}
<div
className="py-[9px] px-4 border-b border-[#152a1a]"
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
>
{banner}
</div>
{/* game area */}
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
<div className="flex items-center gap-1.5 mb-2">
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
<div
className="flex-1 flex items-center justify-center rounded-full"
style={{
minHeight: 192,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
</div>
<div className="mb-1.5">{meSeat}</div>
</div>
{handArea}
{/* score */}
<div className="bg-table px-3 pb-4 pt-1">
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
</div>
</div>
);
}