From e7da206d32f92ef9e5cb713f318d12d538b59d91 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 08:18:46 +0100 Subject: [PATCH] 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 --- .../src/lib/components/ActivityFeed.svelte | 203 ++++++++ .../src/lib/components/BalancingPanel.svelte | 332 +++++++++++++ frontend/src/lib/components/BlindInfo.svelte | 81 ++++ .../src/lib/components/ClockDisplay.svelte | 276 +++++++++++ frontend/src/lib/components/OvalTable.svelte | 268 +++++++++++ .../src/lib/components/TableListView.svelte | 196 ++++++++ frontend/src/lib/stores/tournament.svelte.ts | 102 ++++ frontend/src/routes/overview/+page.svelte | 300 ++++++++++-- frontend/src/routes/tables/+page.svelte | 443 +++++++++++++++++- 9 files changed, 2125 insertions(+), 76 deletions(-) create mode 100644 frontend/src/lib/components/ActivityFeed.svelte create mode 100644 frontend/src/lib/components/BalancingPanel.svelte create mode 100644 frontend/src/lib/components/BlindInfo.svelte create mode 100644 frontend/src/lib/components/ClockDisplay.svelte create mode 100644 frontend/src/lib/components/OvalTable.svelte create mode 100644 frontend/src/lib/components/TableListView.svelte diff --git a/frontend/src/lib/components/ActivityFeed.svelte b/frontend/src/lib/components/ActivityFeed.svelte new file mode 100644 index 0000000..8d178ec --- /dev/null +++ b/frontend/src/lib/components/ActivityFeed.svelte @@ -0,0 +1,203 @@ + + +
+
+

Recent Activity

+ {#if showViewAll && onviewall && entries.length > limit} + + {/if} +
+ + {#if visibleEntries.length === 0} +

No activity yet

+ {:else} +
+ {#each visibleEntries as entry (entry.id)} + {@const style = getEntryStyle(entry.type)} +
+ + {entry.message} + {formatRelativeTime(entry.timestamp)} +
+ {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/BalancingPanel.svelte b/frontend/src/lib/components/BalancingPanel.svelte new file mode 100644 index 0000000..dcf408b --- /dev/null +++ b/frontend/src/lib/components/BalancingPanel.svelte @@ -0,0 +1,332 @@ + + +{#if isUnbalanced} +
+ + + + + {#if expanded} +
+ {#if !hasSuggestions && !loading} + + {:else if loading} +
Calculating suggestions...
+ {:else if balanceStatus} +
+ Suggested Moves + Live +
+
    + {#each balanceStatus.moves_needed as move, idx} +
  • +
    + Move {move.player_name} + from Table {move.from_table} to Table {move.to_table}, Seat {move.to_seat} +
    +
    + {#if selectedMoveIdx === idx} + + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/BlindInfo.svelte b/frontend/src/lib/components/BlindInfo.svelte new file mode 100644 index 0000000..c269b2b --- /dev/null +++ b/frontend/src/lib/components/BlindInfo.svelte @@ -0,0 +1,81 @@ + + +{#if breakInfo.visible} +
+ {breakInfo.label}: + {breakInfo.time} +
+{/if} + + diff --git a/frontend/src/lib/components/ClockDisplay.svelte b/frontend/src/lib/components/ClockDisplay.svelte new file mode 100644 index 0000000..544b4b4 --- /dev/null +++ b/frontend/src/lib/components/ClockDisplay.svelte @@ -0,0 +1,276 @@ + + +
+ + {#if clock.is_hand_for_hand} +
HAND FOR HAND
+ {/if} + + + {#if clock.is_break} +
BREAK
+ {/if} + + + {#if clock.is_paused} +
PAUSED
+ {/if} + + +
+ {formatTime(clock.remaining_seconds)} +
+ + + {#if !clock.is_break} +
{levelLabel}
+ {/if} + + + {#if !clock.is_break} +
+ + SB: {formatChips(clock.small_blind)} / BB: {formatChips(clock.big_blind)} + + {#if clock.bb_ante > 0} + BB Ante: {formatChips(clock.bb_ante)} + {:else if clock.ante > 0} + Ante: {formatChips(clock.ante)} + {/if} +
+ {/if} + + + {#if nextLevelPreview} +
+ {nextLevelPreview} + {#if clock.chip_up_denomination} + Chip-up: Remove {formatChips(clock.chip_up_denomination)}s + {/if} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/OvalTable.svelte b/frontend/src/lib/components/OvalTable.svelte new file mode 100644 index 0000000..f9349d6 --- /dev/null +++ b/frontend/src/lib/components/OvalTable.svelte @@ -0,0 +1,268 @@ + + +
+
Table {tableNumber}
+ + + + + + + + + + T{tableNumber} + + + + {#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} + + + handleSeatTap(seatNum)} + onkeydown={(e) => e.key === 'Enter' && handleSeatTap(seatNum)} + role="button" + tabindex="0" + aria-label="Seat {seatNum}{isOccupied ? `: ${seat.player?.name}` : ' (empty)'}" + > + + {#if isOccupied} + + + {truncateName(seat.player?.name ?? '')} + + + + {seatNum} + + {:else} + + + {seatNum} + + {/if} + + + + {#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)} + + D + {/if} + {/each} + +
+ + diff --git a/frontend/src/lib/components/TableListView.svelte b/frontend/src/lib/components/TableListView.svelte new file mode 100644 index 0000000..9b39c0b --- /dev/null +++ b/frontend/src/lib/components/TableListView.svelte @@ -0,0 +1,196 @@ + + +
+ String(item['id'])} + onrowclick={handleRowClick} + /> + + + {#if expandedTableId} + {@const expandedPlayers = playersAtTable(expandedTableId)} + {@const expandedTable = tables.find((t) => t.id === expandedTableId)} +
+
+ + Table {expandedTable?.number} - {expandedPlayers.length} players + + {#if onbreaktable && tables.length > 1} + + {/if} +
+ {#if expandedPlayers.length > 0} +
    + {#each expandedPlayers as player} +
  • + {player.name} + {player.chips.toLocaleString()} +
  • + {/each} +
+ {:else} +

No players seated.

+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/lib/stores/tournament.svelte.ts b/frontend/src/lib/stores/tournament.svelte.ts index a12d7bd..7df7fe6 100644 --- a/frontend/src/lib/stores/tournament.svelte.ts +++ b/frontend/src/lib/stores/tournament.svelte.ts @@ -16,11 +16,21 @@ export interface ClockSnapshot { small_blind: number; big_blind: number; ante: number; + bb_ante: number; elapsed_seconds: number; remaining_seconds: number; + duration_seconds: number; is_break: boolean; is_paused: boolean; + is_hand_for_hand: boolean; 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'; @@ -52,11 +62,60 @@ export interface FinancialSummary { total_buyin: number; total_rebuys: number; total_addons: number; + total_reentries: number; total_collected: number; prize_pool: number; house_fee: number; paid_positions: number; 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 { @@ -150,6 +209,24 @@ class TournamentState { 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. */ get activeTables(): number { return this.tables.filter((t) => t.players.length > 0).length; @@ -160,6 +237,19 @@ class TournamentState { return this.balanceStatus?.is_balanced ?? true; } + /** Transactions list. */ + transactions = $state([]); + + /** 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 // ============================================ @@ -246,6 +336,15 @@ class TournamentState { this.addActivity(msg.data as ActivityEntry); 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 case 'connected': console.log('tournament: connected to server'); @@ -266,6 +365,7 @@ class TournamentState { this.activity = []; this.rankings = []; this.balanceStatus = null; + this.transactions = []; } // ============================================ @@ -281,6 +381,7 @@ class TournamentState { this.activity = snapshot.activity ?? []; this.rankings = snapshot.rankings ?? []; this.balanceStatus = snapshot.balance_status ?? null; + this.transactions = snapshot.transactions ?? []; } private addOrUpdatePlayer(player: Player): void { @@ -320,6 +421,7 @@ interface FullSnapshot { activity?: ActivityEntry[]; rankings?: PlayerRanking[]; balance_status?: BalanceStatus; + transactions?: Transaction[]; } /** Singleton tournament state instance. */ diff --git a/frontend/src/routes/overview/+page.svelte b/frontend/src/routes/overview/+page.svelte index 3fee3f4..ac85456 100644 --- a/frontend/src/routes/overview/+page.svelte +++ b/frontend/src/routes/overview/+page.svelte @@ -1,92 +1,288 @@
-

Overview

-

Tournament dashboard — detailed views coming in Plan N.

- {#if tournament.clock} -
-
- Players - {tournament.remainingPlayers}/{tournament.totalPlayers} + {@const clock = tournament.clock} + + + + + + + + +
+
+ {tournament.remainingPlayers} / {tournament.totalPlayers} + remaining
-
- Tables - {tournament.activeTables} -
-
- Level - {tournament.clock.level} -
-
- Blinds - {tournament.clock.small_blind}/{tournament.clock.big_blind} +
+ {tournament.bustedPlayers} busted + Avg: {formatNumber(tournament.averageStack)}
+ {#if tournament.totalChips > 0} +
+ Total chips: {formatNumber(tournament.totalChips)} +
+ {/if}
+ + + {#if tournament.balanceStatus} + {#if tournament.isBalanced} +
+ + Tables balanced +
+ {:else} + + {/if} + {/if} + + + {#if tournament.financials} + {@const fin = tournament.financials} + + {/if} + + + + {:else} + +

No active tournament. Start or join a tournament to see the overview.

{/if}
diff --git a/frontend/src/routes/tables/+page.svelte b/frontend/src/routes/tables/+page.svelte index 55e8f9b..78898be 100644 --- a/frontend/src/routes/tables/+page.svelte +++ b/frontend/src/routes/tables/+page.svelte @@ -1,35 +1,234 @@
-

Tables

-

Active tables and seating.

+ - String(item['id'])} + + {#if moveSource} + {@const srcPlayer = tournament.players.find( + (p) => p.table_id === moveSource?.tableId && p.seat === moveSource?.seatNumber + )} +
+ Moving: {srcPlayer?.name ?? 'Player'} (Table {getTableNumber(moveSource.tableId)}, Seat {moveSource.seatNumber}) + +
+ {/if} + + + + + + {#if confirmingBreak} + {@const breakTable = tournament.tables.find((t) => t.id === confirmingBreak)} +
+
+

+ Distribute {breakTable?.players.length ?? 0} players from Table {breakTable?.number ?? '?'} to remaining tables? +

+
+ + +
+
+
+ {/if} + + + {#if viewMode === 'oval'} + {#if tournament.tables.length === 0} +

No tables set up yet.

+ {:else} +
+ {#each tournament.tables as table (table.id)} +
+ +
+ {#if tournament.tables.length > 1} + + {/if} +
+
+ {/each} +
+ {/if} + {:else} + + {/if}