2c2f07c2ec
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>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|