Files
bridzik/frontend/src/components/Standings.tsx
T
timandClaude Opus 4.8 97c71cf6e1 Frontend: rozmery a farby dosky centralizovane do boardTheme
Sizing konstanty (seat/standings/guess/totals) presunute do boardTheme.ts a farebne odlisenie tipu v PlayerCircle (nad tip = terakota).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:37:48 +02:00

215 lines
7.7 KiB
TypeScript

import { useState } from 'react';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
import { displayName } from '../lib/names';
import { STANDINGS_FONT } from '../lib/boardTheme';
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, guesses = [], players, myOrder, desktop = false }: Props) {
const [open, setOpen] = useState(false);
// Finished series (8/8 rounds) collapsed to just their Σ row; toggled per series.
const [collapsedSeries, setCollapsedSeries] = useState<Set<number>>(new Set());
const toggleSeries = (si: number) =>
setCollapsedSeries((prev) => {
const next = new Set(prev);
if (next.has(si)) next.delete(si);
else next.add(si);
return next;
});
// 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.
// Sizes live in one place (boardTheme.ts) alongside the rest of the board.
const fz = desktop ? STANDINGS_FONT.desktop : STANDINGS_FONT.mobile;
const gridCols = { gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` };
const columnHeader = (
<div className="px-3 pt-3 flex-shrink-0">
<div className="grid items-end mb-1" style={gridCols}>
<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 }}
>
{displayName(p.name)}
</div>
))}
</div>
<div className="h-px bg-gold/10" />
</div>
);
// Completed rounds, grouped by series with a per-series summary row. Finished
// series can be collapsed to just their Σ row so long games stay scannable.
const rows = standings.flatMap((seriesRounds, si) => {
const priorRounds = seriesRoundOffsets[si];
const isFinished = seriesRounds.length === ROUNDS_PER_SERIES;
const isCollapsed = isFinished && collapsedSeries.has(si);
const elems = isCollapsed
? []
: seriesRounds.map((scores, lri) => (
<div key={`r-${si}-${lri}`} className="grid items-center py-1 border-b border-gold/[.05]" style={gridCols}>
<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 → show the tip in the dimmer "0 points" color, no strikethrough.
if (points === 0) {
return (
<div
key={p.order}
className="text-center font-serif leading-none"
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. Clicking toggles
// whether that series' individual rounds are shown.
if (isFinished) {
elems.push(
<div
key={`s-${si}`}
role="button"
tabIndex={0}
onClick={() => toggleSeries(si)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSeries(si)}
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07] cursor-pointer select-none"
style={gridCols}
>
<div className="text-center font-serif text-gold flex items-center justify-center gap-[2px]" style={{ fontSize: fz.sigma }}>
<span className="text-[8px] text-green-dim">{isCollapsed ? '▸' : '▾'}</span>
Σ{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;
});
const scrollableRounds = (
<div className={`velvet-scroll flex-1 min-h-0 overflow-y-auto px-3 ${desktop ? '' : 'max-h-[45vh]'}`}>
{rows}
{/* Active round placeholder */}
<div className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]" style={gridCols}>
<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>
);
const totalsBlock = (
<div className="px-3 pb-4 pt-2 flex-shrink-0">
<div className="h-px bg-gold/20 mb-2" />
<div className="grid items-center py-0.5" style={gridCols}>
<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>
);
const content = (
<div className="flex-1 min-h-0 flex flex-col">
{columnHeader}
{scrollableRounds}
{totalsBlock}
</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] flex-shrink-0">
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
</div>
{content}
</aside>
);
}
// Mobile: collapsible panel
return (
<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 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
>
<span>Skóre</span>
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
</button>
{open && content}
</div>
);
}