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
+56
View File
@@ -0,0 +1,56 @@
interface Props {
name: string;
/** Tricks won this round. */
won: number;
/** Bid for this round (null until the player has guessed). */
guess: number | null;
/** Whether it is this player's turn — the only state that highlights a circle. */
active: boolean;
size?: number;
}
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
const nameFont = Math.max(9, Math.round(size * 0.17));
const valueFont = Math.round(size * 0.32);
// Oval: width = size, height a touch shorter so it reads as an ellipse.
const height = Math.round(size * 0.78);
return (
<div
// Only the active player is highlighted (gold ring + glow) — colors come
// from the velvet-table palette tokens (tailwind.config.js), not literals.
className={`flex flex-col items-center justify-center rounded-[50%] ${
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
}`}
style={{
width: size,
height,
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
}}
>
<span
className={`uppercase leading-tight text-center ${active ? 'text-gold' : 'text-green-circle'}`}
style={{
fontFamily: '"DM Sans",sans-serif',
fontSize: nameFont,
letterSpacing: '.09em',
fontWeight: 500,
}}
>
{name}
</span>
<span
className={`leading-none ${active ? 'text-gold-bright' : 'text-gold'}`}
style={{
fontFamily: '"Playfair Display",serif',
fontSize: valueFont,
fontWeight: active ? 700 : 400,
}}
>
{won}
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
</span>
</div>
);
}