Files
bridzik/frontend/src/pages/History.tsx
T
tim 2c2f07c2ec 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>
2026-07-01 00:11:42 +02:00

293 lines
11 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 { Fragment, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit, socket } from '../lib/socket';
import { useIsDesktop } from '../lib/useIsDesktop';
import type { GameDetail, GameDetailRound } from '../types';
function fmtDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('sk-SK');
}
export default function History() {
const navigate = useNavigate();
const history = useGameStore((s) => s.history);
const detail = useGameStore((s) => s.gameDetail);
const setGameDetail = useGameStore((s) => s.setGameDetail);
useEffect(() => {
emit.getPlayerHistory();
return () => setGameDetail(null);
}, [setGameDetail]);
// After a prematurely-ended game is reopened, the server confirms with
// `game_restored`; jump to the lobby where it now shows as resumable.
useEffect(() => {
const onRestored = () => navigate('/');
socket.on('game_restored', onRestored);
return () => {
socket.off('game_restored', onRestored);
};
}, [navigate]);
// --- detail view ---
if (detail) {
return <GameDetailView detail={detail} onBack={() => setGameDetail(null)} />;
}
// --- list view ---
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">Moja história</h1>
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
Späť
</button>
</div>
{history.length === 0 && (
<p className="text-center text-green-dim py-6">Zatiaľ žiadne odohrané hry.</p>
)}
<div className="flex flex-col gap-3">
{history.map((g) => (
<div
key={g.gid}
className="flex items-stretch bg-header border border-[#142018] rounded-xl overflow-hidden"
>
<button
onClick={() => emit.getGameDetail(g.gid)}
className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
>
<p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
<p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p>
<p className="text-xs text-[#7a7058] mt-0.5">
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
</p>
</button>
<div className="flex flex-col items-end justify-center gap-2 py-3 pl-2 pr-3">
<span className="text-base font-serif text-gold whitespace-nowrap leading-none">
{g.my_points}
<span className="text-[11px] text-green-dim ml-0.5">b.</span>
</span>
{!g.completed && (
<button
onClick={() => emit.restoreGame(g.gid)}
className="px-4 py-1.5 rounded-lg text-sm font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors whitespace-nowrap"
>
Obnoviť
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
const SEP = '1px solid rgba(201,168,76,.16)'; // vertical divider between players
const DESKTOP_COLS = '28px repeat(8,1fr)'; // index + 4 players × 2 series columns
const MOBILE_COLS = '28px repeat(4,1fr)'; // index + 4 players (one series stacked)
/** Scoreboard-style detail. Desktop: 4 player columns in the header (total
* beside the name), and under each a pair of series side by side — series 1 &
* 2 on top, 3 & 4 below, separated by a blank row. Mobile: the same 4 player
* columns, but the series stack one below another. Per round a cell shows the
* points (hit bid) or the struck-through bid (missed → 0); each series ends
* with a Σ total. */
function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () => void }) {
const desktop = useIsDesktop();
const seats = detail.players; // seat order 0..3
const seatIds = seats.map((p) => p.player_id);
const totals = seatIds.map((pid) =>
detail.rounds.reduce((a, r) => (r.player_id === pid ? a + r.points : a), 0),
);
// series -> round -> playerId -> round entry
const bySeries = useMemo(() => {
const m = new Map<number, Map<number, Map<number, GameDetailRound>>>();
for (const r of detail.rounds) {
let rounds = m.get(r.series_number);
if (!rounds) m.set(r.series_number, (rounds = new Map()));
let byPlayer = rounds.get(r.round_number);
if (!byPlayer) rounds.set(r.round_number, (byPlayer = new Map()));
byPlayer.set(r.player_id, r);
}
return m;
}, [detail.rounds]);
const seriesNums = [...bySeries.keys()].sort((a, b) => a - b);
const roundsOf = (s: number | undefined) =>
s === undefined ? [] : [...(bySeries.get(s)?.keys() ?? [])];
const cellNode = (s: number | undefined, rn: number, seat: number) => {
const r = s === undefined ? undefined : bySeries.get(s)?.get(rn)?.get(seatIds[seat]);
if (!r) return null;
return r.won ? (
<span className="font-serif" style={{ fontSize: 14, color: '#c8bb95' }}>{r.points}</span>
) : (
<span className="font-serif line-through" style={{ fontSize: 14, color: '#7a6e4a' }}>{r.guess}</span>
);
};
const seriesTotal = (s: number | undefined, seat: number) =>
s === undefined
? ''
: [...(bySeries.get(s)?.values() ?? [])].reduce(
(a, byP) => a + (byP.get(seatIds[seat])?.points ?? 0),
0,
);
// Header: player names with their grand total beside the name (both layouts).
const header = (
<div
className="border-b border-gold/[.18]"
style={{ display: 'grid', gridTemplateColumns: MOBILE_COLS, padding: '9px 8px' }}
>
<div />
{seats.map((p, c) => (
<div
key={c}
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: 6,
borderLeft: c > 0 ? SEP : undefined,
}}
>
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
{p.username}
</span>
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
{totals[c]}
</span>
</div>
))}
</div>
);
// A round row: index column + one cell per (player × series) in `cols`.
const roundRow = (rn: number, cols: (number | undefined)[]) => (
<div
key={rn}
className="border-b border-gold/[.04]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '3px 8px',
}}
>
<div style={{ textAlign: 'center', fontSize: 10, color: '#7a7252' }}>{rn + 1}</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div key={`${c}-${si}`} style={{ textAlign: 'center', borderLeft: c > 0 && si === 0 ? SEP : undefined }}>
{cellNode(s, rn, c)}
</div>
)),
)}
</div>
);
// Σ row: per-series totals for each player.
const sigmaRow = (cols: (number | undefined)[]) => (
<div
className="bg-gold/[.08] border-b border-gold/[.14]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '5px 8px',
}}
>
<div className="text-green-dim" style={{ textAlign: 'center', fontSize: 11 }}>Σ</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div
key={`${c}-${si}`}
className="font-serif text-green-score"
style={{ textAlign: 'center', fontWeight: 600, fontSize: 14, borderLeft: c > 0 && si === 0 ? SEP : undefined }}
>
{seriesTotal(s, c)}
</div>
)),
)}
</div>
);
// Tiny row telling which series each sub-column is (desktop only — always a
// fixed pair, sB may be absent for a trailing odd series).
const seriesTags = (sA: number, sB: number | undefined) => (
<div
className="border-b border-gold/10"
style={{ display: 'grid', gridTemplateColumns: DESKTOP_COLS, padding: '5px 8px 3px' }}
>
<div />
{seats.map((_, c) => (
<Fragment key={c}>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064', borderLeft: c > 0 ? SEP : undefined }}>
{sA + 1}
</div>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064' }}>
{sB !== undefined ? sB + 1 : ''}
</div>
</Fragment>
))}
</div>
);
// Desktop: pair series side by side (S1|S2, then S3|S4) with a blank row between.
const desktopBody = (() => {
const blocks: number[][] = [];
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
return blocks.map((block, bi) => {
const [sA, sB] = [block[0], block[1]];
const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
return (
<Fragment key={bi}>
{seriesTags(sA, sB)}
{roundNums.map((rn) => roundRow(rn, [sA, sB]))}
{sigmaRow([sA, sB])}
{bi < blocks.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
})();
// Mobile: one series per block, stacked vertically, 4 player columns each.
const mobileBody = seriesNums.map((s, si) => {
const roundNums = roundsOf(s).sort((a, b) => a - b);
return (
<Fragment key={s}>
<div style={{ padding: '7px 8px 3px', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.09em', color: '#8a8064' }}>
Séria {s + 1}
</div>
{roundNums.map((rn) => roundRow(rn, [s]))}
{sigmaRow([s])}
{si < seriesNums.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
return (
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
<button onClick={onBack} className="text-sm text-green-dim hover:text-gold mb-4">
Späť na zoznam
</button>
<h1 className="font-serif text-2xl text-gold mb-1 truncate">{detail.name || 'Detail hry'}</h1>
<p className="text-xs text-green-dim mb-4">{fmtDate(detail.created_at)}</p>
{/* Desktop packs 8 columns → allow horizontal scroll on narrow widths;
mobile uses only 4 columns and fits the phone, so no scroll. */}
<div className={`bg-header border border-[#142018] rounded-xl ${desktop ? 'overflow-x-auto' : ''}`}>
<div style={desktop ? { minWidth: 440 } : undefined}>
{header}
{desktop ? desktopBody : mobileBody}
</div>
</div>
</div>
);
}