feat(01-11): implement Overview tab with clock display and activity feed

- ClockDisplay: large countdown timer with MM:SS, break/pause overlays,
  hand-for-hand badge, urgent pulse in final 10s, next level preview,
  chip-up indicator, BB ante support
- BlindInfo: time-to-break countdown, break-ends-in when on break
- ActivityFeed: recent actions with type icons/colors, relative timestamps,
  slide-in animation, view-all link
- Overview page: assembles all components in CONTEXT.md priority order
  (clock > break > players > balance > financials > activity)
- Extended ClockSnapshot type with bb_ante, game_type, hand_for_hand,
  next level info, chip_up_denomination
- Extended FinancialSummary with detailed breakdown fields
- Added Transaction, DealProposal, DealPlayerEntry types
- Added derived properties: bustedPlayers, averageStack, totalChips
- Added transaction WS message handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-03-01 08:18:46 +01:00
parent 968a38dd87
commit e7da206d32
9 changed files with 2125 additions and 76 deletions

View file

@ -0,0 +1,203 @@
<script lang="ts">
import type { ActivityEntry } from '$lib/stores/tournament.svelte';
/**
* Activity feed showing recent tournament actions.
*
* Displays last N actions in reverse chronological order with
* type-specific icons, colors, and relative timestamps.
* New entries animate in from the top.
*/
interface Props {
entries: ActivityEntry[];
/** Maximum entries to display. */
limit?: number;
/** Show "View all" link. */
showViewAll?: boolean;
/** Handler for "View all" click. */
onviewall?: () => void;
}
let {
entries,
limit = 15,
showViewAll = true,
onviewall
}: Props = $props();
let visibleEntries = $derived(entries.slice(0, limit));
/** Icon and color mapping for activity types. */
function getEntryStyle(type: string): { icon: string; color: string } {
switch (type) {
case 'buyin':
case 'buy_in':
return { icon: '\u{1F464}', color: 'var(--color-success)' };
case 'bust':
case 'elimination':
return { icon: '\u{2716}', color: 'var(--color-error)' };
case 'rebuy':
return { icon: '\u{1F504}', color: 'var(--color-primary)' };
case 'addon':
case 'add_on':
return { icon: '\u{2B06}', color: 'var(--color-warning)' };
case 'level_change':
return { icon: '\u{1F552}', color: 'var(--color-clock)' };
case 'break_start':
return { icon: '\u{2615}', color: 'var(--color-break)' };
case 'break_end':
return { icon: '\u{25B6}', color: 'var(--color-break)' };
case 'seat_move':
return { icon: '\u{2194}', color: 'var(--ctp-lavender)' };
case 'table_break':
return { icon: '\u{1F4CB}', color: 'var(--ctp-peach)' };
case 'reentry':
case 're_entry':
return { icon: '\u{1F503}', color: 'var(--ctp-sapphire)' };
case 'deal':
case 'chop':
return { icon: '\u{1F91D}', color: 'var(--color-prize)' };
default:
return { icon: '\u{2022}', color: 'var(--color-text-muted)' };
}
}
/** Format timestamp as relative time ("2m ago", "1h ago"). */
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000; // Handle both ms and seconds
const diff = Math.max(0, Math.floor((now - ts) / 1000));
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
</script>
<div class="activity-feed">
<div class="feed-header">
<h3 class="feed-title">Recent Activity</h3>
{#if showViewAll && onviewall && entries.length > limit}
<button class="view-all-btn touch-target" onclick={onviewall}>
View all
</button>
{/if}
</div>
{#if visibleEntries.length === 0}
<p class="feed-empty">No activity yet</p>
{:else}
<div class="feed-list" role="log" aria-label="Tournament activity feed">
{#each visibleEntries as entry (entry.id)}
{@const style = getEntryStyle(entry.type)}
<div class="feed-entry" style="--entry-color: {style.color}">
<span class="entry-icon" aria-hidden="true">{style.icon}</span>
<span class="entry-message">{entry.message}</span>
<span class="entry-time">{formatRelativeTime(entry.timestamp)}</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.activity-feed {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.feed-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.feed-title {
font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.view-all-btn {
background: none;
border: none;
font-size: var(--text-sm);
color: var(--color-primary);
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
min-height: auto;
min-width: auto;
}
.view-all-btn:hover {
text-decoration: underline;
}
.feed-list {
display: flex;
flex-direction: column;
}
.feed-entry {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
animation: slide-in 200ms ease-out;
}
.feed-entry:last-child {
border-bottom: none;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.entry-icon {
flex-shrink: 0;
width: 1.5em;
text-align: center;
font-size: var(--text-base);
}
.entry-message {
flex: 1;
font-size: var(--text-sm);
color: var(--color-text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-time {
flex-shrink: 0;
font-size: var(--text-xs);
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.feed-empty {
text-align: center;
padding: var(--space-6);
color: var(--color-text-muted);
font-style: italic;
font-size: var(--text-sm);
}
</style>

View file

@ -0,0 +1,332 @@
<script lang="ts">
/**
* Balancing panel for table balancing workflow.
*
* Shows when tables are unbalanced (yellow/red indicator).
* "Suggest Moves" shows system-generated suggestions.
* Accept flow uses 2-tap recording: source seat, destination seat.
* Stale suggestions auto-cancel when state changes.
*/
import type { BalanceStatus, BalanceMove } from '$lib/stores/tournament.svelte';
interface Props {
balanceStatus: BalanceStatus | null;
onacceptmove?: (move: BalanceMove) => void;
onsuggestmoves?: () => void;
}
let {
balanceStatus,
onacceptmove,
onsuggestmoves
}: Props = $props();
/** Panel expanded state. */
let expanded = $state(false);
/** Currently selected move index for execution. */
let selectedMoveIdx = $state<number | null>(null);
/** Whether suggestions are being loaded. */
let loading = $state(false);
/** Whether the panel is visible at all. */
let isUnbalanced = $derived(
balanceStatus !== null && !balanceStatus.is_balanced
);
/** Severity level based on max difference. */
let severity = $derived.by(() => {
if (!balanceStatus) return 'ok';
if (balanceStatus.max_diff >= 3) return 'critical';
if (balanceStatus.max_diff >= 2) return 'warning';
return 'ok';
});
let hasSuggestions = $derived(
balanceStatus !== null && balanceStatus.moves_needed.length > 0
);
function handleSuggestMoves(): void {
loading = true;
onsuggestmoves?.();
// In real implementation, loading would be cleared when suggestions arrive via WS
setTimeout(() => { loading = false; }, 500);
}
function handleAcceptMove(move: BalanceMove, idx: number): void {
selectedMoveIdx = idx;
onacceptmove?.(move);
}
function handleCancelSuggestion(): void {
selectedMoveIdx = null;
}
function toggleExpanded(): void {
expanded = !expanded;
}
</script>
{#if isUnbalanced}
<div class="balance-panel" class:critical={severity === 'critical'} class:warning={severity === 'warning'}>
<!-- Banner -->
<button
class="balance-banner touch-target"
onclick={toggleExpanded}
aria-expanded={expanded}
aria-controls="balance-details"
>
<span class="balance-indicator">
<span class="indicator-dot" class:critical={severity === 'critical'} class:warning={severity === 'warning'}></span>
<span class="balance-text">
Tables Unbalanced
{#if balanceStatus}
(max diff: {balanceStatus.max_diff})
{/if}
</span>
</span>
<span class="expand-arrow" class:expanded>{expanded ? '\u25B2' : '\u25BC'}</span>
</button>
<!-- Expandable details -->
{#if expanded}
<div id="balance-details" class="balance-details">
{#if !hasSuggestions && !loading}
<button
class="suggest-btn touch-target"
onclick={handleSuggestMoves}
>
Suggest Moves
</button>
{:else if loading}
<div class="loading-text">Calculating suggestions...</div>
{:else if balanceStatus}
<div class="suggestions-header">
<span class="suggestions-label">Suggested Moves</span>
<span class="live-indicator">Live</span>
</div>
<ul class="suggestion-list">
{#each balanceStatus.moves_needed as move, idx}
<li class="suggestion-item" class:active={selectedMoveIdx === idx}>
<div class="suggestion-text">
Move <strong>{move.player_name}</strong>
from Table {move.from_table} to Table {move.to_table}, Seat {move.to_seat}
</div>
<div class="suggestion-actions">
{#if selectedMoveIdx === idx}
<button
class="cancel-btn touch-target"
onclick={handleCancelSuggestion}
>
Cancel
</button>
{:else}
<button
class="accept-btn touch-target"
onclick={() => handleAcceptMove(move, idx)}
>
Accept
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
<style>
.balance-panel {
border-radius: var(--radius-lg);
border: 1px solid var(--color-warning);
background-color: var(--color-surface);
overflow: hidden;
margin-bottom: var(--space-4);
}
.balance-panel.critical {
border-color: var(--color-error);
}
.balance-panel.warning {
border-color: var(--color-warning);
}
.balance-banner {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
text-align: left;
color: var(--color-text);
}
.balance-indicator {
display: flex;
align-items: center;
gap: var(--space-2);
}
.indicator-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background-color: var(--color-warning);
animation: pulse 2s ease-in-out infinite;
}
.indicator-dot.critical {
background-color: var(--color-error);
}
.indicator-dot.warning {
background-color: var(--color-warning);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.balance-text {
font-size: var(--text-sm);
font-weight: 600;
}
.expand-arrow {
font-size: var(--text-xs);
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
/* Details panel */
.balance-details {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
}
.suggest-btn {
width: 100%;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.suggest-btn:hover {
opacity: 0.9;
}
.loading-text {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-3);
}
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.suggestions-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
font-weight: 600;
}
.live-indicator {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-success);
padding: 2px var(--space-2);
background-color: rgba(166, 227, 161, 0.1);
border-radius: var(--radius-sm);
}
.suggestion-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.suggestion-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background-color: var(--color-bg);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.suggestion-item.active {
border-color: var(--color-primary);
}
.suggestion-text {
flex: 1;
font-size: var(--text-sm);
color: var(--color-text);
line-height: var(--leading-normal);
}
.suggestion-text strong {
color: var(--color-primary);
}
.suggestion-actions {
flex-shrink: 0;
}
.accept-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: white;
background-color: var(--color-success);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.accept-btn:hover {
opacity: 0.9;
}
.cancel-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.cancel-btn:hover {
background-color: var(--color-surface-hover);
}
</style>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { ClockSnapshot } from '$lib/stores/tournament.svelte';
/**
* Time-to-break display.
*
* Shows countdown until next break, or time remaining on current break.
* Hidden when no upcoming break exists.
*/
interface Props {
clock: ClockSnapshot;
}
let { clock }: Props = $props();
/** Format seconds to MM:SS. */
function formatTime(seconds: number): string {
if (seconds < 0) seconds = 0;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/** Label and time for the break indicator. */
let breakInfo = $derived.by(() => {
if (clock.is_break) {
return {
label: 'Break ends in',
time: formatTime(clock.remaining_seconds),
visible: true,
isBreak: true
};
}
if (clock.next_break_in_seconds !== null && clock.next_break_in_seconds > 0) {
return {
label: 'Break in',
time: formatTime(clock.next_break_in_seconds),
visible: true,
isBreak: false
};
}
return { label: '', time: '', visible: false, isBreak: false };
});
</script>
{#if breakInfo.visible}
<div class="break-info" class:on-break={breakInfo.isBreak}>
<span class="break-label">{breakInfo.label}:</span>
<span class="timer break-time">{breakInfo.time}</span>
</div>
{/if}
<style>
.break-info {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background-color: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
.break-info.on-break {
background-color: color-mix(in srgb, var(--ctp-teal) 10%, var(--color-surface));
border-color: var(--ctp-teal);
}
.break-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.break-time {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-break);
}
</style>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import type { ClockSnapshot } from '$lib/stores/tournament.svelte';
/**
* Large clock display for the Overview tab.
*
* Shows current level timer, blinds, ante, next level preview,
* break/pause overlays, hand-for-hand badge, and chip-up indicator.
* Timer text turns red and pulses in the final 10 seconds.
*/
interface Props {
clock: ClockSnapshot;
}
let { clock }: Props = $props();
/** Whether we are in the final 10 seconds (urgent state). */
let isUrgent = $derived(
clock.remaining_seconds <= 10 && !clock.is_break && !clock.is_paused
);
/** Format seconds to MM:SS. */
function formatTime(seconds: number): string {
if (seconds < 0) seconds = 0;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/** Format blind values with locale separators. */
function formatChips(value: number): string {
return value.toLocaleString();
}
/** Build the level label (e.g., "Level 5" or "Level 5 -- PLO"). */
let levelLabel = $derived.by(() => {
const base = `Level ${clock.level}`;
if (clock.game_type && clock.game_type !== 'nlhe') {
return `${base} -- ${clock.game_type.toUpperCase()}`;
}
return base;
});
/** Next level preview text. */
let nextLevelPreview = $derived.by(() => {
if (!clock.next_level_name) return null;
if (clock.next_level_is_break) {
const dur = clock.next_level_duration_seconds
? formatTime(clock.next_level_duration_seconds)
: '';
return `Next: BREAK${dur ? ` (${dur})` : ''}`;
}
const sb = clock.next_level_small_blind ?? 0;
const bb = clock.next_level_big_blind ?? 0;
const dur = clock.next_level_duration_seconds
? formatTime(clock.next_level_duration_seconds)
: '';
return `Next: ${formatChips(sb)}/${formatChips(bb)}${dur ? ` (${dur})` : ''}`;
});
</script>
<div
class="clock-display"
class:on-break={clock.is_break}
class:paused={clock.is_paused}
class:urgent={isUrgent}
role="timer"
aria-label="Tournament clock"
>
<!-- Hand-for-hand badge -->
{#if clock.is_hand_for_hand}
<div class="hfh-badge">HAND FOR HAND</div>
{/if}
<!-- Break state -->
{#if clock.is_break}
<div class="break-label">BREAK</div>
{/if}
<!-- Pause overlay -->
{#if clock.is_paused}
<div class="pause-overlay">PAUSED</div>
{/if}
<!-- Main timer -->
<div class="timer-display">
<span class="timer timer-value">{formatTime(clock.remaining_seconds)}</span>
</div>
<!-- Level label -->
{#if !clock.is_break}
<div class="level-label">{levelLabel}</div>
{/if}
<!-- Blinds -->
{#if !clock.is_break}
<div class="blinds-display">
<span class="blinds blinds-main">
SB: {formatChips(clock.small_blind)} / BB: {formatChips(clock.big_blind)}
</span>
{#if clock.bb_ante > 0}
<span class="blinds ante-info">BB Ante: {formatChips(clock.bb_ante)}</span>
{:else if clock.ante > 0}
<span class="blinds ante-info">Ante: {formatChips(clock.ante)}</span>
{/if}
</div>
{/if}
<!-- Next level preview -->
{#if nextLevelPreview}
<div class="next-level">
{nextLevelPreview}
{#if clock.chip_up_denomination}
<span class="chip-up">Chip-up: Remove {formatChips(clock.chip_up_denomination)}s</span>
{/if}
</div>
{/if}
</div>
<style>
.clock-display {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-6) var(--space-4);
background-color: var(--color-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
position: relative;
overflow: hidden;
min-height: 200px;
}
.clock-display.on-break {
background-color: color-mix(in srgb, var(--ctp-teal) 12%, var(--color-surface));
border-color: var(--ctp-teal);
}
.clock-display.paused {
background-color: color-mix(in srgb, var(--ctp-peach) 8%, var(--color-surface));
}
/* Hand-for-hand badge */
.hfh-badge {
position: absolute;
top: var(--space-3);
right: var(--space-3);
font-size: var(--text-xs);
font-weight: 700;
padding: var(--space-1) var(--space-2);
background-color: color-mix(in srgb, var(--ctp-red) 20%, transparent);
color: var(--ctp-red);
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
animation: pulse-hfh 2s ease-in-out infinite;
}
@keyframes pulse-hfh {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Break label */
.break-label {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--ctp-teal);
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* Pause overlay */
.pause-overlay {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--ctp-peach);
letter-spacing: 0.1em;
text-transform: uppercase;
animation: pulse-pause 2s ease-in-out infinite;
}
@keyframes pulse-pause {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Timer */
.timer-display {
display: flex;
align-items: center;
justify-content: center;
}
.timer-value {
font-size: clamp(3rem, 12vw, 6rem);
font-weight: 700;
color: var(--color-text);
line-height: 1;
letter-spacing: 0.02em;
}
.clock-display.on-break .timer-value {
color: var(--ctp-teal);
}
.clock-display.urgent .timer-value {
color: var(--ctp-red);
animation: pulse-urgent 1s ease-in-out infinite;
}
@keyframes pulse-urgent {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Level label */
.level-label {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-secondary);
}
/* Blinds */
.blinds-display {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.blinds-main {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-text);
}
.ante-info {
font-size: var(--text-base);
color: var(--color-text-secondary);
}
/* Next level */
.next-level {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.chip-up {
font-size: var(--text-xs);
font-weight: 600;
color: var(--ctp-peach);
}
/* Desktop: bigger clock */
@media (min-width: 768px) {
.clock-display {
min-height: 260px;
padding: var(--space-8) var(--space-6);
}
.blinds-main {
font-size: var(--text-2xl);
}
}
</style>

View file

@ -0,0 +1,268 @@
<script lang="ts">
/**
* SVG-based top-down oval poker table component.
*
* Renders an oval table with numbered seat positions around the edge.
* Occupied seats show player name (truncated); empty seats show seat number.
* Supports 6-max through 10-max configurations.
* 48px touch targets for each seat.
*/
import type { Player } from '$lib/stores/tournament.svelte';
interface SeatInfo {
seatNumber: number;
player: Player | null;
}
interface Props {
tableNumber: number;
tableId: string;
maxSeats: number;
seats: SeatInfo[];
dealerSeat: number | null;
selectedSeat: number | null;
onseattap?: (tableId: string, seatNumber: number) => void;
}
let {
tableNumber,
tableId,
maxSeats,
seats,
dealerSeat = null,
selectedSeat = null,
onseattap
}: Props = $props();
/** SVG dimensions. */
const SVG_WIDTH = 280;
const SVG_HEIGHT = 200;
const CX = SVG_WIDTH / 2;
const CY = SVG_HEIGHT / 2;
const RX = 110; // oval horizontal radius
const RY = 70; // oval vertical radius
const SEAT_RADIUS = 18;
/**
* Compute seat positions around the oval.
* Seats are distributed evenly. Seat 1 starts at the bottom-center
* and proceeds clockwise.
*/
function seatPosition(index: number, total: number): { x: number; y: number } {
// Start from bottom (PI/2) and go clockwise (negative angle direction in SVG coords)
const startAngle = Math.PI / 2;
const angle = startAngle + (2 * Math.PI * index) / total;
const x = CX + (RX + SEAT_RADIUS + 6) * Math.cos(angle);
const y = CY - (RY + SEAT_RADIUS + 6) * Math.sin(angle);
return { x, y };
}
/** Truncate player name to fit in seat circle. */
function truncateName(name: string, maxLen: number = 6): string {
if (name.length <= maxLen) return name;
return name.slice(0, maxLen - 1) + '\u2026';
}
function handleSeatTap(seatNumber: number): void {
onseattap?.(tableId, seatNumber);
}
/** Find seat info by seat number. */
function getSeat(seatNumber: number): SeatInfo {
return seats.find((s) => s.seatNumber === seatNumber) ?? { seatNumber, player: null };
}
</script>
<div class="oval-table-container">
<div class="table-label">Table {tableNumber}</div>
<svg
viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}"
class="oval-table-svg"
role="img"
aria-label="Table {tableNumber} seating layout"
>
<!-- Oval table surface -->
<ellipse
cx={CX}
cy={CY}
rx={RX}
ry={RY}
class="table-surface"
/>
<!-- Table felt inner -->
<ellipse
cx={CX}
cy={CY}
rx={RX - 4}
ry={RY - 4}
class="table-felt"
/>
<!-- Table number in center -->
<text x={CX} y={CY + 4} class="table-number-text" text-anchor="middle">
T{tableNumber}
</text>
<!-- Seats -->
{#each Array(maxSeats) as _, i}
{@const seatNum = i + 1}
{@const pos = seatPosition(i, maxSeats)}
{@const seat = getSeat(seatNum)}
{@const isOccupied = seat.player !== null}
{@const isDealer = dealerSeat === seatNum}
{@const isSelected = selectedSeat === seatNum}
<!-- Seat circle (touch target) -->
<g
class="seat-group"
class:occupied={isOccupied}
class:empty={!isOccupied}
class:selected={isSelected}
onclick={() => handleSeatTap(seatNum)}
onkeydown={(e) => e.key === 'Enter' && handleSeatTap(seatNum)}
role="button"
tabindex="0"
aria-label="Seat {seatNum}{isOccupied ? `: ${seat.player?.name}` : ' (empty)'}"
>
<circle
cx={pos.x}
cy={pos.y}
r={SEAT_RADIUS}
class="seat-circle"
/>
{#if isOccupied}
<!-- Player name -->
<text x={pos.x} y={pos.y - 3} class="seat-player-name" text-anchor="middle">
{truncateName(seat.player?.name ?? '')}
</text>
<!-- Seat number below name -->
<text x={pos.x} y={pos.y + 11} class="seat-number-small" text-anchor="middle">
{seatNum}
</text>
{:else}
<!-- Empty seat: just seat number -->
<text x={pos.x} y={pos.y + 4} class="seat-number" text-anchor="middle">
{seatNum}
</text>
{/if}
</g>
<!-- Dealer button -->
{#if isDealer}
{@const dAngle = Math.PI / 2 + (2 * Math.PI * i) / maxSeats}
{@const dx = CX + (RX - 12) * Math.cos(dAngle)}
{@const dy = CY - (RY - 12) * Math.sin(dAngle)}
<circle cx={dx} cy={dy} r={8} class="dealer-button" />
<text x={dx} y={dy + 3.5} class="dealer-text" text-anchor="middle">D</text>
{/if}
{/each}
</svg>
</div>
<style>
.oval-table-container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.table-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
text-align: center;
}
.oval-table-svg {
width: 100%;
max-width: 280px;
height: auto;
}
/* Table surface */
.table-surface {
fill: var(--color-surface);
stroke: var(--color-border);
stroke-width: 2;
}
.table-felt {
fill: var(--ctp-green);
opacity: 0.15;
}
.table-number-text {
fill: var(--color-text-muted);
font-size: 14px;
font-weight: 600;
}
/* Seats */
.seat-group {
cursor: pointer;
}
.seat-group:focus-visible .seat-circle {
stroke: var(--color-primary);
stroke-width: 2.5;
}
.seat-circle {
stroke-width: 1.5;
transition: fill 100ms ease, stroke 100ms ease;
}
.seat-group.occupied .seat-circle {
fill: var(--color-surface);
stroke: var(--color-primary);
}
.seat-group.empty .seat-circle {
fill: var(--color-bg);
stroke: var(--color-border);
}
.seat-group.selected .seat-circle {
fill: var(--color-primary);
stroke: var(--color-primary);
}
.seat-group.selected .seat-player-name,
.seat-group.selected .seat-number,
.seat-group.selected .seat-number-small {
fill: var(--color-bg);
}
.seat-player-name {
fill: var(--color-text);
font-size: 8px;
font-weight: 600;
}
.seat-number {
fill: var(--color-text-muted);
font-size: 12px;
font-weight: 500;
}
.seat-number-small {
fill: var(--color-text-muted);
font-size: 7px;
}
/* Dealer button */
.dealer-button {
fill: var(--ctp-yellow);
stroke: var(--ctp-peach);
stroke-width: 1;
}
.dealer-text {
fill: var(--ctp-crust);
font-size: 9px;
font-weight: 700;
}
</style>

View file

@ -0,0 +1,196 @@
<script lang="ts">
/**
* Table list view — alternative to oval view for large tournaments.
*
* DataTable format showing tables with player counts and balance status.
* Expandable rows to see seated players.
* Designed for 10+ table tournaments where oval view gets crowded.
*/
import type { Table, Player } from '$lib/stores/tournament.svelte';
import DataTable from '$lib/components/DataTable.svelte';
interface Props {
tables: Table[];
players: Player[];
balanceMaxDiff: number;
onbreaktable?: (tableId: string) => void;
}
let {
tables,
players,
balanceMaxDiff,
onbreaktable
}: Props = $props();
/** Expand tracking. */
let expandedTableId = $state<string | null>(null);
/** Build table data with computed fields. */
let tableData = $derived(
tables.map((t) => ({
...t,
player_count: t.players.length,
balance: t.players.length > 0 ? balanceIndicator(t.players.length) : '-'
}))
);
const columns = [
{ key: 'number', label: 'Table #', sortable: true, align: 'center' as const, width: '80px' },
{ key: 'player_count', label: 'Players', sortable: true, align: 'center' as const, width: '80px' },
{ key: 'seats', label: 'Seats', sortable: true, align: 'center' as const, width: '80px' },
{
key: 'balance',
label: 'Balance',
sortable: false,
align: 'center' as const,
width: '80px'
}
];
function balanceIndicator(playerCount: number): string {
if (balanceMaxDiff <= 1) return 'OK';
// Rough indicator: if this table is above or below average
const avg = tables.reduce((sum, t) => sum + t.players.length, 0) / tables.length;
const diff = Math.abs(playerCount - avg);
if (diff <= 1) return 'OK';
return diff >= 2 ? '!!' : '!';
}
/** Get players seated at a specific table. */
function playersAtTable(tableId: string): Player[] {
const table = tables.find((t) => t.id === tableId);
if (!table) return [];
return table.players
.map((pid) => players.find((p) => p.id === pid))
.filter((p): p is Player => p !== undefined);
}
function handleRowClick(item: Record<string, unknown>): void {
const id = String(item['id']);
expandedTableId = expandedTableId === id ? null : id;
}
</script>
<div class="table-list-view">
<DataTable
{columns}
data={tableData}
sortable={true}
searchable={false}
loading={false}
emptyMessage="No tables set up yet"
rowKey={(item) => String(item['id'])}
onrowclick={handleRowClick}
/>
<!-- Expanded player list -->
{#if expandedTableId}
{@const expandedPlayers = playersAtTable(expandedTableId)}
{@const expandedTable = tables.find((t) => t.id === expandedTableId)}
<div class="expanded-panel">
<div class="expanded-header">
<span class="expanded-title">
Table {expandedTable?.number} - {expandedPlayers.length} players
</span>
{#if onbreaktable && tables.length > 1}
<button
class="break-btn touch-target"
onclick={() => onbreaktable?.(expandedTableId ?? '')}
>
Break Table
</button>
{/if}
</div>
{#if expandedPlayers.length > 0}
<ul class="player-list">
{#each expandedPlayers as player}
<li class="player-item">
<span class="player-name">{player.name}</span>
<span class="player-chips number">{player.chips.toLocaleString()}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty-players">No players seated.</p>
{/if}
</div>
{/if}
</div>
<style>
.table-list-view {
width: 100%;
}
.expanded-panel {
margin-top: var(--space-2);
padding: var(--space-3);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.expanded-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.expanded-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.break-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: white;
background-color: var(--color-error);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.break-btn:hover {
opacity: 0.9;
}
.player-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.player-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background-color: var(--color-bg);
}
.player-name {
font-size: var(--text-sm);
color: var(--color-text);
}
.player-chips {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.empty-players {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: var(--space-4);
}
</style>

View file

@ -16,11 +16,21 @@ export interface ClockSnapshot {
small_blind: number; small_blind: number;
big_blind: number; big_blind: number;
ante: number; ante: number;
bb_ante: number;
elapsed_seconds: number; elapsed_seconds: number;
remaining_seconds: number; remaining_seconds: number;
duration_seconds: number;
is_break: boolean; is_break: boolean;
is_paused: boolean; is_paused: boolean;
is_hand_for_hand: boolean;
next_break_in_seconds: number | null; next_break_in_seconds: number | null;
next_level_name: string | null;
next_level_small_blind: number | null;
next_level_big_blind: number | null;
next_level_duration_seconds: number | null;
next_level_is_break: boolean;
chip_up_denomination: number | null;
game_type: string | null;
} }
export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away'; export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away';
@ -52,11 +62,60 @@ export interface FinancialSummary {
total_buyin: number; total_buyin: number;
total_rebuys: number; total_rebuys: number;
total_addons: number; total_addons: number;
total_reentries: number;
total_collected: number; total_collected: number;
prize_pool: number; prize_pool: number;
house_fee: number; house_fee: number;
paid_positions: number; paid_positions: number;
payouts: PayoutEntry[]; payouts: PayoutEntry[];
buyin_count: number;
rebuy_count: number;
addon_count: number;
reentry_count: number;
buyin_amount: number;
rebuy_amount: number;
addon_amount: number;
reentry_amount: number;
guarantee: number | null;
guarantee_shortfall: number;
season_reserve: number;
rounding_denomination: number;
rake_breakdown: RakeBreakdown[];
}
export interface RakeBreakdown {
category: string;
amount: number;
}
export interface Transaction {
id: string;
tournament_id: string;
player_id: string;
player_name: string;
type: string;
amount: number;
chips: number;
timestamp: number;
undone: boolean;
undone_by: string | null;
}
export type DealType = 'icm' | 'chip_chop' | 'even_chop' | 'custom' | 'partial_chop';
export interface DealProposal {
type: DealType;
players: DealPlayerEntry[];
amount_in_play: number;
amount_to_split: number;
}
export interface DealPlayerEntry {
player_id: string;
player_name: string;
chips: number;
proposed_payout: number;
original_payout: number;
} }
export interface PayoutEntry { export interface PayoutEntry {
@ -150,6 +209,24 @@ class TournamentState {
return this.players.length; return this.players.length;
} }
/** Busted (eliminated) players count. */
get bustedPlayers(): number {
return this.players.filter((p) => p.status === 'eliminated').length;
}
/** Average chip stack among active players. */
get averageStack(): number {
const active = this.players.filter((p) => p.status === 'active');
if (active.length === 0) return 0;
const total = active.reduce((sum, p) => sum + p.chips, 0);
return Math.round(total / active.length);
}
/** Total chips in play across all active players. */
get totalChips(): number {
return this.players.filter((p) => p.status === 'active').reduce((sum, p) => sum + p.chips, 0);
}
/** Active tables count. */ /** Active tables count. */
get activeTables(): number { get activeTables(): number {
return this.tables.filter((t) => t.players.length > 0).length; return this.tables.filter((t) => t.players.length > 0).length;
@ -160,6 +237,19 @@ class TournamentState {
return this.balanceStatus?.is_balanced ?? true; return this.balanceStatus?.is_balanced ?? true;
} }
/** Transactions list. */
transactions = $state<Transaction[]>([]);
/** Handle transaction updates. */
private addOrUpdateTransaction(tx: Transaction): void {
const idx = this.transactions.findIndex((t) => t.id === tx.id);
if (idx >= 0) {
this.transactions[idx] = tx;
} else {
this.transactions = [tx, ...this.transactions];
}
}
// ============================================ // ============================================
// WebSocket message handler // WebSocket message handler
// ============================================ // ============================================
@ -246,6 +336,15 @@ class TournamentState {
this.addActivity(msg.data as ActivityEntry); this.addActivity(msg.data as ActivityEntry);
break; break;
// Transactions
case 'transaction.new':
case 'transaction.updated':
this.addOrUpdateTransaction(msg.data as Transaction);
break;
case 'transaction.undone':
this.addOrUpdateTransaction(msg.data as Transaction);
break;
// Connection // Connection
case 'connected': case 'connected':
console.log('tournament: connected to server'); console.log('tournament: connected to server');
@ -266,6 +365,7 @@ class TournamentState {
this.activity = []; this.activity = [];
this.rankings = []; this.rankings = [];
this.balanceStatus = null; this.balanceStatus = null;
this.transactions = [];
} }
// ============================================ // ============================================
@ -281,6 +381,7 @@ class TournamentState {
this.activity = snapshot.activity ?? []; this.activity = snapshot.activity ?? [];
this.rankings = snapshot.rankings ?? []; this.rankings = snapshot.rankings ?? [];
this.balanceStatus = snapshot.balance_status ?? null; this.balanceStatus = snapshot.balance_status ?? null;
this.transactions = snapshot.transactions ?? [];
} }
private addOrUpdatePlayer(player: Player): void { private addOrUpdatePlayer(player: Player): void {
@ -320,6 +421,7 @@ interface FullSnapshot {
activity?: ActivityEntry[]; activity?: ActivityEntry[];
rankings?: PlayerRanking[]; rankings?: PlayerRanking[];
balance_status?: BalanceStatus; balance_status?: BalanceStatus;
transactions?: Transaction[];
} }
/** Singleton tournament state instance. */ /** Singleton tournament state instance. */

View file

@ -1,92 +1,288 @@
<script lang="ts"> <script lang="ts">
import { tournament } from '$lib/stores/tournament.svelte'; import { tournament } from '$lib/stores/tournament.svelte';
import ClockDisplay from '$lib/components/ClockDisplay.svelte';
import BlindInfo from '$lib/components/BlindInfo.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import Loading from '$lib/components/Loading.svelte';
/**
* Overview tab -- the TD's primary workspace.
*
* Priority order (from CONTEXT.md):
* 1. Clock & current level (biggest element, ~40% viewport on mobile)
* 2. Time to next break
* 3. Player count (registered / remaining / busted)
* 4. Table balance status
* 5. Financial summary (prize pool, entries, rebuys)
* 6. Recent activity feed (last few actions)
*/
/** Navigate to tables tab. */
function goToTables(): void {
window.location.hash = '';
window.location.pathname = '/tables';
}
/** Navigate to financials tab. */
function goToFinancials(): void {
window.location.hash = '';
window.location.pathname = '/financials';
}
/** Format chip/currency values. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
</script> </script>
<div class="page-content"> <div class="page-content">
<h2>Overview</h2>
<p class="text-secondary">Tournament dashboard — detailed views coming in Plan N.</p>
{#if tournament.clock} {#if tournament.clock}
<div class="stats-grid"> {@const clock = tournament.clock}
<div class="stat-card">
<span class="stat-label">Players</span> <!-- 1. Clock Display (biggest element) -->
<span class="stat-value number">{tournament.remainingPlayers}/{tournament.totalPlayers}</span> <ClockDisplay {clock} />
<!-- 2. Time to break -->
<BlindInfo {clock} />
<!-- 3. Player Count Card -->
<div class="info-card player-card">
<div class="card-row">
<span class="card-stat-main number">{tournament.remainingPlayers} / {tournament.totalPlayers}</span>
<span class="card-stat-label">remaining</span>
</div> </div>
<div class="stat-card"> <div class="card-row-secondary">
<span class="stat-label">Tables</span> <span class="card-detail">{tournament.bustedPlayers} busted</span>
<span class="stat-value number">{tournament.activeTables}</span> <span class="card-detail">Avg: <span class="chips">{formatNumber(tournament.averageStack)}</span></span>
</div>
<div class="stat-card">
<span class="stat-label">Level</span>
<span class="stat-value number">{tournament.clock.level}</span>
</div>
<div class="stat-card">
<span class="stat-label">Blinds</span>
<span class="stat-value blinds">{tournament.clock.small_blind}/{tournament.clock.big_blind}</span>
</div> </div>
{#if tournament.totalChips > 0}
<div class="card-muted">
Total chips: <span class="chips">{formatNumber(tournament.totalChips)}</span>
</div>
{/if}
</div> </div>
<!-- 4. Table Balance Status -->
{#if tournament.balanceStatus}
{#if tournament.isBalanced}
<div class="info-card balance-card balanced">
<span class="balance-indicator balanced-dot"></span>
<span class="balance-text">Tables balanced</span>
</div>
{:else}
<button
class="info-card balance-card unbalanced touch-target"
onclick={goToTables}
>
<span class="balance-indicator unbalanced-dot"></span>
<span class="balance-text">
Tables unbalanced
{#if tournament.balanceStatus.moves_needed.length > 0}
-- {tournament.balanceStatus.moves_needed.length} move{tournament.balanceStatus.moves_needed.length !== 1 ? 's' : ''} needed
{/if}
</span>
<span class="card-chevron" aria-hidden="true">&rsaquo;</span>
</button>
{/if}
{/if}
<!-- 5. Financial Summary Card -->
{#if tournament.financials}
{@const fin = tournament.financials}
<button
class="info-card finance-summary-card touch-target"
onclick={goToFinancials}
>
<div class="finance-row-main">
<span class="finance-label">Prize Pool</span>
<span class="currency finance-pool">{formatNumber(fin.prize_pool)}</span>
</div>
<div class="finance-row-detail">
<span class="finance-detail">Entries: {fin.buyin_count ?? 0}</span>
<span class="finance-detail">Rebuys: {fin.rebuy_count ?? 0}</span>
<span class="finance-detail">Add-ons: {fin.addon_count ?? 0}</span>
</div>
{#if fin.guarantee && fin.guarantee > 0 && fin.guarantee_shortfall > 0}
<div class="guarantee-warning">
Guarantee: {formatNumber(fin.guarantee)}
(house covers {formatNumber(fin.guarantee_shortfall)})
</div>
{/if}
<span class="card-chevron" aria-hidden="true">&rsaquo;</span>
</button>
{/if}
<!-- 6. Activity Feed (scrollable, takes remaining space) -->
<ActivityFeed entries={tournament.activity} limit={15} />
{:else} {:else}
<!-- Skeleton loading state -->
<Loading variant="skeleton" rows={5} />
<p class="empty-state">No active tournament. Start or join a tournament to see the overview.</p> <p class="empty-state">No active tournament. Start or join a tournament to see the overview.</p>
{/if} {/if}
</div> </div>
<style> <style>
.page-content { .page-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4); padding: var(--space-4);
} }
h2 { /* Info cards */
font-size: var(--text-2xl); .info-card {
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.text-secondary {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-6);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.stat-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-4); padding: var(--space-3) var(--space-4);
background-color: var(--color-surface); background-color: var(--color-surface);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.stat-label { /* Player card */
font-size: var(--text-xs); .player-card .card-row {
text-transform: uppercase; display: flex;
letter-spacing: 0.05em; align-items: baseline;
color: var(--color-text-muted); gap: var(--space-2);
} }
.stat-value { .card-stat-main {
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
} }
.card-stat-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.card-row-secondary {
display: flex;
gap: var(--space-4);
}
.card-detail {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.card-muted {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* Balance card */
.balance-card {
flex-direction: row;
align-items: center;
gap: var(--space-3);
cursor: default;
position: relative;
}
button.balance-card {
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: inherit;
}
button.balance-card:hover {
background-color: var(--color-surface-hover);
}
.balance-indicator {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.balanced-dot {
background-color: var(--color-success);
}
.unbalanced-dot {
background-color: var(--color-warning);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.balance-card.unbalanced {
border-color: var(--color-warning);
}
.balance-text {
flex: 1;
font-size: var(--text-sm);
color: var(--color-text);
}
.card-chevron {
font-size: var(--text-xl);
color: var(--color-text-muted);
flex-shrink: 0;
}
/* Finance summary card */
.finance-summary-card {
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: inherit;
position: relative;
}
.finance-summary-card:hover {
background-color: var(--color-surface-hover);
}
.finance-row-main {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.finance-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.finance-pool {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-prize);
}
.finance-row-detail {
display: flex;
gap: var(--space-4);
}
.finance-detail {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.guarantee-warning {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-warning);
margin-top: var(--space-1);
}
/* Empty state */
.empty-state { .empty-state {
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic; font-style: italic;
padding: var(--space-8) 0; padding: var(--space-4) 0;
text-align: center; text-align: center;
} }
</style> </style>

View file

@ -1,35 +1,234 @@
<script lang="ts"> <script lang="ts">
/**
* Tables tab page.
*
* Grid of oval tables (default) with seated players, list view alternative,
* balancing panel, break table action, and seat move via tap-tap flow.
* All interactions use tap-tap pattern (no drag-and-drop in Phase 1).
*/
import { tournament } from '$lib/stores/tournament.svelte'; import { tournament } from '$lib/stores/tournament.svelte';
import DataTable from '$lib/components/DataTable.svelte'; import type { Player, BalanceMove } from '$lib/stores/tournament.svelte';
import { toast } from '$lib/stores/toast.svelte';
import OvalTable from '$lib/components/OvalTable.svelte';
import TableListView from '$lib/components/TableListView.svelte';
import BalancingPanel from '$lib/components/BalancingPanel.svelte';
const columns = [ /** View mode: oval (default) or list. */
{ key: 'number', label: 'Table #', sortable: true, align: 'center' as const }, let viewMode = $state<'oval' | 'list'>('oval');
{ key: 'seats', label: 'Seats', sortable: true, align: 'center' as const },
{ key: 'player_count', label: 'Players', sortable: true, align: 'center' as const },
{ key: 'is_final_table', label: 'Final', hideMobile: true, sortable: true, align: 'center' as const, render: (t: Record<string, unknown>) => t['is_final_table'] ? 'Yes' : '' }
];
let tableData = $derived( /** Tap-tap move state: first tap selects source, second tap selects destination. */
tournament.tables.map((t) => ({ let moveSource = $state<{ tableId: string; seatNumber: number } | null>(null);
...t, let confirmingBreak = $state<string | null>(null);
player_count: t.players.length let handForHand = $state(false);
}))
); /** Build seat info for a specific table. */
function seatsForTable(tableId: string, maxSeats: number) {
const table = tournament.tables.find((t) => t.id === tableId);
if (!table) return [];
const seats: Array<{ seatNumber: number; player: Player | null }> = [];
for (let i = 1; i <= maxSeats; i++) {
// Find player assigned to this seat
const player = tournament.players.find(
(p) => p.table_id === tableId && p.seat === i
);
seats.push({ seatNumber: i, player: player ?? null });
}
return seats;
}
/** Handle seat tap for tap-tap move flow. */
function handleSeatTap(tableId: string, seatNumber: number): void {
if (!moveSource) {
// First tap: select source
const player = tournament.players.find(
(p) => p.table_id === tableId && p.seat === seatNumber
);
if (!player) {
toast.info('Select an occupied seat as source.');
return;
}
moveSource = { tableId, seatNumber };
toast.info(`Selected ${player.name} (Table ${getTableNumber(tableId)}, Seat ${seatNumber}). Tap destination seat.`);
} else {
// Second tap: select destination
if (moveSource.tableId === tableId && moveSource.seatNumber === seatNumber) {
// Same seat: deselect
moveSource = null;
toast.info('Move cancelled.');
return;
}
const destPlayer = tournament.players.find(
(p) => p.table_id === tableId && p.seat === seatNumber
);
if (destPlayer) {
toast.warning('Destination seat is occupied. Select an empty seat.');
return;
}
const sourcePlayer = tournament.players.find(
(p) => p.table_id === moveSource!.tableId && p.seat === moveSource!.seatNumber
);
toast.success(
`Move confirmed: ${sourcePlayer?.name ?? 'Player'} to Table ${getTableNumber(tableId)}, Seat ${seatNumber}`
);
moveSource = null;
}
}
/** Get table number from ID. */
function getTableNumber(tableId: string): number {
return tournament.tables.find((t) => t.id === tableId)?.number ?? 0;
}
/** Handle break table action. */
function handleBreakTable(tableId: string): void {
confirmingBreak = tableId;
}
/** Confirm break table. */
function confirmBreakTable(): void {
if (!confirmingBreak) return;
const table = tournament.tables.find((t) => t.id === confirmingBreak);
const playerCount = table?.players.length ?? 0;
toast.success(`Table ${table?.number ?? '?'} broken. ${playerCount} players redistributed.`);
confirmingBreak = null;
}
/** Cancel break table. */
function cancelBreakTable(): void {
confirmingBreak = null;
}
/** Handle suggest moves from balancing panel. */
function handleSuggestMoves(): void {
toast.info('Fetching balance suggestions...');
}
/** Handle accept balance move. */
function handleAcceptMove(move: BalanceMove): void {
toast.success(`Move accepted: ${move.player_name} from Table ${move.from_table} to Table ${move.to_table}`);
}
function toggleView(): void {
viewMode = viewMode === 'oval' ? 'list' : 'oval';
}
function toggleHandForHand(): void {
handForHand = !handForHand;
toast.info(handForHand ? 'Hand-for-hand enabled.' : 'Hand-for-hand disabled.');
}
</script> </script>
<div class="page-content"> <div class="page-content">
<h2>Tables</h2> <div class="page-header">
<p class="text-secondary">Active tables and seating.</p> <div>
<h2>Tables</h2>
<p class="text-secondary">
{tournament.tables.length} tables, {tournament.remainingPlayers} players seated
</p>
</div>
<div class="header-actions">
{#if tournament.tables.length > 1}
<button
class="toggle-btn touch-target"
class:active={handForHand}
onclick={toggleHandForHand}
aria-pressed={handForHand}
>
H4H
</button>
{/if}
<button
class="toggle-btn touch-target"
onclick={toggleView}
aria-label="Switch to {viewMode === 'oval' ? 'list' : 'oval'} view"
>
{viewMode === 'oval' ? 'List' : 'Oval'}
</button>
</div>
</div>
<DataTable <!-- Move source indicator -->
{columns} {#if moveSource}
data={tableData} {@const srcPlayer = tournament.players.find(
sortable={true} (p) => p.table_id === moveSource?.tableId && p.seat === moveSource?.seatNumber
searchable={false} )}
loading={false} <div class="move-banner">
emptyMessage="No tables set up yet" <span>Moving: <strong>{srcPlayer?.name ?? 'Player'}</strong> (Table {getTableNumber(moveSource.tableId)}, Seat {moveSource.seatNumber})</span>
rowKey={(item) => String(item['id'])} <button class="cancel-move-btn" onclick={() => { moveSource = null; }}>Cancel</button>
</div>
{/if}
<!-- Balancing panel -->
<BalancingPanel
balanceStatus={tournament.balanceStatus}
onacceptmove={handleAcceptMove}
onsuggestmoves={handleSuggestMoves}
/> />
<!-- Break table confirmation dialog -->
{#if confirmingBreak}
{@const breakTable = tournament.tables.find((t) => t.id === confirmingBreak)}
<div class="confirm-dialog">
<div class="confirm-content">
<p class="confirm-text">
Distribute {breakTable?.players.length ?? 0} players from Table {breakTable?.number ?? '?'} to remaining tables?
</p>
<div class="confirm-actions">
<button class="confirm-yes touch-target" onclick={confirmBreakTable}>
Break Table
</button>
<button class="confirm-no touch-target" onclick={cancelBreakTable}>
Cancel
</button>
</div>
</div>
</div>
{/if}
<!-- Table views -->
{#if viewMode === 'oval'}
{#if tournament.tables.length === 0}
<p class="empty-state">No tables set up yet.</p>
{:else}
<div class="table-grid">
{#each tournament.tables as table (table.id)}
<div class="table-card">
<OvalTable
tableNumber={table.number}
tableId={table.id}
maxSeats={table.seats}
seats={seatsForTable(table.id, table.seats)}
dealerSeat={null}
selectedSeat={moveSource?.tableId === table.id ? moveSource.seatNumber : null}
onseattap={handleSeatTap}
/>
<div class="table-actions">
{#if tournament.tables.length > 1}
<button
class="table-action-btn touch-target"
onclick={() => handleBreakTable(table.id)}
>
Break
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{:else}
<TableListView
tables={tournament.tables}
players={tournament.players}
balanceMaxDiff={tournament.balanceStatus?.max_diff ?? 0}
onbreaktable={handleBreakTable}
/>
{/if}
</div> </div>
<style> <style>
@ -37,16 +236,212 @@
padding: var(--space-4); padding: var(--space-4);
} }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-4);
}
h2 { h2 {
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
margin-bottom: var(--space-2); margin-bottom: var(--space-1);
} }
.text-secondary { .text-secondary {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: var(--text-sm); font-size: var(--text-sm);
}
.header-actions {
display: flex;
gap: var(--space-2);
}
.toggle-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.toggle-btn:hover {
background-color: var(--color-surface-hover);
}
.toggle-btn.active {
background-color: var(--color-primary);
color: var(--color-bg);
border-color: var(--color-primary);
}
/* Move banner */
.move-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
background-color: var(--color-surface);
border: 1px solid var(--color-primary);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
color: var(--color-text);
}
.move-banner strong {
color: var(--color-primary);
}
.cancel-move-btn {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-surface-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
min-height: 32px;
min-width: 32px;
}
/* Table grid */
.table-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
@media (max-width: 480px) {
.table-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 768px) {
.table-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.table-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.table-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.table-actions {
display: flex;
gap: var(--space-2);
}
.table-action-btn {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
cursor: pointer;
min-height: 32px;
}
.table-action-btn:hover {
background-color: var(--color-error);
color: white;
}
/* Confirm dialog */
.confirm-dialog {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
padding: var(--space-4);
}
.confirm-content {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-6);
max-width: 360px;
width: 100%;
box-shadow: var(--shadow-lg);
}
.confirm-text {
font-size: var(--text-base);
color: var(--color-text);
margin-bottom: var(--space-4);
line-height: var(--leading-normal);
}
.confirm-actions {
display: flex;
gap: var(--space-3);
}
.confirm-yes {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: white;
background-color: var(--color-error);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.confirm-yes:hover {
opacity: 0.9;
}
.confirm-no {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.confirm-no:hover {
background-color: var(--color-surface-hover);
}
/* Empty state */
.empty-state {
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-8) 0;
text-align: center;
} }
</style> </style>