- add Task 10: store WebSocket send in Zustand, child routes use store - fix all tapBingoSquare test calls to 2-arg signature (no displayName) - fix requestNewBingoCard test calls to include displayName - remove duplicate Leaderboard from host-tab.tsx - fix bingo completion tests: cards move on redraw, not on detection - fix addBingoAnnouncement to track announcements via count comparison - remove use-websocket.ts and room-store.ts from Unchanged Files - renumber tasks 10-13 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1944 lines
61 KiB
Markdown
1944 lines
61 KiB
Markdown
# Menu Rework — App-Style Navigation Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace ad-hoc tabs with an iOS-style bottom navigation bar driven by TanStack Router nested routes, and add bingo card completion/redraw flow.
|
||
|
||
**Architecture:** Convert flat route files into layout routes with nested children. Shared `RoomLayout` component renders sticky header + `BottomNav` + `<Outlet />`. Player gets 3 tabs (Game, Bingo, Leaderboard), host gets 4 (+ Host). Bingo completion stores cards server-side and allows redraw.
|
||
|
||
**Tech Stack:** TanStack Router (file-based nested routes), React 19, Zustand, Tailwind v4, shadcn/ui, Hono WebSocket server, Zod schemas.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-03-13-menu-rework-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### New Files
|
||
- `packages/shared/src/game-types.ts` — add `completedBingoCardSchema`
|
||
- `packages/shared/src/ws-messages.ts` — add `requestNewBingoCardMessage`
|
||
- `packages/client/src/components/room-layout.tsx` — sticky header + bottom nav + outlet wrapper
|
||
- `packages/client/src/components/bottom-nav.tsx` — fixed bottom tab bar
|
||
- `packages/client/src/components/bingo-claims.tsx` — host verification of completed bingo cards
|
||
- `packages/client/src/components/game-tab.tsx` — shared game content by act
|
||
- `packages/client/src/components/bingo-tab.tsx` — shared bingo tab content
|
||
- `packages/client/src/components/board-tab.tsx` — shared leaderboard tab content
|
||
- `packages/client/src/components/host-tab.tsx` — host-only controls tab content
|
||
- `packages/client/src/routes/play.$roomCode.game.tsx` — player game child route
|
||
- `packages/client/src/routes/play.$roomCode.bingo.tsx` — player bingo child route
|
||
- `packages/client/src/routes/play.$roomCode.board.tsx` — player leaderboard child route
|
||
- `packages/client/src/routes/play.$roomCode.index.tsx` — redirect to game
|
||
- `packages/client/src/routes/host.$roomCode.game.tsx` — host game child route
|
||
- `packages/client/src/routes/host.$roomCode.bingo.tsx` — host bingo child route
|
||
- `packages/client/src/routes/host.$roomCode.board.tsx` — host leaderboard child route
|
||
- `packages/client/src/routes/host.$roomCode.host.tsx` — host controls child route
|
||
- `packages/client/src/routes/host.$roomCode.index.tsx` — redirect to game
|
||
- `packages/server/src/games/__tests__/game-manager-bingo.test.ts` — bingo tests
|
||
|
||
### Modified Files
|
||
- `packages/shared/src/game-types.ts` — add `completedBingoCardSchema`, extend `gameStateSchema`
|
||
- `packages/shared/src/ws-messages.ts` — add `requestNewBingoCardMessage` to `clientMessage` union
|
||
- `packages/server/src/games/game-manager.ts` — tap-only bingo, completion on redraw, scoring across cards, `requestNewBingoCard()`, per-player card generation
|
||
- `packages/server/src/ws/handler.ts` — add `request_new_bingo_card` case, update `tap_bingo_square` for completion, generate bingo card on join
|
||
- `packages/client/src/routes/play.$roomCode.tsx` — convert to layout route (header + nav + outlet)
|
||
- `packages/client/src/routes/host.$roomCode.tsx` — convert to layout route (header + nav + outlet)
|
||
- `packages/client/src/hooks/use-websocket.ts` — expose `send` via Zustand store instead of return value
|
||
- `packages/client/src/stores/room-store.ts` — add `send` function to store
|
||
- `packages/client/src/components/bingo-card.tsx` — add `readonly` prop
|
||
- `packages/client/src/components/leaderboard.tsx` — lobby mode (names only), absorb player list
|
||
|
||
### Unchanged Files
|
||
- `packages/client/src/routes/__root.tsx`
|
||
- `packages/client/src/routes/index.tsx`
|
||
- `packages/client/src/routes/display.$roomCode.tsx`
|
||
|
||
---
|
||
|
||
## Chunk 1: Server-Side Changes
|
||
|
||
### Task 1: Shared Types — completedBingoCard Schema + WS Message
|
||
|
||
**Files:**
|
||
- Modify: `packages/shared/src/game-types.ts`
|
||
- Modify: `packages/shared/src/ws-messages.ts`
|
||
|
||
- [ ] **Step 1: Add `completedBingoCardSchema` to `game-types.ts`**
|
||
|
||
After the `bingoCardSchema` block (line 88), add:
|
||
|
||
```typescript
|
||
export const completedBingoCardSchema = z.object({
|
||
playerId: z.string(),
|
||
displayName: z.string(),
|
||
card: bingoCardSchema,
|
||
completedAt: z.string(),
|
||
})
|
||
|
||
export type CompletedBingoCard = z.infer<typeof completedBingoCardSchema>
|
||
```
|
||
|
||
- [ ] **Step 2: Add `completedBingoCards` to `gameStateSchema`**
|
||
|
||
In `gameStateSchema` (line 122), after the `bingoAnnouncements` field (line 136), add:
|
||
|
||
```typescript
|
||
completedBingoCards: z.array(completedBingoCardSchema),
|
||
```
|
||
|
||
- [ ] **Step 3: Add `requestNewBingoCardMessage` to `ws-messages.ts`**
|
||
|
||
After `tapBingoSquareMessage` (line 55), add:
|
||
|
||
```typescript
|
||
export const requestNewBingoCardMessage = z.object({
|
||
type: z.literal("request_new_bingo_card"),
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: Add to `clientMessage` discriminated union**
|
||
|
||
In the `clientMessage` array (line 82), add `requestNewBingoCardMessage` after `tapBingoSquareMessage`:
|
||
|
||
```typescript
|
||
tapBingoSquareMessage,
|
||
requestNewBingoCardMessage,
|
||
```
|
||
|
||
- [ ] **Step 5: Verify build**
|
||
|
||
Run: `bun run --filter @celebrate-esc/shared build 2>&1 || echo "shared has no build script, checking types..."; cd packages/shared && bunx tsc --noEmit 2>&1 || true`
|
||
|
||
Note: the shared package uses barrel exports (`export *`) so new types auto-export.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/game-types.ts packages/shared/src/ws-messages.ts
|
||
git commit -m "add completedBingoCard schema, request_new_bingo_card WS message"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Server — Bingo Completion Logic
|
||
|
||
**Files:**
|
||
- Modify: `packages/server/src/games/game-manager.ts`
|
||
|
||
**Context:** The bingo section starts at line 159. Key methods: `tapBingoSquare` (line 187), `addBingoAnnouncement` (line 209), `getBingoScore` (line 220), `getGameStateForPlayer` (line 432), `getGameStateForDisplay` (line 454), `buildLeaderboard` (line 475).
|
||
|
||
- [ ] **Step 1: Add `CompletedBingoCard` import**
|
||
|
||
Update the import on line 1 to include `CompletedBingoCard`:
|
||
|
||
```typescript
|
||
import type { Prediction, GameState, Lineup, JuryRound, JuryResult, QuizQuestion, CompletedBingoCard } from "@celebrate-esc/shared"
|
||
```
|
||
|
||
- [ ] **Step 2: Add `completedBingoCards` storage**
|
||
|
||
After `private announcedBingo = new Set<string>()` (line 166), add:
|
||
|
||
```typescript
|
||
private completedBingoCards: CompletedBingoCard[] = []
|
||
```
|
||
|
||
- [ ] **Step 3: Change `tapBingoSquare` from toggle to tap-only**
|
||
|
||
In `tapBingoSquare` (line 192), change:
|
||
|
||
```typescript
|
||
square.tapped = !square.tapped
|
||
```
|
||
|
||
to:
|
||
|
||
```typescript
|
||
if (square.tapped) return { success: true, hasBingo: card.hasBingo }
|
||
square.tapped = true
|
||
```
|
||
|
||
- [ ] **Step 4: Simplify `tapBingoSquare` — detect bingo but don't move card**
|
||
|
||
The card moves to `completedBingoCards` only when the player requests a new card (in `requestNewBingoCard`). This avoids double-counting between the active card and the completed array. The full updated method:
|
||
|
||
```typescript
|
||
tapBingoSquare(playerId: string, tropeId: string): { success: true; hasBingo: boolean; isNewBingo: boolean } | { error: string } {
|
||
const card = this.bingoCards.get(playerId)
|
||
if (!card) return { error: "No bingo card found" }
|
||
const square = card.squares.find((s) => s.tropeId === tropeId)
|
||
if (!square) return { error: "Trope not on your card" }
|
||
if (square.tapped) return { success: true, hasBingo: card.hasBingo, isNewBingo: false }
|
||
square.tapped = true
|
||
const hadBingo = card.hasBingo
|
||
card.hasBingo = this.checkBingo(card.squares)
|
||
const isNewBingo = card.hasBingo && !hadBingo
|
||
return { success: true, hasBingo: card.hasBingo, isNewBingo }
|
||
}
|
||
```
|
||
|
||
Note: signature unchanged (no `displayName` needed here — it's passed in `addBingoAnnouncement` separately).
|
||
|
||
- [ ] **Step 5: Update `addBingoAnnouncement` to track `playerId:cardIndex`**
|
||
|
||
Replace the `addBingoAnnouncement` method (lines 209-214):
|
||
|
||
```typescript
|
||
addBingoAnnouncement(playerId: string, displayName: string): boolean {
|
||
// Count how many bingos this player already announced
|
||
const count = this.bingoAnnouncements.filter((a) => a.playerId === playerId).length
|
||
// Count how many bingo-detected cards this player has (completed + current if hasBingo)
|
||
const completedCount = this.completedBingoCards.filter((c) => c.playerId === playerId).length
|
||
const activeCard = this.bingoCards.get(playerId)
|
||
const totalBingos = completedCount + (activeCard?.hasBingo ? 1 : 0)
|
||
// Only announce if there are more bingos than announcements
|
||
if (count >= totalBingos) return false
|
||
this.bingoAnnouncements.push({ playerId, displayName })
|
||
return true
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Update `getBingoScore` to accumulate across all cards**
|
||
|
||
Cards are in `completedBingoCards` only AFTER redraw, so there's no overlap with the active card. Replace the `getBingoScore` method (lines 220-227):
|
||
|
||
```typescript
|
||
getBingoScore(playerId: string): number {
|
||
let totalTapped = 0
|
||
let totalBonuses = 0
|
||
// Count completed cards (moved here on redraw)
|
||
const completed = this.completedBingoCards.filter((c) => c.playerId === playerId)
|
||
for (const c of completed) {
|
||
totalTapped += c.card.squares.filter((s) => s.tapped).length
|
||
totalBonuses += scoringConfig.bingo_full_bonus
|
||
}
|
||
// Count active card (never overlaps with completed — card moves on redraw)
|
||
const activeCard = this.bingoCards.get(playerId)
|
||
if (activeCard) {
|
||
totalTapped += activeCard.squares.filter((s) => s.tapped).length
|
||
if (activeCard.hasBingo) totalBonuses += scoringConfig.bingo_full_bonus
|
||
}
|
||
return totalTapped * scoringConfig.bingo_per_square + totalBonuses
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Add `requestNewBingoCard` method**
|
||
|
||
This is where the completed card moves to `completedBingoCards` — NOT during tap detection. This avoids double-counting in `getBingoScore`.
|
||
|
||
After `getBingoScore`, add:
|
||
|
||
```typescript
|
||
requestNewBingoCard(playerId: string, displayName: string): { success: true } | { error: string } {
|
||
const currentCard = this.bingoCards.get(playerId)
|
||
if (!currentCard || !currentCard.hasBingo) {
|
||
return { error: "No completed bingo card to replace" }
|
||
}
|
||
// Move current card to completedBingoCards
|
||
this.completedBingoCards.push({
|
||
playerId,
|
||
displayName,
|
||
card: { squares: currentCard.squares.map((s) => ({ ...s })), hasBingo: true },
|
||
completedAt: new Date().toISOString(),
|
||
})
|
||
// Generate new card excluding tropes from the just-completed card
|
||
const excludeIds = new Set(currentCard.squares.map((s) => s.tropeId))
|
||
const available = tropes.filter((t) => !excludeIds.has(t.id))
|
||
const pool = available.length >= 16 ? available : tropes
|
||
const shuffled = [...pool].sort(() => Math.random() - 0.5)
|
||
const selected = shuffled.slice(0, 16)
|
||
this.bingoCards.set(playerId, {
|
||
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
|
||
hasBingo: false,
|
||
})
|
||
return { success: true }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Add `getCompletedBingoCards` accessor**
|
||
|
||
After `requestNewBingoCard`, add:
|
||
|
||
```typescript
|
||
getCompletedBingoCards(): CompletedBingoCard[] {
|
||
return this.completedBingoCards
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8b: Add `generateBingoCardForPlayer` method**
|
||
|
||
The spec requires bingo cards to be viewable before live-event. Add a per-player card generation method (called on join):
|
||
|
||
```typescript
|
||
generateBingoCardForPlayer(playerId: string): void {
|
||
if (this.bingoCards.has(playerId)) return
|
||
const shuffled = [...tropes].sort(() => Math.random() - 0.5)
|
||
const selected = shuffled.slice(0, 16)
|
||
this.bingoCards.set(playerId, {
|
||
squares: selected.map((t) => ({ tropeId: t.id, label: t.label, tapped: false })),
|
||
hasBingo: false,
|
||
})
|
||
}
|
||
```
|
||
|
||
This is called from the WS handler when a player joins, so cards are available immediately. The existing `generateBingoCards(allPlayerIds)` bulk method stays for backward compatibility but can now skip players who already have cards (add a guard: `if (this.bingoCards.has(playerId)) continue`).
|
||
|
||
- [ ] **Step 9: Update `getGameStateForPlayer` to include `completedBingoCards`**
|
||
|
||
In `getGameStateForPlayer` (line 432), after `bingoAnnouncements` (line 446), add:
|
||
|
||
```typescript
|
||
completedBingoCards: this.completedBingoCards,
|
||
```
|
||
|
||
- [ ] **Step 10: Update `getGameStateForDisplay` to include `completedBingoCards`**
|
||
|
||
In `getGameStateForDisplay` (line 454), after `bingoAnnouncements` (line 467), add:
|
||
|
||
```typescript
|
||
completedBingoCards: this.completedBingoCards,
|
||
```
|
||
|
||
- [ ] **Step 11: Verify server tests still pass**
|
||
|
||
Run: `bun test --filter packages/server`
|
||
|
||
Expected: all existing tests pass (the bingo-specific tests are new and will be written in Task 3).
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add packages/server/src/games/game-manager.ts
|
||
git commit -m "add bingo completion logic: tap-only, card storage, scoring across cards, redraw"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Server — Bingo Completion Tests
|
||
|
||
**Files:**
|
||
- Create: `packages/server/src/games/__tests__/game-manager-bingo.test.ts`
|
||
|
||
- [ ] **Step 1: Write bingo tests**
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from "bun:test"
|
||
import { GameManager } from "../game-manager"
|
||
|
||
describe("Bingo", () => {
|
||
let gm: GameManager
|
||
|
||
beforeEach(() => {
|
||
gm = new GameManager()
|
||
})
|
||
|
||
describe("generateBingoCards", () => {
|
||
it("should create a 16-square card for each player", () => {
|
||
gm.generateBingoCards(["p1", "p2"])
|
||
const card1 = gm.getBingoCard("p1")
|
||
const card2 = gm.getBingoCard("p2")
|
||
expect(card1).not.toBeNull()
|
||
expect(card1!.squares).toHaveLength(16)
|
||
expect(card1!.hasBingo).toBe(false)
|
||
expect(card2).not.toBeNull()
|
||
expect(card2!.squares).toHaveLength(16)
|
||
})
|
||
|
||
it("should return null for unknown player", () => {
|
||
expect(gm.getBingoCard("unknown")).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe("tapBingoSquare", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should mark a square as tapped", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
const tropeId = card.squares[0]!.tropeId
|
||
const result = gm.tapBingoSquare("p1", tropeId)
|
||
expect(result).toHaveProperty("success", true)
|
||
expect(card.squares[0]!.tapped).toBe(true)
|
||
})
|
||
|
||
it("should be tap-only (not toggle)", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
const tropeId = card.squares[0]!.tropeId
|
||
gm.tapBingoSquare("p1", tropeId)
|
||
expect(card.squares[0]!.tapped).toBe(true)
|
||
// Tap again — should stay tapped
|
||
const result = gm.tapBingoSquare("p1", tropeId)
|
||
expect(result).toHaveProperty("success", true)
|
||
expect(card.squares[0]!.tapped).toBe(true)
|
||
})
|
||
|
||
it("should error for unknown player", () => {
|
||
const result = gm.tapBingoSquare("unknown", "trope1")
|
||
expect(result).toHaveProperty("error")
|
||
})
|
||
|
||
it("should error for trope not on card", () => {
|
||
const result = gm.tapBingoSquare("p1", "nonexistent-trope")
|
||
expect(result).toHaveProperty("error")
|
||
})
|
||
})
|
||
|
||
describe("bingo detection", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should detect a completed row", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Tap first row (indices 0-3)
|
||
for (let i = 0; i < 4; i++) {
|
||
const result = gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
if (i < 3) {
|
||
expect((result as any).hasBingo).toBe(false)
|
||
} else {
|
||
expect((result as any).hasBingo).toBe(true)
|
||
expect((result as any).isNewBingo).toBe(true)
|
||
}
|
||
}
|
||
})
|
||
|
||
it("should detect a completed column", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Tap first column (indices 0, 4, 8, 12)
|
||
for (const i of [0, 4, 8, 12]) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
expect(card.hasBingo).toBe(true)
|
||
})
|
||
|
||
it("should detect a completed diagonal", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Tap main diagonal (indices 0, 5, 10, 15)
|
||
for (const i of [0, 5, 10, 15]) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
expect(card.hasBingo).toBe(true)
|
||
})
|
||
|
||
it("should detect anti-diagonal", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Tap anti-diagonal (indices 3, 6, 9, 12)
|
||
for (const i of [3, 6, 9, 12]) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
expect(card.hasBingo).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe("bingo completion flow", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should NOT move card to completedBingoCards on bingo detection (only on redraw)", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Complete first row — sets hasBingo but does NOT move card
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
expect(card.hasBingo).toBe(true)
|
||
expect(gm.getCompletedBingoCards()).toHaveLength(0)
|
||
})
|
||
|
||
it("should move card to completedBingoCards on requestNewBingoCard", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Complete first row
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
// Redraw moves the completed card
|
||
gm.requestNewBingoCard("p1", "Player 1")
|
||
const completed = gm.getCompletedBingoCards()
|
||
expect(completed).toHaveLength(1)
|
||
expect(completed[0]!.playerId).toBe("p1")
|
||
expect(completed[0]!.displayName).toBe("Player 1")
|
||
expect(completed[0]!.card.hasBingo).toBe(true)
|
||
expect(completed[0]!.completedAt).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
describe("requestNewBingoCard", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should error if card has no bingo", () => {
|
||
const result = gm.requestNewBingoCard("p1", "Player 1")
|
||
expect(result).toHaveProperty("error")
|
||
})
|
||
|
||
it("should generate a fresh card after bingo", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
const originalTropes = card.squares.map((s) => s.tropeId)
|
||
// Complete first row
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
const result = gm.requestNewBingoCard("p1", "Player 1")
|
||
expect(result).toHaveProperty("success", true)
|
||
const newCard = gm.getBingoCard("p1")!
|
||
expect(newCard.hasBingo).toBe(false)
|
||
expect(newCard.squares.every((s) => !s.tapped)).toBe(true)
|
||
expect(newCard.squares).toHaveLength(16)
|
||
})
|
||
})
|
||
|
||
describe("getBingoScore — accumulation across cards", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should score tapped squares on active card", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
gm.tapBingoSquare("p1", card.squares[0]!.tropeId)
|
||
gm.tapBingoSquare("p1", card.squares[1]!.tropeId)
|
||
// 2 tapped squares * 2 points = 4
|
||
expect(gm.getBingoScore("p1")).toBe(4)
|
||
})
|
||
|
||
it("should include bingo bonus on completed card", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Complete first row (4 squares)
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
// 4 tapped * 2 = 8, plus 10 bonus = 18
|
||
expect(gm.getBingoScore("p1")).toBe(18)
|
||
})
|
||
|
||
it("should accumulate scores across completed + new card", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
// Complete first row (4 squares) — triggers completion
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
// Request new card
|
||
gm.requestNewBingoCard("p1", "Player 1")
|
||
const newCard = gm.getBingoCard("p1")!
|
||
// Tap 2 squares on new card
|
||
gm.tapBingoSquare("p1", newCard.squares[0]!.tropeId)
|
||
gm.tapBingoSquare("p1", newCard.squares[1]!.tropeId)
|
||
// Old card: 4 tapped * 2 = 8 + 10 bonus = 18
|
||
// New card: 2 tapped * 2 = 4
|
||
// Total: 22
|
||
expect(gm.getBingoScore("p1")).toBe(22)
|
||
})
|
||
|
||
it("should return 0 for unknown player", () => {
|
||
expect(gm.getBingoScore("unknown")).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe("addBingoAnnouncement — multiple per player", () => {
|
||
beforeEach(() => {
|
||
gm.generateBingoCards(["p1"])
|
||
})
|
||
|
||
it("should announce first bingo", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||
expect(isNew).toBe(true)
|
||
})
|
||
|
||
it("should not re-announce same bingo", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
gm.addBingoAnnouncement("p1", "Player 1")
|
||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||
expect(isNew).toBe(false)
|
||
})
|
||
|
||
it("should announce second bingo after redraw", () => {
|
||
const card = gm.getBingoCard("p1")!
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", card.squares[i]!.tropeId)
|
||
}
|
||
gm.addBingoAnnouncement("p1", "Player 1")
|
||
// Redraw
|
||
gm.requestNewBingoCard("p1", "Player 1")
|
||
const newCard = gm.getBingoCard("p1")!
|
||
// Complete first row of new card
|
||
for (let i = 0; i < 4; i++) {
|
||
gm.tapBingoSquare("p1", newCard.squares[i]!.tropeId)
|
||
}
|
||
const isNew = gm.addBingoAnnouncement("p1", "Player 1")
|
||
expect(isNew).toBe(true)
|
||
expect(gm.getBingoAnnouncements()).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
describe("game state includes completedBingoCards", () => {
|
||
it("should include completedBingoCards in player game state", () => {
|
||
gm.generateBingoCards(["p1"])
|
||
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Player 1" })
|
||
expect(state.completedBingoCards).toEqual([])
|
||
})
|
||
|
||
it("should include completedBingoCards in display game state", () => {
|
||
gm.generateBingoCards(["p1"])
|
||
const state = gm.getGameStateForDisplay(["p1"], { p1: "Player 1" })
|
||
expect(state.completedBingoCards).toEqual([])
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
Run: `bun test --filter packages/server`
|
||
|
||
Expected: all tests pass (existing 93 + new bingo tests).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/server/src/games/__tests__/game-manager-bingo.test.ts
|
||
git commit -m "add bingo completion tests: tap-only, detection, scoring, redraw, announcements"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Server — WS Handler Updates
|
||
|
||
**Files:**
|
||
- Modify: `packages/server/src/ws/handler.ts`
|
||
|
||
**Context:** The `tap_bingo_square` case is at line 423. The handler needs to pass `displayName` to `tapBingoSquare()` and handle the new `isNewBingo` field. A new `request_new_bingo_card` case is needed.
|
||
|
||
- [ ] **Step 1: Update `tap_bingo_square` case**
|
||
|
||
Replace the `tap_bingo_square` case (lines 423-460) with:
|
||
|
||
```typescript
|
||
case "tap_bingo_square": {
|
||
if (!sessionId) {
|
||
sendError(ws, "Not joined")
|
||
return
|
||
}
|
||
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
|
||
sendError(ws, "Bingo is only available during Live Event")
|
||
return
|
||
}
|
||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||
const gm = roomManager.getGameManager(roomCode)
|
||
if (!playerId || !gm) {
|
||
sendError(ws, "Room not found")
|
||
return
|
||
}
|
||
const result = gm.tapBingoSquare(playerId, msg.tropeId)
|
||
if ("error" in result) {
|
||
sendError(ws, result.error)
|
||
return
|
||
}
|
||
sendGameState(ws, roomCode, sessionId)
|
||
if (result.isNewBingo) {
|
||
const room = roomManager.getRoom(roomCode)
|
||
const player = room?.players.find((p) => p.sessionId === sessionId)
|
||
const displayName = player?.displayName ?? "Unknown"
|
||
const isNew = gm.addBingoAnnouncement(playerId, displayName)
|
||
if (isNew) {
|
||
broadcast(roomCode, {
|
||
type: "bingo_announced",
|
||
playerId,
|
||
displayName,
|
||
})
|
||
broadcastGameStateToAll(roomCode)
|
||
}
|
||
}
|
||
break
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `request_new_bingo_card` case**
|
||
|
||
After the `tap_bingo_square` case, add:
|
||
|
||
```typescript
|
||
case "request_new_bingo_card": {
|
||
if (!sessionId) {
|
||
sendError(ws, "Not joined")
|
||
return
|
||
}
|
||
if (roomManager.getRoom(roomCode)?.currentAct !== "live-event") {
|
||
sendError(ws, "New bingo cards are only available during Live Event")
|
||
return
|
||
}
|
||
const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId)
|
||
const gm = roomManager.getGameManager(roomCode)
|
||
if (!playerId || !gm) {
|
||
sendError(ws, "Room not found")
|
||
return
|
||
}
|
||
const room = roomManager.getRoom(roomCode)
|
||
const player = room?.players.find((p) => p.sessionId === sessionId)
|
||
const displayName = player?.displayName ?? "Unknown"
|
||
const result = gm.requestNewBingoCard(playerId, displayName)
|
||
if ("error" in result) {
|
||
sendError(ws, result.error)
|
||
return
|
||
}
|
||
broadcastGameStateToAll(roomCode)
|
||
break
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Generate bingo card on player join**
|
||
|
||
In the `join_room` case (around line 163), after `sendGameState(ws, roomCode, result.sessionId)` (line 177), add bingo card generation:
|
||
|
||
```typescript
|
||
// Generate bingo card for new player immediately
|
||
const gm = roomManager.getGameManager(roomCode)
|
||
if (gm) {
|
||
const playerId = roomManager.getPlayerIdBySession(roomCode, result.sessionId)
|
||
if (playerId) gm.generateBingoCardForPlayer(playerId)
|
||
}
|
||
```
|
||
|
||
Also update the `generateBingoCards` call when entering live-event (line 230) to skip players who already have cards. The existing `generateBingoCards` method needs a guard added (done in Task 2, Step 8b).
|
||
|
||
- [ ] **Step 4: Verify all tests pass**
|
||
|
||
Run: `bun test --filter packages/server`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/server/src/ws/handler.ts
|
||
git commit -m "update tap_bingo_square for completion flow, add request_new_bingo_card handler, generate cards on join"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: Client-Side — Components
|
||
|
||
### Task 5: Client — RoomLayout Component (Sticky Header)
|
||
|
||
**Files:**
|
||
- Create: `packages/client/src/components/room-layout.tsx`
|
||
|
||
**Context:** Replaces `RoomHeader`. Shows "I❤️ESC" left, room code + connection dot right. Sticky, safe-area aware.
|
||
|
||
- [ ] **Step 1: Create `room-layout.tsx`**
|
||
|
||
```tsx
|
||
import { Outlet } from "@tanstack/react-router"
|
||
|
||
interface RoomLayoutProps {
|
||
roomCode: string
|
||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||
}
|
||
|
||
export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
|
||
return (
|
||
<div className="flex min-h-screen flex-col">
|
||
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
|
||
<span className="text-lg font-bold">I❤️ESC</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
|
||
<span
|
||
className={`h-2 w-2 rounded-full ${
|
||
connectionStatus === "connected"
|
||
? "bg-green-500"
|
||
: connectionStatus === "connecting"
|
||
? "bg-yellow-500"
|
||
: "bg-red-500"
|
||
}`}
|
||
title={connectionStatus}
|
||
/>
|
||
</div>
|
||
</header>
|
||
<main className="flex-1 pb-20">
|
||
<Outlet />
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/components/room-layout.tsx
|
||
git commit -m "add RoomLayout component: sticky header with safe-area support"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Client — BottomNav Component
|
||
|
||
**Files:**
|
||
- Create: `packages/client/src/components/bottom-nav.tsx`
|
||
|
||
**Context:** Fixed bottom tab bar. iOS-style SVG icons. Player: Game | Bingo | Leaderboard. Host: Game | Bingo | Board | Host. Each tab is a TanStack Router `<Link>`.
|
||
|
||
- [ ] **Step 1: Create `bottom-nav.tsx`**
|
||
|
||
```tsx
|
||
import { Link, useMatchRoute } from "@tanstack/react-router"
|
||
|
||
interface BottomNavProps {
|
||
basePath: "/play/$roomCode" | "/host/$roomCode"
|
||
roomCode: string
|
||
isHost: boolean
|
||
}
|
||
|
||
function GameIcon({ active }: { active: boolean }) {
|
||
return (
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<polygon points="10,8 16,12 10,16" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function BingoIcon({ active }: { active: boolean }) {
|
||
return (
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function TrophyIcon({ active }: { active: boolean }) {
|
||
return (
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||
<path d="M4 22h16" />
|
||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
|
||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
|
||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function WrenchIcon({ active }: { active: boolean }) {
|
||
return (
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
interface TabConfig {
|
||
to: string
|
||
label: string
|
||
icon: (props: { active: boolean }) => React.ReactNode
|
||
}
|
||
|
||
export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
|
||
const matchRoute = useMatchRoute()
|
||
|
||
const tabs: TabConfig[] = [
|
||
{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
|
||
{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
|
||
{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
|
||
]
|
||
|
||
if (isHost) {
|
||
tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
|
||
}
|
||
|
||
return (
|
||
<nav
|
||
className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
|
||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||
>
|
||
<div className="flex">
|
||
{tabs.map((tab) => {
|
||
const active = !!matchRoute({ to: tab.to, params: { roomCode } })
|
||
return (
|
||
<Link
|
||
key={tab.to}
|
||
to={tab.to}
|
||
params={{ roomCode }}
|
||
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
|
||
active
|
||
? "text-primary"
|
||
: "text-muted-foreground"
|
||
}`}
|
||
>
|
||
<tab.icon active={active} />
|
||
<span>{tab.label}</span>
|
||
</Link>
|
||
)
|
||
})}
|
||
</div>
|
||
</nav>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/components/bottom-nav.tsx
|
||
git commit -m "add BottomNav component: fixed bottom tab bar with iOS-style SVG icons"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Client — BingoCard Readonly Mode + Completion UI
|
||
|
||
**Files:**
|
||
- Modify: `packages/client/src/components/bingo-card.tsx`
|
||
|
||
**Context:** Current component (43 lines) always allows tapping. Add `readonly` prop to disable interaction. Add completion UI showing "Draw New Card" button.
|
||
|
||
- [ ] **Step 1: Update BingoCard component**
|
||
|
||
Replace the full file content:
|
||
|
||
```tsx
|
||
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
|
||
interface BingoCardProps {
|
||
card: BingoCardType
|
||
onTap: (tropeId: string) => void
|
||
readonly?: boolean
|
||
onRequestNewCard?: () => void
|
||
}
|
||
|
||
export function BingoCard({ card, onTap, readonly, onRequestNewCard }: BingoCardProps) {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle>Bingo</CardTitle>
|
||
{card.hasBingo && (
|
||
<Badge variant="default" className="bg-green-600">
|
||
BINGO!
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
<div className="grid grid-cols-4 gap-1.5">
|
||
{card.squares.map((square) => (
|
||
<button
|
||
key={square.tropeId}
|
||
type="button"
|
||
onClick={() => !readonly && onTap(square.tropeId)}
|
||
disabled={readonly}
|
||
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
|
||
square.tapped
|
||
? "border-primary bg-primary/20 font-medium text-primary"
|
||
: readonly
|
||
? "border-muted text-muted-foreground"
|
||
: "border-muted hover:bg-muted/50"
|
||
} ${readonly ? "cursor-default" : "cursor-pointer"}`}
|
||
>
|
||
{square.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{card.hasBingo && onRequestNewCard && (
|
||
<Button onClick={onRequestNewCard} variant="outline" className="w-full">
|
||
Draw New Card
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/components/bingo-card.tsx
|
||
git commit -m "add readonly mode, draw-new-card button to BingoCard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Client — Leaderboard Absorbs Player List
|
||
|
||
**Files:**
|
||
- Modify: `packages/client/src/components/leaderboard.tsx`
|
||
|
||
**Context:** Current component (49 lines) shows leaderboard table with P/J/B/Q columns. In lobby, should show simplified "who's here" view. Absorbs `PlayerList` role.
|
||
|
||
- [ ] **Step 1: Update Leaderboard component**
|
||
|
||
Replace the full file content:
|
||
|
||
```tsx
|
||
import type { LeaderboardEntry } from "@celebrate-esc/shared"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
|
||
interface LeaderboardProps {
|
||
entries: LeaderboardEntry[]
|
||
resultsEntered?: boolean
|
||
lobbyMode?: boolean
|
||
}
|
||
|
||
export function Leaderboard({ entries, resultsEntered, lobbyMode }: LeaderboardProps) {
|
||
if (entries.length === 0) return null
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-1">
|
||
{entries.map((entry, i) => (
|
||
<div key={entry.playerId} className="flex items-center justify-between border-b py-1.5 last:border-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
|
||
{i + 1}
|
||
</span>
|
||
<span className="text-sm font-medium">{entry.displayName}</span>
|
||
</div>
|
||
{!lobbyMode && (
|
||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
|
||
<span title="Jury points">J:{entry.juryPoints}</span>
|
||
<span title="Bingo points">B:{entry.bingoPoints}</span>
|
||
<span title="Quiz points">Q:{entry.quizPoints}</span>
|
||
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!lobbyMode && (
|
||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||
<p className="mb-1 font-medium">How scoring works</p>
|
||
<ul className="flex flex-col gap-0.5">
|
||
<li><strong>P</strong> = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
|
||
<li><strong>J</strong> = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
|
||
<li><strong>B</strong> = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line</li>
|
||
<li><strong>Q</strong> = Quiz points — 5 easy, 10 medium, 15 hard</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/components/leaderboard.tsx
|
||
git commit -m "add lobbyMode to Leaderboard, absorb PlayerList role"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Client — Shared Tab Components
|
||
|
||
**Files:**
|
||
- Create: `packages/client/src/components/game-tab.tsx`
|
||
- Create: `packages/client/src/components/bingo-tab.tsx`
|
||
- Create: `packages/client/src/components/board-tab.tsx`
|
||
- Create: `packages/client/src/components/host-tab.tsx`
|
||
- Create: `packages/client/src/components/bingo-claims.tsx`
|
||
|
||
**Context:** These components extract tab content from the current route files. They consume state from `useRoomStore()` and accept a `send` function prop. Shared between host and player where applicable.
|
||
|
||
- [ ] **Step 1: Create `game-tab.tsx`**
|
||
|
||
```tsx
|
||
import type { ClientMessage, GameState } from "@celebrate-esc/shared"
|
||
import type { Act } from "@celebrate-esc/shared"
|
||
import { PredictionsForm } from "@/components/predictions-form"
|
||
import { JuryVoting } from "@/components/jury-voting"
|
||
import { QuizBuzzer } from "@/components/quiz-buzzer"
|
||
|
||
interface GameTabProps {
|
||
currentAct: Act
|
||
gameState: GameState
|
||
send: (message: ClientMessage) => void
|
||
}
|
||
|
||
export function GameTab({ currentAct, gameState, send }: GameTabProps) {
|
||
if (currentAct === "lobby" || currentAct === "pre-show") {
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<PredictionsForm
|
||
entries={gameState.lineup.entries}
|
||
existingPrediction={gameState.myPrediction}
|
||
locked={gameState.predictionsLocked}
|
||
onSubmit={(prediction) => send({ type: "submit_prediction", ...prediction })}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (currentAct === "live-event") {
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
{gameState.currentJuryRound ? (
|
||
<JuryVoting
|
||
round={gameState.currentJuryRound}
|
||
myVote={gameState.myJuryVote}
|
||
onVote={(rating) => send({ type: "submit_jury_vote", rating })}
|
||
/>
|
||
) : (
|
||
<div className="py-8 text-center text-muted-foreground">
|
||
Waiting for host to open voting...
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (currentAct === "scoring") {
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
{gameState.myPrediction && (
|
||
<PredictionsForm
|
||
entries={gameState.lineup.entries}
|
||
existingPrediction={gameState.myPrediction}
|
||
locked={true}
|
||
actualResults={gameState.actualResults}
|
||
onSubmit={() => {}}
|
||
/>
|
||
)}
|
||
{gameState.currentQuizQuestion && (
|
||
<QuizBuzzer
|
||
question={gameState.currentQuizQuestion}
|
||
buzzStatus={gameState.myQuizBuzzStatus}
|
||
onBuzz={() => send({ type: "buzz" })}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (currentAct === "ended") {
|
||
return (
|
||
<div className="flex flex-col items-center gap-4 p-4 py-8">
|
||
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
|
||
{gameState.myPrediction && (
|
||
<PredictionsForm
|
||
entries={gameState.lineup.entries}
|
||
existingPrediction={gameState.myPrediction}
|
||
locked={true}
|
||
actualResults={gameState.actualResults}
|
||
onSubmit={() => {}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `bingo-tab.tsx`**
|
||
|
||
```tsx
|
||
import type { ClientMessage, GameState } from "@celebrate-esc/shared"
|
||
import type { Act } from "@celebrate-esc/shared"
|
||
import { BingoCard } from "@/components/bingo-card"
|
||
|
||
interface BingoTabProps {
|
||
currentAct: Act
|
||
gameState: GameState
|
||
send: (message: ClientMessage) => void
|
||
}
|
||
|
||
export function BingoTab({ currentAct, gameState, send }: BingoTabProps) {
|
||
const isLiveEvent = currentAct === "live-event"
|
||
|
||
if (!gameState.myBingoCard) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-4 p-4 py-8">
|
||
<p className="text-muted-foreground">No bingo card yet. Cards are dealt when the Live Event begins.</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<BingoCard
|
||
card={gameState.myBingoCard}
|
||
onTap={(tropeId) => send({ type: "tap_bingo_square", tropeId })}
|
||
readonly={!isLiveEvent}
|
||
onRequestNewCard={isLiveEvent ? () => send({ type: "request_new_bingo_card" }) : undefined}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `board-tab.tsx`**
|
||
|
||
```tsx
|
||
import type { GameState } from "@celebrate-esc/shared"
|
||
import type { Act } from "@celebrate-esc/shared"
|
||
import { Leaderboard } from "@/components/leaderboard"
|
||
|
||
interface BoardTabProps {
|
||
currentAct: Act
|
||
gameState: GameState
|
||
}
|
||
|
||
export function BoardTab({ currentAct, gameState }: BoardTabProps) {
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<Leaderboard
|
||
entries={gameState.leaderboard}
|
||
resultsEntered={!!gameState.actualResults}
|
||
lobbyMode={currentAct === "lobby"}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `bingo-claims.tsx`**
|
||
|
||
```tsx
|
||
import type { CompletedBingoCard } from "@celebrate-esc/shared"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
|
||
interface BingoClaimsProps {
|
||
completedCards: CompletedBingoCard[]
|
||
}
|
||
|
||
export function BingoClaims({ completedCards }: BingoClaimsProps) {
|
||
if (completedCards.length === 0) return null
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Bingo Claims ({completedCards.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
{completedCards.map((claim, i) => (
|
||
<div key={`${claim.playerId}-${i}`} className="flex flex-col gap-1.5 border-b pb-3 last:border-0 last:pb-0">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{claim.displayName}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{new Date(claim.completedAt).toLocaleTimeString()}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-0.5">
|
||
{claim.card.squares.map((square) => (
|
||
<div
|
||
key={square.tropeId}
|
||
className={`rounded px-1 py-0.5 text-center text-xs ${
|
||
square.tapped
|
||
? "bg-primary/20 text-primary"
|
||
: "bg-muted/50 text-muted-foreground"
|
||
}`}
|
||
>
|
||
{square.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `host-tab.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from "react"
|
||
import type { ClientMessage, GameState, Act } from "@celebrate-esc/shared"
|
||
import { JuryHost } from "@/components/jury-host"
|
||
import { QuizHost } from "@/components/quiz-host"
|
||
import { ActualResultsForm } from "@/components/actual-results-form"
|
||
import { BingoClaims } from "@/components/bingo-claims"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
|
||
const nextActLabels: Partial<Record<Act, string>> = {
|
||
lobby: "Start Pre-Show",
|
||
"pre-show": "Start Live Event",
|
||
"live-event": "Start Scoring",
|
||
}
|
||
|
||
const prevActLabels: Partial<Record<Act, string>> = {
|
||
"pre-show": "Back to Lobby",
|
||
"live-event": "Back to Pre-Show",
|
||
scoring: "Back to Live Event",
|
||
ended: "Back to Scoring",
|
||
}
|
||
|
||
interface HostTabProps {
|
||
roomCode: string
|
||
currentAct: Act
|
||
gameState: GameState
|
||
send: (message: ClientMessage) => void
|
||
}
|
||
|
||
export function HostTab({ roomCode, currentAct, gameState, send }: HostTabProps) {
|
||
const [copied, setCopied] = useState(false)
|
||
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
|
||
const displayUrl = `${window.location.origin}${base}/display/${roomCode}`
|
||
|
||
function copyDisplayUrl() {
|
||
navigator.clipboard.writeText(displayUrl).then(() => {
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
{/* Act Controls */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Room Controls</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-3">
|
||
{currentAct !== "ended" && (
|
||
<div className="flex gap-2">
|
||
{currentAct !== "lobby" && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => send({ type: "revert_act" })}
|
||
className="flex-1"
|
||
>
|
||
{prevActLabels[currentAct] ?? "Back"}
|
||
</Button>
|
||
)}
|
||
{nextActLabels[currentAct] && (
|
||
<Button onClick={() => send({ type: "advance_act" })} className="flex-1">
|
||
{nextActLabels[currentAct]}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{currentAct === "ended" && (
|
||
<Button variant="outline" onClick={() => send({ type: "revert_act" })}>
|
||
{prevActLabels[currentAct] ?? "Back"}
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Display View Link */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Display View</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-2">
|
||
<p className="text-sm text-muted-foreground">
|
||
Project this on a TV for everyone to see.
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs">{displayUrl}</code>
|
||
<Button variant="outline" size="sm" onClick={copyDisplayUrl}>
|
||
{copied ? "Copied!" : "Copy"}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Jury Host (live-event) */}
|
||
{currentAct === "live-event" && (
|
||
<JuryHost
|
||
entries={gameState.lineup.entries}
|
||
currentRound={gameState.currentJuryRound}
|
||
results={gameState.juryResults}
|
||
onOpenVote={(countryCode) => send({ type: "open_jury_vote", countryCode })}
|
||
onCloseVote={() => send({ type: "close_jury_vote" })}
|
||
/>
|
||
)}
|
||
|
||
{/* Quiz Host (scoring) */}
|
||
{currentAct === "scoring" && (
|
||
<QuizHost
|
||
question={gameState.currentQuizQuestion}
|
||
onStartQuestion={() => send({ type: "start_quiz_question" })}
|
||
onJudge={(correct) => send({ type: "judge_quiz_answer", correct })}
|
||
onSkip={() => send({ type: "skip_quiz_question" })}
|
||
/>
|
||
)}
|
||
|
||
{/* Actual Results Form (scoring/ended) */}
|
||
{(currentAct === "scoring" || currentAct === "ended") && (
|
||
<ActualResultsForm
|
||
entries={gameState.lineup.entries}
|
||
existingResults={gameState.actualResults}
|
||
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
|
||
/>
|
||
)}
|
||
|
||
{/* Bingo Claims */}
|
||
{gameState.completedBingoCards.length > 0 && (
|
||
<BingoClaims completedCards={gameState.completedBingoCards} />
|
||
)}
|
||
|
||
{/* End Party (destructive) */}
|
||
{currentAct !== "ended" && (
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => send({ type: "end_room" })}
|
||
className="w-full"
|
||
>
|
||
End Party
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/components/game-tab.tsx packages/client/src/components/bingo-tab.tsx packages/client/src/components/board-tab.tsx packages/client/src/components/host-tab.tsx packages/client/src/components/bingo-claims.tsx
|
||
git commit -m "add shared tab components: GameTab, BingoTab, BoardTab, HostTab, BingoClaims"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Client — WebSocket Send Sharing via Zustand
|
||
|
||
**Files:**
|
||
- Modify: `packages/client/src/stores/room-store.ts`
|
||
- Modify: `packages/client/src/hooks/use-websocket.ts`
|
||
|
||
**Context:** CRITICAL: `useWebSocket` creates a new WebSocket connection on every call. If child routes call it, they create duplicate connections. When a child route unmounts (tab switch), its cleanup calls `reset()`, wiping the Zustand store. Solution: layout route is the ONLY component that calls `useWebSocket`. It stores `send` in Zustand. Child routes read `send` from the store.
|
||
|
||
- [ ] **Step 1: Add `send` to room store**
|
||
|
||
In `packages/client/src/stores/room-store.ts`, add `send` to the interface and initial state:
|
||
|
||
```typescript
|
||
import { create } from "zustand"
|
||
import type { RoomState, Player, GameState, ClientMessage } from "@celebrate-esc/shared"
|
||
|
||
interface RoomStore {
|
||
room: RoomState | null
|
||
mySessionId: string | null
|
||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||
gameState: GameState | null
|
||
send: (message: ClientMessage) => void
|
||
|
||
setRoom: (room: RoomState) => void
|
||
setMySessionId: (sessionId: string) => void
|
||
setConnectionStatus: (status: "disconnected" | "connecting" | "connected") => void
|
||
updatePlayerConnected: (playerId: string, connected: boolean) => void
|
||
addPlayer: (player: Player) => void
|
||
setAct: (act: RoomState["currentAct"]) => void
|
||
setGameState: (gameState: GameState) => void
|
||
lockPredictions: () => void
|
||
setSend: (send: (message: ClientMessage) => void) => void
|
||
reset: () => void
|
||
}
|
||
|
||
const noop = () => {}
|
||
|
||
export const useRoomStore = create<RoomStore>((set) => ({
|
||
room: null,
|
||
mySessionId: null,
|
||
connectionStatus: "disconnected",
|
||
gameState: null,
|
||
send: noop,
|
||
|
||
setRoom: (room) => set({ room }),
|
||
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
|
||
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||
|
||
updatePlayerConnected: (playerId, connected) =>
|
||
set((state) => {
|
||
if (!state.room) return state
|
||
return {
|
||
room: {
|
||
...state.room,
|
||
players: state.room.players.map((p) => (p.id === playerId ? { ...p, connected } : p)),
|
||
},
|
||
}
|
||
}),
|
||
|
||
addPlayer: (player) =>
|
||
set((state) => {
|
||
if (!state.room) return state
|
||
if (state.room.players.some((p) => p.id === player.id)) return state
|
||
return {
|
||
room: {
|
||
...state.room,
|
||
players: [...state.room.players, player],
|
||
},
|
||
}
|
||
}),
|
||
|
||
setAct: (act) =>
|
||
set((state) => {
|
||
if (!state.room) return state
|
||
return { room: { ...state.room, currentAct: act } }
|
||
}),
|
||
|
||
setGameState: (gameState) => set({ gameState }),
|
||
|
||
lockPredictions: () =>
|
||
set((state) => {
|
||
if (!state.gameState) return state
|
||
return {
|
||
gameState: { ...state.gameState, predictionsLocked: true },
|
||
}
|
||
}),
|
||
|
||
setSend: (send) => set({ send }),
|
||
|
||
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null, send: noop }),
|
||
}))
|
||
```
|
||
|
||
- [ ] **Step 2: Update `useWebSocket` to store `send` in Zustand**
|
||
|
||
In `packages/client/src/hooks/use-websocket.ts`, add `setSend` to the destructured store and call `setSend(send)` after creating the `send` callback:
|
||
|
||
After `const send = useCallback(...)`, add:
|
||
|
||
```typescript
|
||
const { setSend, ...rest } = useRoomStore()
|
||
|
||
useEffect(() => {
|
||
setSend(send)
|
||
}, [send, setSend])
|
||
```
|
||
|
||
The hook still returns `{ send }` for backward compatibility with the layout route's join flow, but child routes should use `useRoomStore().send` instead.
|
||
|
||
- [ ] **Step 3: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/stores/room-store.ts packages/client/src/hooks/use-websocket.ts
|
||
git commit -m "share WebSocket send via Zustand store, prevent duplicate connections"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 3: Client-Side — Route Restructure
|
||
|
||
### Task 11: Client — Play Route Restructure
|
||
|
||
**Files:**
|
||
- Modify: `packages/client/src/routes/play.$roomCode.tsx` — convert to layout route
|
||
- Create: `packages/client/src/routes/play.$roomCode.index.tsx` — redirect
|
||
- Create: `packages/client/src/routes/play.$roomCode.game.tsx` — game child
|
||
- Create: `packages/client/src/routes/play.$roomCode.bingo.tsx` — bingo child
|
||
- Create: `packages/client/src/routes/play.$roomCode.board.tsx` — leaderboard child
|
||
|
||
**Context:** The current `play.$roomCode.tsx` (176 lines) contains everything: WebSocket setup, join flow, game content. Convert it to a layout route that handles WS + join flow and renders `RoomLayout` with `<Outlet />`. Child routes render tab content.
|
||
|
||
- [ ] **Step 1: Replace `play.$roomCode.tsx` with layout route**
|
||
|
||
Replace the entire file:
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||
import { useWebSocket } from "@/hooks/use-websocket"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { RoomLayout } from "@/components/room-layout"
|
||
import { BottomNav } from "@/components/bottom-nav"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
|
||
export const Route = createFileRoute("/play/$roomCode")({
|
||
component: PlayLayout,
|
||
})
|
||
|
||
function PlayLayout() {
|
||
const { roomCode } = Route.useParams()
|
||
const { send } = useWebSocket(roomCode)
|
||
const { room, mySessionId, connectionStatus } = useRoomStore()
|
||
const joinSentRef = useRef(false)
|
||
const [manualName, setManualName] = useState("")
|
||
|
||
useEffect(() => {
|
||
if (connectionStatus !== "connected" || mySessionId || joinSentRef.current) return
|
||
|
||
const displayName = sessionStorage.getItem("esc-party-join-name")
|
||
if (displayName) {
|
||
joinSentRef.current = true
|
||
sessionStorage.removeItem("esc-party-join-name")
|
||
send({ type: "join_room", displayName })
|
||
}
|
||
}, [connectionStatus, mySessionId, send])
|
||
|
||
if (!room) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center">
|
||
<p className="text-muted-foreground">
|
||
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
|
||
return (
|
||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||
<h2 className="text-xl font-bold">Join Room {roomCode}</h2>
|
||
<Input
|
||
placeholder="Your name"
|
||
value={manualName}
|
||
onChange={(e) => setManualName(e.target.value)}
|
||
maxLength={20}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && manualName.trim()) {
|
||
joinSentRef.current = true
|
||
send({ type: "join_room", displayName: manualName.trim() })
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
onClick={() => {
|
||
if (manualName.trim()) {
|
||
joinSentRef.current = true
|
||
send({ type: "join_room", displayName: manualName.trim() })
|
||
}
|
||
}}
|
||
disabled={!manualName.trim()}
|
||
>
|
||
Join
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!mySessionId) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center">
|
||
<p className="text-muted-foreground">Joining room...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
|
||
<BottomNav basePath="/play/$roomCode" roomCode={roomCode} isHost={false} />
|
||
</>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `play.$roomCode.index.tsx` (redirect)**
|
||
|
||
```tsx
|
||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||
|
||
export const Route = createFileRoute("/play/$roomCode/")({
|
||
beforeLoad: ({ params }) => {
|
||
throw redirect({ to: "/play/$roomCode/game", params })
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Create `play.$roomCode.game.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { GameTab } from "@/components/game-tab"
|
||
|
||
export const Route = createFileRoute("/play/$roomCode/game")({
|
||
component: PlayGame,
|
||
})
|
||
|
||
function PlayGame() {
|
||
const { room, gameState, send } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `play.$roomCode.bingo.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { BingoTab } from "@/components/bingo-tab"
|
||
|
||
export const Route = createFileRoute("/play/$roomCode/bingo")({
|
||
component: PlayBingo,
|
||
})
|
||
|
||
function PlayBingo() {
|
||
const { room, gameState, send } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `play.$roomCode.board.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { BoardTab } from "@/components/board-tab"
|
||
|
||
export const Route = createFileRoute("/play/$roomCode/board")({
|
||
component: PlayBoard,
|
||
})
|
||
|
||
function PlayBoard() {
|
||
const { room, gameState } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/routes/play.\$roomCode.tsx packages/client/src/routes/play.\$roomCode.index.tsx packages/client/src/routes/play.\$roomCode.game.tsx packages/client/src/routes/play.\$roomCode.bingo.tsx packages/client/src/routes/play.\$roomCode.board.tsx
|
||
git commit -m "restructure play routes: layout with nested game, bingo, board children"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Client — Host Route Restructure
|
||
|
||
**Files:**
|
||
- Modify: `packages/client/src/routes/host.$roomCode.tsx` — convert to layout route
|
||
- Create: `packages/client/src/routes/host.$roomCode.index.tsx` — redirect
|
||
- Create: `packages/client/src/routes/host.$roomCode.game.tsx` — game child
|
||
- Create: `packages/client/src/routes/host.$roomCode.bingo.tsx` — bingo child
|
||
- Create: `packages/client/src/routes/host.$roomCode.board.tsx` — leaderboard child
|
||
- Create: `packages/client/src/routes/host.$roomCode.host.tsx` — host controls child
|
||
|
||
**Context:** The host is a player with an extra "Host" tab. Game, Bingo, and Board tabs are identical to player tabs. The WebSocket connection is established in the layout route.
|
||
|
||
- [ ] **Step 1: Replace `host.$roomCode.tsx` with layout route**
|
||
|
||
Replace the entire file:
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useWebSocket } from "@/hooks/use-websocket"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { RoomLayout } from "@/components/room-layout"
|
||
import { BottomNav } from "@/components/bottom-nav"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode")({
|
||
component: HostLayout,
|
||
})
|
||
|
||
function HostLayout() {
|
||
const { roomCode } = Route.useParams()
|
||
useWebSocket(roomCode)
|
||
const { room, connectionStatus } = useRoomStore()
|
||
|
||
if (!room) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center">
|
||
<p className="text-muted-foreground">
|
||
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<RoomLayout roomCode={roomCode} connectionStatus={connectionStatus} />
|
||
<BottomNav basePath="/host/$roomCode" roomCode={roomCode} isHost={true} />
|
||
</>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `host.$roomCode.index.tsx` (redirect)**
|
||
|
||
```tsx
|
||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode/")({
|
||
beforeLoad: ({ params }) => {
|
||
throw redirect({ to: "/host/$roomCode/game", params })
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Create `host.$roomCode.game.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { GameTab } from "@/components/game-tab"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode/game")({
|
||
component: HostGame,
|
||
})
|
||
|
||
function HostGame() {
|
||
const { room, gameState, send } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <GameTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `host.$roomCode.bingo.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { BingoTab } from "@/components/bingo-tab"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode/bingo")({
|
||
component: HostBingo,
|
||
})
|
||
|
||
function HostBingo() {
|
||
const { room, gameState, send } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <BingoTab currentAct={room.currentAct} gameState={gameState} send={send} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `host.$roomCode.board.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { BoardTab } from "@/components/board-tab"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode/board")({
|
||
component: HostBoard,
|
||
})
|
||
|
||
function HostBoard() {
|
||
const { room, gameState } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <BoardTab currentAct={room.currentAct} gameState={gameState} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create `host.$roomCode.host.tsx`**
|
||
|
||
```tsx
|
||
import { createFileRoute } from "@tanstack/react-router"
|
||
import { useRoomStore } from "@/stores/room-store"
|
||
import { HostTab } from "@/components/host-tab"
|
||
|
||
export const Route = createFileRoute("/host/$roomCode/host")({
|
||
component: HostControls,
|
||
})
|
||
|
||
function HostControls() {
|
||
const { roomCode } = Route.useParams()
|
||
const { room, gameState, send } = useRoomStore()
|
||
|
||
if (!room || !gameState) return null
|
||
|
||
return <HostTab roomCode={roomCode} currentAct={room.currentAct} gameState={gameState} send={send} />
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify client builds**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add packages/client/src/routes/host.\$roomCode.tsx packages/client/src/routes/host.\$roomCode.index.tsx packages/client/src/routes/host.\$roomCode.game.tsx packages/client/src/routes/host.\$roomCode.bingo.tsx packages/client/src/routes/host.\$roomCode.board.tsx packages/client/src/routes/host.\$roomCode.host.tsx
|
||
git commit -m "restructure host routes: layout with nested game, bingo, board, host children"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: End-to-End Verification
|
||
|
||
**Files:** None (verification only)
|
||
|
||
- [ ] **Step 1: Run all server tests**
|
||
|
||
Run: `bun test --filter packages/server`
|
||
|
||
Expected: all tests pass (93 existing + new bingo tests).
|
||
|
||
- [ ] **Step 2: Run full client build**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client build`
|
||
|
||
Expected: clean build, no errors.
|
||
|
||
- [ ] **Step 3: Verify route tree generation**
|
||
|
||
Check that `packages/client/src/routeTree.gen.ts` was regenerated by TanStack Router plugin with the new nested routes. It should include entries for all new child routes.
|
||
|
||
Run: `grep -c "Route" packages/client/src/routeTree.gen.ts`
|
||
|
||
Expected: significantly more route entries than before.
|
||
|
||
- [ ] **Step 4: Start dev server and verify manually**
|
||
|
||
Run: `bun run --filter @celebrate-esc/client dev` (in one terminal)
|
||
Run: `bun run --filter @celebrate-esc/server dev` (in another terminal)
|
||
|
||
Verify:
|
||
1. Navigate to `/` — landing page unchanged
|
||
2. Create a room — redirected to `/host/XXXX/game`
|
||
3. Bottom nav visible with 4 tabs (Game, Bingo, Board, Host)
|
||
4. Sticky header shows "I❤️ESC" + room code + green dot
|
||
5. Tap each tab — content switches, URL updates
|
||
6. Browser back/forward works between tabs
|
||
7. Open `/play/XXXX` in another tab — redirected to `/play/XXXX/game`
|
||
8. Bottom nav has 3 tabs (Game, Bingo, Leaderboard)
|
||
9. Bingo tab shows "No bingo card yet" in lobby
|
||
10. Display view (`/display/XXXX`) unchanged — no bottom nav
|