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:
@@ -4,53 +4,192 @@ import { computeTotal } from '../lib/standings';
|
||||
|
||||
interface Props {
|
||||
standings: number[][][];
|
||||
/** Tips per series/round/seat, same shape as standings. */
|
||||
guesses?: number[][][];
|
||||
players: PlayerInfo[];
|
||||
myOrder: number;
|
||||
/** Desktop renders an always-open sidebar; mobile a collapsible panel. */
|
||||
desktop?: boolean;
|
||||
}
|
||||
|
||||
export default function Standings({ standings, players }: Props) {
|
||||
export default function Standings({ standings, guesses = [], players, myOrder, desktop = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = [...players]
|
||||
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
// Player columns in seat order; the local player's column is highlighted.
|
||||
const cols = [...players].sort((a, b) => a.order - b.order);
|
||||
// Completed-round count before each series (running total), so each series'
|
||||
// round index can be offset in a single pass instead of re-summing per row.
|
||||
const seriesRoundOffsets: number[] = [];
|
||||
let completedRounds = 0;
|
||||
for (const s of standings) {
|
||||
seriesRoundOffsets.push(completedRounds);
|
||||
completedRounds += s.length;
|
||||
}
|
||||
// Engine: every series is exactly 8 rounds → a series with 8 entries is done,
|
||||
// and gets a per-series summary row after its last round.
|
||||
const ROUNDS_PER_SERIES = 8;
|
||||
|
||||
// Bigger, more legible type on the wide desktop sidebar; compact on mobile.
|
||||
const fz = {
|
||||
head: desktop ? 11 : 10,
|
||||
idx: desktop ? 13 : 9,
|
||||
cell: desktop ? 19 : 14,
|
||||
dot: desktop ? 18 : 13,
|
||||
sigma: desktop ? 13 : 9,
|
||||
total: desktop ? 20 : 18,
|
||||
};
|
||||
|
||||
const table = (
|
||||
<div className="flex-1 flex flex-col px-3 pt-3 pb-4">
|
||||
{/* Column headers */}
|
||||
<div
|
||||
className="grid items-end mb-1"
|
||||
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||
>
|
||||
<div />
|
||||
{cols.map((p) => (
|
||||
<div
|
||||
key={p.order}
|
||||
className={`text-center uppercase tracking-[.09em] truncate ${
|
||||
p.order === myOrder ? 'text-gold' : 'text-green-dim'
|
||||
}`}
|
||||
style={{ fontSize: fz.head }}
|
||||
>
|
||||
{p.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-gold/10 mb-1" />
|
||||
|
||||
{/* Completed rounds, grouped by series with a per-series summary row */}
|
||||
{standings.flatMap((seriesRounds, si) => {
|
||||
const priorRounds = seriesRoundOffsets[si];
|
||||
const elems = seriesRounds.map((scores, lri) => (
|
||||
<div
|
||||
key={`r-${si}-${lri}`}
|
||||
className="grid items-center py-1 border-b border-gold/[.05]"
|
||||
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||
>
|
||||
<div className="text-center text-[#7a7252]" style={{ fontSize: fz.idx }}>
|
||||
{priorRounds + lri + 1}
|
||||
</div>
|
||||
{cols.map((p) => {
|
||||
const points = scores[p.order] ?? 0;
|
||||
// Failed tip (0 points) → show the struck-through tip instead of 0.
|
||||
if (points === 0) {
|
||||
return (
|
||||
<div
|
||||
key={p.order}
|
||||
className="text-center font-serif leading-none line-through"
|
||||
style={{ fontSize: fz.cell, color: '#7a6e4a' }}
|
||||
>
|
||||
{guesses[si]?.[lri]?.[p.order] ?? 0}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={p.order}
|
||||
className="text-center font-serif leading-none"
|
||||
style={{ fontSize: fz.cell, color: p.order === myOrder ? '#f0dca8' : '#c8bb95' }}
|
||||
>
|
||||
{points}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
|
||||
// After a finished series, sum its points per player.
|
||||
if (seriesRounds.length === ROUNDS_PER_SERIES) {
|
||||
elems.push(
|
||||
<div
|
||||
key={`s-${si}`}
|
||||
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07]"
|
||||
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||
>
|
||||
<div className="text-center font-serif text-gold" style={{ fontSize: fz.sigma }}>
|
||||
Σ{si + 1}
|
||||
</div>
|
||||
{cols.map((p) => {
|
||||
const sum = seriesRounds.reduce((a, r) => a + (r[p.order] ?? 0), 0);
|
||||
return (
|
||||
<div
|
||||
key={p.order}
|
||||
className={`text-center font-serif leading-none ${
|
||||
p.order === myOrder ? 'text-gold-dim' : 'text-green-score'
|
||||
}`}
|
||||
style={{ fontSize: fz.cell, fontWeight: 600 }}
|
||||
>
|
||||
{sum}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
return elems;
|
||||
})}
|
||||
|
||||
{/* Active round placeholder */}
|
||||
<div
|
||||
className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]"
|
||||
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||
>
|
||||
<div className="text-center font-medium text-gold" style={{ fontSize: fz.idx }}>{completedRounds + 1}</div>
|
||||
{cols.map((p) => (
|
||||
<div key={p.order} className="text-center text-[#7a7252]" style={{ fontSize: fz.dot }}>·</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-2" />
|
||||
<div className="h-px bg-gold/20 mb-2" />
|
||||
|
||||
{/* Totals */}
|
||||
<div
|
||||
className="grid items-center py-0.5"
|
||||
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
|
||||
>
|
||||
<div className="text-center uppercase tracking-[.08em] text-green-dim" style={{ fontSize: fz.sigma }}>
|
||||
Σ
|
||||
</div>
|
||||
{cols.map((p) => (
|
||||
<div
|
||||
key={p.order}
|
||||
className={`text-center font-serif leading-none ${
|
||||
p.order === myOrder ? 'text-gold-dim' : 'text-[#c8bb95]'
|
||||
}`}
|
||||
style={{ fontSize: fz.total, fontWeight: p.order === myOrder ? 700 : 600 }}
|
||||
>
|
||||
{computeTotal(standings, p.order)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (desktop) {
|
||||
return (
|
||||
<aside className="w-[268px] flex-shrink-0 bg-header border-l border-[#142018] flex flex-col">
|
||||
<div className="h-[58px] flex items-center gap-2 px-5 border-b border-[#14221a]">
|
||||
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
|
||||
</div>
|
||||
{table}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: collapsible panel
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="bg-header/80 border border-[#142018] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex justify-between items-center px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-slate-700"
|
||||
className="w-full flex justify-between items-center px-4 py-2 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
|
||||
>
|
||||
<span>Skore</span>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
<span>Skóre</span>
|
||||
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<table className="w-full text-sm text-center">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-slate-700">
|
||||
<th className="py-1 px-2 text-left">Hrac</th>
|
||||
{standings.map((_, si) => (
|
||||
<th key={si} className="py-1 px-2">S{si + 1}</th>
|
||||
))}
|
||||
<th className="py-1 px-2">Spolu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((p) => (
|
||||
<tr key={p.order} className="border-b border-slate-700/50">
|
||||
<td className="py-1 px-2 text-left text-gray-200">{p.name}</td>
|
||||
{standings.map((series, si) => {
|
||||
return (
|
||||
<td key={si} className="py-1 px-2 text-gray-300">
|
||||
{computeTotal([series], p.order)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-1 px-2 font-bold text-white">{p.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{open && table}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user