diff --git a/docs/superpowers/plans/2026-03-12-issue1-fixes.md b/docs/superpowers/plans/2026-03-12-issue1-fixes.md new file mode 100644 index 0000000..f8ecbf2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-issue1-fixes.md @@ -0,0 +1,2047 @@ +# Issue #1 Fixes — 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:** Rework predictions to use full ESC entries with tap-to-assign UI, remove Dish of the Nation, rename acts, add player submission checkmarks, and make lobby code copyable. + +**Architecture:** All changes flow top-down: shared types first (entry model, prediction model, acts, remove dishes), then server (GameManager, DB schema, WS handler), then client (store, components, routes). No new dependencies needed. + +**Tech Stack:** Zod, Drizzle ORM, PostgreSQL, React, Zustand, shadcn/ui, TanStack Router + +**Spec:** `docs/superpowers/specs/2026-03-12-issue1-fixes-design.md` + +--- + +## File Structure + +### Modified +- `packages/shared/src/constants.ts` — act names +- `packages/shared/src/game-types.ts` — entry/lineup schemas, prediction schema, gameState schema +- `packages/shared/src/ws-messages.ts` — remove dish messages, update prediction message +- `packages/server/src/games/game-manager.ts` — remove dish logic, update predictions, add predictionSubmitted +- `packages/server/src/games/game-service.ts` — remove dish persistence, update prediction columns +- `packages/server/src/db/schema.ts` — remove dish tables, update prediction columns, update actEnum +- `packages/server/src/rooms/room-manager.ts` — act name reference in advanceAct +- `packages/server/src/ws/handler.ts` — remove dish handlers, update prediction handler, update act lock +- `packages/server/tests/game-manager.test.ts` — rewrite for new model +- `packages/server/tests/ws-handler.test.ts` — update for changed messages +- `packages/client/src/stores/room-store.ts` — remove dish state, simplify +- `packages/client/src/hooks/use-websocket.ts` — remove dish message handlers +- `packages/client/src/components/predictions-form.tsx` — rewrite as tap-to-assign +- `packages/client/src/components/player-list.tsx` — add prediction checkmark +- `packages/client/src/components/room-header.tsx` — update act labels +- `packages/client/src/routes/play.$roomCode.tsx` — remove dish UI, update act refs +- `packages/client/src/routes/host.$roomCode.tsx` — remove dish UI, update act labels +- `packages/client/src/routes/display.$roomCode.tsx` — remove dish UI, add copy-to-clipboard + +### Deleted +- `packages/client/src/components/dish-list.tsx` +- `packages/client/src/components/dish-host.tsx` +- `packages/client/src/components/dish-results.tsx` +- `packages/server/data/esc-2026.json` + +### Created +- `packages/server/data/esc-2025.json` — full ESC 2025 entry data with flag, artist, song + +--- + +## Chunk 1: Shared Types + Data + +### Task 1: Update shared constants (acts) + +**Files:** +- Modify: `packages/shared/src/constants.ts` + +- [ ] **Step 1: Update ACTS array and remove unused constants** + +Replace the entire file: + +```ts +export const MAX_PLAYERS = 10 +export const ROOM_CODE_LENGTH = 4 +export const ROOM_EXPIRY_HOURS = 12 + +/** Characters used for room codes — excludes I/O/0/1 to avoid confusion */ +export const ROOM_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +export const ACTS = ["lobby", "pre-show", "live-event", "scoring", "ended"] as const +export type Act = (typeof ACTS)[number] + +export const ACT_LABELS: Record = { + lobby: "Lobby", + "pre-show": "Pre-Show", + "live-event": "Live Event", + scoring: "Scoring", + ended: "Ended", +} +``` + +Remove `JURY_RATING_MIN`, `JURY_RATING_MAX`, `BINGO_GRID_SIZE`, `BINGO_TOTAL_SQUARES` — they are unused and belong to unimplemented plans. + +- [ ] **Step 2: Verify the shared package builds** + +Run: `cd packages/shared && bun run build 2>&1 || true` + +This will have errors because downstream files still reference old types — that's expected. Verify the constants file itself has no syntax errors by checking the output. + +- [ ] **Step 3: Commit** + +```bash +git add packages/shared/src/constants.ts +git commit -m "update acts to pre-show/live-event/scoring, add ACT_LABELS, remove unused constants" +``` + +### Task 2: Update shared game types + +**Files:** +- Modify: `packages/shared/src/game-types.ts` + +- [ ] **Step 1: Rewrite game-types.ts with new entry/prediction model and no dishes** + +Replace the entire file: + +```ts +import { z } from "zod" + +// ─── Entry Lineup ─────────────────────────────────────────────────── + +export const countrySchema = z.object({ + code: z.string(), + name: z.string(), + flag: z.string(), +}) + +export type Country = z.infer + +export const entrySchema = z.object({ + country: countrySchema, + artist: z.string(), + song: z.string(), +}) + +export type Entry = z.infer + +export const lineupSchema = z.object({ + year: z.number(), + entries: z.array(entrySchema), +}) + +export type Lineup = z.infer + +// ─── Predictions ──────────────────────────────────────────────────── + +export const predictionSchema = z.object({ + playerId: z.string().uuid(), + first: z.string(), + second: z.string(), + third: z.string(), + last: z.string(), +}) + +export type Prediction = z.infer + +// ─── Game State (sent to clients) ─────────────────────────────────── + +export const gameStateSchema = z.object({ + lineup: lineupSchema, + myPrediction: predictionSchema.nullable(), + predictionsLocked: z.boolean(), + predictionSubmitted: z.record(z.string(), z.boolean()), +}) + +export type GameState = z.infer +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared/src/game-types.ts +git commit -m "rewrite game types: entry model with flag/artist/song, ordered predictions, remove dishes" +``` + +### Task 3: Update shared WS messages + +**Files:** +- Modify: `packages/shared/src/ws-messages.ts` + +- [ ] **Step 1: Remove dish messages and update prediction message** + +Replace the entire file: + +```ts +import { z } from "zod" +import { ACTS } from "./constants" +import { gameStateSchema } from "./game-types" +import { playerSchema, roomStateSchema } from "./room-types" + +// ─── Client → Server ─────────────────────────────────────────────── + +export const joinRoomMessage = z.object({ + type: z.literal("join_room"), + displayName: z.string().min(1).max(20), +}) + +export const reconnectMessage = z.object({ + type: z.literal("reconnect"), + sessionId: z.string().uuid(), +}) + +export const advanceActMessage = z.object({ + type: z.literal("advance_act"), +}) + +export const endRoomMessage = z.object({ + type: z.literal("end_room"), +}) + +export const submitPredictionMessage = z.object({ + type: z.literal("submit_prediction"), + first: z.string(), + second: z.string(), + third: z.string(), + last: z.string(), +}) + +export const clientMessage = z.discriminatedUnion("type", [ + joinRoomMessage, + reconnectMessage, + advanceActMessage, + endRoomMessage, + submitPredictionMessage, +]) + +export type ClientMessage = z.infer + +// ─── Server → Client ─────────────────────────────────────────────── + +export const roomStateMessage = z.object({ + type: z.literal("room_state"), + room: roomStateSchema, + sessionId: z.string().uuid().optional(), +}) + +export const playerJoinedMessage = z.object({ + type: z.literal("player_joined"), + player: playerSchema, +}) + +export const playerDisconnectedMessage = z.object({ + type: z.literal("player_disconnected"), + playerId: z.string().uuid(), +}) + +export const playerReconnectedMessage = z.object({ + type: z.literal("player_reconnected"), + playerId: z.string().uuid(), +}) + +export const actChangedMessage = z.object({ + type: z.literal("act_changed"), + newAct: z.enum(ACTS), +}) + +export const roomEndedMessage = z.object({ + type: z.literal("room_ended"), +}) + +export const errorMessage = z.object({ + type: z.literal("error"), + message: z.string(), +}) + +export const gameStateMessage = z.object({ + type: z.literal("game_state"), + gameState: gameStateSchema, +}) + +export const predictionsLockedMessage = z.object({ + type: z.literal("predictions_locked"), +}) + +export const serverMessage = z.discriminatedUnion("type", [ + roomStateMessage, + playerJoinedMessage, + playerDisconnectedMessage, + playerReconnectedMessage, + actChangedMessage, + roomEndedMessage, + errorMessage, + gameStateMessage, + predictionsLockedMessage, +]) + +export type ServerMessage = z.infer +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/shared/src/ws-messages.ts +git commit -m "remove dish WS messages, update prediction message to first/second/third/last" +``` + +### Task 4: Create ESC 2025 data file + +**Files:** +- Create: `packages/server/data/esc-2025.json` +- Delete: `packages/server/data/esc-2026.json` + +- [ ] **Step 1: Create esc-2025.json with full entry data** + +Create the file with ESC 2025 entries. Each entry needs `country: { code, name, flag }`, `artist`, and `song`. Use real ESC 2025 Basel contest data. Here is the data (verify artist/song accuracy if unsure — web search "Eurovision 2025 entries" for the official list): + +```json +{ + "year": 2025, + "entries": [ + { "country": { "code": "AL", "name": "Albania", "flag": "🇦🇱" }, "artist": "Shkodra Elektronike", "song": "Zjerm" }, + { "country": { "code": "AM", "name": "Armenia", "flag": "🇦🇲" }, "artist": "Parg", "song": "Survivor" }, + { "country": { "code": "AU", "name": "Australia", "flag": "🇦🇺" }, "artist": "Go-Jo", "song": "Milkshake Man" }, + { "country": { "code": "AT", "name": "Austria", "flag": "🇦🇹" }, "artist": "JJ", "song": "Wasted Love" }, + { "country": { "code": "AZ", "name": "Azerbaijan", "flag": "🇦🇿" }, "artist": "Mamagama", "song": "Run" }, + { "country": { "code": "BE", "name": "Belgium", "flag": "🇧🇪" }, "artist": "Red Sebastian", "song": "Strobe Lights" }, + { "country": { "code": "HR", "name": "Croatia", "flag": "🇭🇷" }, "artist": "Marko Bošnjak", "song": "Lying to Myself" }, + { "country": { "code": "CY", "name": "Cyprus", "flag": "🇨🇾" }, "artist": "Theo Evan", "song": "Aponi" }, + { "country": { "code": "CZ", "name": "Czechia", "flag": "🇨🇿" }, "artist": "Adonxs", "song": "Kiss Kiss Goodbye" }, + { "country": { "code": "DK", "name": "Denmark", "flag": "🇩🇰" }, "artist": "Sissal", "song": "Hallucination" }, + { "country": { "code": "EE", "name": "Estonia", "flag": "🇪🇪" }, "artist": "Tommy Cash", "song": "Espresso Macchiato" }, + { "country": { "code": "FI", "name": "Finland", "flag": "🇫🇮" }, "artist": "Erika Vikman", "song": "Ich Komme" }, + { "country": { "code": "FR", "name": "France", "flag": "🇫🇷" }, "artist": "Louane", "song": "Maman" }, + { "country": { "code": "GE", "name": "Georgia", "flag": "🇬🇪" }, "artist": "Mariam Bigvava", "song": "Ertad Moval" }, + { "country": { "code": "DE", "name": "Germany", "flag": "🇩🇪" }, "artist": "Abor", "song": "Süden" }, + { "country": { "code": "GR", "name": "Greece", "flag": "🇬🇷" }, "artist": "Klavdia", "song": "Asteromata" }, + { "country": { "code": "IS", "name": "Iceland", "flag": "🇮🇸" }, "artist": "VÍK", "song": "Róa" }, + { "country": { "code": "IE", "name": "Ireland", "flag": "🇮🇪" }, "artist": "Óige", "song": "Song of the Sirens" }, + { "country": { "code": "IL", "name": "Israel", "flag": "🇮🇱" }, "artist": "Yuval Raphael", "song": "New Day Will Rise" }, + { "country": { "code": "IT", "name": "Italy", "flag": "🇮🇹" }, "artist": "Lucio Corsi", "song": "Volano le Rondini" }, + { "country": { "code": "LV", "name": "Latvia", "flag": "🇱🇻" }, "artist": "Tautumeitas", "song": "Bur man laimi" }, + { "country": { "code": "LT", "name": "Lithuania", "flag": "🇱🇹" }, "artist": "Katažina Zvonkuvienė", "song": "Tavo Akys" }, + { "country": { "code": "LU", "name": "Luxembourg", "flag": "🇱🇺" }, "artist": "Laura Music", "song": "La Poupée" }, + { "country": { "code": "MT", "name": "Malta", "flag": "🇲🇹" }, "artist": "Miriana Conte", "song": "Serving" }, + { "country": { "code": "MD", "name": "Moldova", "flag": "🇲🇩" }, "artist": "Natalia Barbu", "song": "Fight" }, + { "country": { "code": "ME", "name": "Montenegro", "flag": "🇲🇪" }, "artist": "Nina Žižić", "song": "Dobrodošli" }, + { "country": { "code": "NL", "name": "Netherlands", "flag": "🇳🇱" }, "artist": "Claude", "song": "C'est La Vie" }, + { "country": { "code": "MK", "name": "North Macedonia", "flag": "🇲🇰" }, "artist": "Ina", "song": "Sunrise" }, + { "country": { "code": "NO", "name": "Norway", "flag": "🇳🇴" }, "artist": "Kyle Alessandro", "song": "Lighter" }, + { "country": { "code": "PL", "name": "Poland", "flag": "🇵🇱" }, "artist": "Justyna Steczkowska", "song": "Gaja" }, + { "country": { "code": "PT", "name": "Portugal", "flag": "🇵🇹" }, "artist": "Napa", "song": "Deslocado" }, + { "country": { "code": "RO", "name": "Romania", "flag": "🇷🇴" }, "artist": "Lucian Colareza", "song": "The Road" }, + { "country": { "code": "SM", "name": "San Marino", "flag": "🇸🇲" }, "artist": "Gabry Ponte", "song": "Tutta L'Italia" }, + { "country": { "code": "RS", "name": "Serbia", "flag": "🇷🇸" }, "artist": "Breskvica", "song": "Mango" }, + { "country": { "code": "SI", "name": "Slovenia", "flag": "🇸🇮" }, "artist": "Klemen Slakonja", "song": "How Much Time Do We Have Left" }, + { "country": { "code": "ES", "name": "Spain", "flag": "🇪🇸" }, "artist": "Melody", "song": "Esa Diva" }, + { "country": { "code": "SE", "name": "Sweden", "flag": "🇸🇪" }, "artist": "KAJ", "song": "Bara Bansen" }, + { "country": { "code": "CH", "name": "Switzerland", "flag": "🇨🇭" }, "artist": "Zoë Më", "song": "Voyage" }, + { "country": { "code": "UA", "name": "Ukraine", "flag": "🇺🇦" }, "artist": "Ziferblat", "song": "Bird of Pray" }, + { "country": { "code": "GB", "name": "United Kingdom", "flag": "🇬🇧" }, "artist": "Remember Monday", "song": "What the Hell Just Happened?" } + ] +} +``` + +**Note:** Verify this data against the official ESC 2025 entries at https://eurovision.tv/event/basel-2025/participants. Some entries may need correction. + +- [ ] **Step 2: Delete the old data file** + +```bash +rm packages/server/data/esc-2026.json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/data/esc-2025.json +git add -u packages/server/data/esc-2026.json +git commit -m "replace esc-2026 country-only data with esc-2025 full entries (flag, artist, song)" +``` + +--- + +## Chunk 2: Server Changes + +### Task 5: Rewrite GameManager + +**Files:** +- Modify: `packages/server/src/games/game-manager.ts` + +- [ ] **Step 1: Rewrite game-manager.ts — remove dishes, update predictions, add predictionSubmitted** + +Replace the entire file: + +```ts +import type { Prediction, GameState, Lineup } from "@celebrate-esc/shared" +import lineupData from "../../data/esc-2025.json" + +const lineup: Lineup = lineupData as Lineup +const countryCodes = new Set(lineup.entries.map((e) => e.country.code)) + +export class GameManager { + private predictions = new Map() // playerId → prediction + private locked = false + + getLineup(): Lineup { + return lineup + } + + isValidCountry(code: string): boolean { + return countryCodes.has(code) + } + + // ─── Predictions ──────────────────────────────────────────────── + + arePredictionsLocked(): boolean { + return this.locked + } + + lockPredictions(): void { + this.locked = true + } + + submitPrediction( + playerId: string, + first: string, + second: string, + third: string, + last: string, + ): { success: true } | { error: string } { + if (this.locked) return { error: "Predictions are locked" } + + const allPicks = [first, second, third, last] + for (const code of allPicks) { + if (!this.isValidCountry(code)) return { error: `Invalid country: ${code}` } + } + + if (new Set(allPicks).size !== 4) { + return { error: "All 4 picks must be different countries" } + } + + this.predictions.set(playerId, { playerId, first, second, third, last }) + return { success: true } + } + + getPrediction(playerId: string): Prediction | null { + return this.predictions.get(playerId) ?? null + } + + getAllPredictions(): Map { + return this.predictions + } + + hasPrediction(playerId: string): boolean { + return this.predictions.has(playerId) + } + + // ─── State for client ─────────────────────────────────────────── + + private buildPredictionSubmitted(playerIds: string[]): Record { + const result: Record = {} + for (const id of playerIds) { + result[id] = this.predictions.has(id) + } + return result + } + + getGameStateForPlayer(playerId: string, allPlayerIds: string[]): GameState { + return { + lineup, + myPrediction: this.getPrediction(playerId), + predictionsLocked: this.locked, + predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds), + } + } + + getGameStateForDisplay(allPlayerIds: string[]): GameState { + return { + lineup, + myPrediction: null, + predictionsLocked: this.locked, + predictionSubmitted: this.buildPredictionSubmitted(allPlayerIds), + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/games/game-manager.ts +git commit -m "rewrite game manager: ordered predictions, predictionSubmitted, remove dishes" +``` + +### Task 6: Update DB schema + +**Files:** +- Modify: `packages/server/src/db/schema.ts` + +- [ ] **Step 1: Update schema — remove dish tables, update prediction columns, update actEnum** + +Replace the entire file: + +```ts +import { boolean, jsonb, pgEnum, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" + +export const actEnum = pgEnum("act", ["lobby", "pre-show", "live-event", "scoring", "ended"]) + +// ─── Room System ──────────────────────────────────────────────────── + +export const rooms = pgTable("rooms", { + id: uuid("id").primaryKey().defaultRandom(), + code: varchar("code", { length: 4 }).notNull().unique(), + currentAct: actEnum("current_act").notNull().default("lobby"), + hostSessionId: uuid("host_session_id").notNull(), + actualWinner: varchar("actual_winner"), + actualSecond: varchar("actual_second"), + actualThird: varchar("actual_third"), + actualLast: varchar("actual_last"), + createdAt: timestamp("created_at").notNull().defaultNow(), + expiresAt: timestamp("expires_at").notNull(), +}) + +export const players = pgTable("players", { + id: uuid("id").primaryKey().defaultRandom(), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + sessionId: uuid("session_id").notNull().unique(), + displayName: varchar("display_name", { length: 20 }).notNull(), + isHost: boolean("is_host").notNull().default(false), + connected: boolean("connected").notNull().default(false), + joinedAt: timestamp("joined_at").notNull().defaultNow(), +}) + +// ─── Predictions ──────────────────────────────────────────────────── + +export const predictions = pgTable("predictions", { + id: uuid("id").primaryKey().defaultRandom(), + playerId: uuid("player_id") + .notNull() + .references(() => players.id), + roomId: uuid("room_id") + .notNull() + .references(() => rooms.id), + first: varchar("first").notNull(), + second: varchar("second").notNull(), + third: varchar("third").notNull(), + last: varchar("last").notNull(), +}) +``` + +Note: This removes `dishes`, `dishGuesses`, `juryRounds`, `juryVotes`, `bingoCards`, `quizRounds`, `quizAnswers` tables, and the `juryRoundStatusEnum` and `quizRoundStatusEnum` enums. These belonged to unimplemented plans and can be re-added when needed. + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/db/schema.ts +git commit -m "update DB schema: rename acts, update prediction columns, remove dish/jury/bingo/quiz tables" +``` + +### Task 7: Update GameService + +**Files:** +- Modify: `packages/server/src/games/game-service.ts` + +- [ ] **Step 1: Remove dish persistence, update prediction columns** + +Replace the entire file: + +```ts +import { eq, and } from "drizzle-orm" +import type { Database } from "../db/client" +import { predictions } from "../db/schema" + +export class GameService { + constructor(private db: Database) {} + + async persistPrediction(data: { + playerId: string + roomId: string + first: string + second: string + third: string + last: string + }) { + // Delete existing prediction for this player+room, then insert + await this.db + .delete(predictions) + .where(and(eq(predictions.playerId, data.playerId), eq(predictions.roomId, data.roomId))) + await this.db.insert(predictions).values({ + playerId: data.playerId, + roomId: data.roomId, + first: data.first, + second: data.second, + third: data.third, + last: data.last, + }) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/games/game-service.ts +git commit -m "simplify game service: remove dish persistence, update prediction columns" +``` + +### Task 8: Update WS handler + +**Files:** +- Modify: `packages/server/src/ws/handler.ts` + +- [ ] **Step 1: Remove dish handlers, update prediction handler, update act lock trigger** + +Replace the entire file: + +```ts +import type { WSContext } from "hono/ws" +import { clientMessage } from "@celebrate-esc/shared" +import type { ServerMessage } from "@celebrate-esc/shared" +import { app, upgradeWebSocket } from "../app" +import { roomManager } from "../rooms/index" + +// Track all WebSocket connections per room +interface Connection { + ws: WSContext + sessionId: string | null +} +const roomConnections = new Map>() + +function getConnections(roomCode: string): Set { + let conns = roomConnections.get(roomCode) + if (!conns) { + conns = new Set() + roomConnections.set(roomCode, conns) + } + return conns +} + +function broadcast(roomCode: string, message: ServerMessage) { + const data = JSON.stringify(message) + const conns = roomConnections.get(roomCode) + if (!conns) return + for (const conn of conns) { + try { + conn.ws.send(data) + } catch { + // Connection may be closed -- will be cleaned up on onClose + } + } +} + +function sendTo(ws: WSContext, message: ServerMessage) { + ws.send(JSON.stringify(message)) +} + +function sendError(ws: WSContext, message: string) { + sendTo(ws, { type: "error", message }) +} + +function sendGameState(ws: WSContext, roomCode: string, sessionId: string) { + const gm = roomManager.getGameManager(roomCode) + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + if (!gm || !playerId) return + + const allPlayerIds = roomManager.getAllPlayerIds(roomCode) + const gameState = gm.getGameStateForPlayer(playerId, allPlayerIds) + sendTo(ws, { type: "game_state", gameState }) +} + +function sendDisplayGameState(ws: WSContext, roomCode: string) { + const gm = roomManager.getGameManager(roomCode) + if (!gm) return + + const allPlayerIds = roomManager.getAllPlayerIds(roomCode) + const gameState = gm.getGameStateForDisplay(allPlayerIds) + sendTo(ws, { type: "game_state", gameState }) +} + +function broadcastGameStateToAll(roomCode: string) { + const conns = roomConnections.get(roomCode) + if (!conns) return + for (const conn of conns) { + try { + if (conn.sessionId) { + sendGameState(conn.ws, roomCode, conn.sessionId) + } else { + sendDisplayGameState(conn.ws, roomCode) + } + } catch { + // Connection may be closed + } + } +} + +let registered = false + +export function registerWebSocketRoutes() { + if (registered) return + registered = true + + app.get( + "/ws/:roomCode", + upgradeWebSocket((c) => { + const roomCode = c.req.param("roomCode")! + let sessionId: string | null = c.req.query("sessionId") ?? null + let connection: Connection | null = null + + return { + onOpen(_event, ws) { + const room = roomManager.getRoom(roomCode) + if (!room) { + sendError(ws, "Room not found") + ws.close(4004, "Room not found") + return + } + + connection = { ws, sessionId } + getConnections(roomCode).add(connection) + + if (sessionId) { + const result = roomManager.reconnectPlayer(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + sessionId = null + connection.sessionId = null + } else { + roomManager.setPlayerConnected(roomCode, sessionId, true) + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + sendGameState(ws, roomCode, sessionId) + broadcast(roomCode, { + type: "player_reconnected", + playerId: result.playerId, + }) + } + } else { + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + sendDisplayGameState(ws, roomCode) + } + }, + + onMessage(event, ws) { + let data: unknown + try { + data = JSON.parse(typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer)) + } catch { + sendError(ws, "Invalid JSON") + return + } + + const parsed = clientMessage.safeParse(data) + if (!parsed.success) { + sendError(ws, `Invalid message: ${parsed.error.message}`) + return + } + + const msg = parsed.data + + switch (msg.type) { + case "join_room": { + if (sessionId) { + sendError(ws, "Already joined") + return + } + const result = roomManager.joinRoom(roomCode, msg.displayName) + if ("error" in result) { + sendError(ws, result.error) + return + } + sessionId = result.sessionId + if (connection) connection.sessionId = sessionId + roomManager.setPlayerConnected(roomCode, sessionId, true) + + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + sessionId: result.sessionId, + }) + sendGameState(ws, roomCode, result.sessionId) + + const room = roomManager.getRoom(roomCode)! + const newPlayer = room.players.find((p) => p.sessionId === sessionId)! + broadcast(roomCode, { + type: "player_joined", + player: newPlayer, + }) + break + } + + case "reconnect": { + const result = roomManager.reconnectPlayer(roomCode, msg.sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + sessionId = msg.sessionId + if (connection) connection.sessionId = sessionId + roomManager.setPlayerConnected(roomCode, sessionId, true) + sendTo(ws, { + type: "room_state", + room: roomManager.getRoom(roomCode)!, + }) + sendGameState(ws, roomCode, msg.sessionId) + broadcast(roomCode, { + type: "player_reconnected", + playerId: result.playerId, + }) + break + } + + case "advance_act": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const result = roomManager.advanceAct(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + broadcast(roomCode, { + type: "act_changed", + newAct: result.newAct, + }) + // Lock predictions when moving from pre-show to live-event + if (result.newAct === "live-event") { + const gm = roomManager.getGameManager(roomCode) + if (gm) { + gm.lockPredictions() + broadcast(roomCode, { type: "predictions_locked" }) + } + } + break + } + + case "end_room": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const result = roomManager.endRoom(roomCode, sessionId) + if ("error" in result) { + sendError(ws, result.error) + return + } + broadcast(roomCode, { type: "room_ended" }) + break + } + + case "submit_prediction": { + if (!sessionId) { + sendError(ws, "Not joined") + return + } + const playerId = roomManager.getPlayerIdBySession(roomCode, sessionId) + const gm = roomManager.getGameManager(roomCode) + if (!playerId || !gm) { + sendError(ws, "Room not found") + return + } + + const result = gm.submitPrediction(playerId, msg.first, msg.second, msg.third, msg.last) + if ("error" in result) { + sendError(ws, result.error) + return + } + + // Broadcast game state to all so everyone sees the checkmark update + broadcastGameStateToAll(roomCode) + break + } + } + }, + + onClose() { + if (connection) { + getConnections(roomCode).delete(connection) + } + if (sessionId) { + roomManager.setPlayerConnected(roomCode, sessionId, false) + const room = roomManager.getRoom(roomCode) + if (room) { + const player = room.players.find((p) => p.sessionId === sessionId) + if (player) { + broadcast(roomCode, { + type: "player_disconnected", + playerId: player.id, + }) + } + } + } + }, + } + }), + ) +} +``` + +Key changes: +- `sendGameState` and `sendDisplayGameState` now pass `allPlayerIds` instead of `playerLookup` +- `submit_prediction` now broadcasts game state to ALL connections (so checkmarks update for everyone) +- `broadcastGameStateToAll` helper sends personalized game state to each connection +- Act lock trigger changed from `"act2"` to `"live-event"` +- All dish message handlers removed + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/ws/handler.ts +git commit -m "update WS handler: remove dish handlers, broadcast prediction checkmarks, lock on live-event" +``` + +### Task 9: Update RoomManager + +**Files:** +- Modify: `packages/server/src/rooms/room-manager.ts` + +- [ ] **Step 1: Add getAllPlayerIds method, remove getPlayerLookup** + +The `getPlayerLookup` method is no longer needed (was used for dish results). Replace it with `getAllPlayerIds`. Also update the import — `ACTS` is unchanged in shape but values are different, no code change needed for the manager itself since it uses the type system. + +In `packages/server/src/rooms/room-manager.ts`, replace the `getPlayerLookup` method (lines 151-159) with: + +```ts + getAllPlayerIds(code: string): string[] { + const room = this.rooms.get(code) + if (!room) return [] + return Array.from(room.players.values()).map((p) => p.id) + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/server/src/rooms/room-manager.ts +git commit -m "replace getPlayerLookup with getAllPlayerIds in room manager" +``` + +### Task 10: Rewrite server tests + +**Files:** +- Modify: `packages/server/tests/game-manager.test.ts` +- Modify: `packages/server/tests/ws-handler.test.ts` + +- [ ] **Step 1: Rewrite game-manager.test.ts** + +Replace the entire file: + +```ts +import { describe, it, expect, beforeEach } from "vitest" +import { GameManager } from "../src/games/game-manager" + +describe("GameManager", () => { + let gm: GameManager + + beforeEach(() => { + gm = new GameManager() + }) + + describe("lineup", () => { + it("returns the ESC 2025 lineup", () => { + const lineup = gm.getLineup() + expect(lineup.year).toBe(2025) + expect(lineup.entries.length).toBeGreaterThan(20) + expect(lineup.entries[0]).toHaveProperty("country") + expect(lineup.entries[0]).toHaveProperty("artist") + expect(lineup.entries[0]).toHaveProperty("song") + expect(lineup.entries[0]?.country).toHaveProperty("flag") + }) + + it("validates country codes", () => { + expect(gm.isValidCountry("DE")).toBe(true) + expect(gm.isValidCountry("XX")).toBe(false) + }) + }) + + describe("predictions", () => { + it("accepts a valid prediction", () => { + const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + expect(result).toEqual({ success: true }) + expect(gm.getPrediction("p1")).toEqual({ + playerId: "p1", + first: "SE", + second: "DE", + third: "IT", + last: "GB", + }) + }) + + it("rejects prediction with invalid country", () => { + const result = gm.submitPrediction("p1", "XX", "DE", "IT", "GB") + expect(result).toEqual({ error: "Invalid country: XX" }) + }) + + it("rejects duplicate picks", () => { + const result = gm.submitPrediction("p1", "SE", "SE", "IT", "GB") + expect(result).toEqual({ error: "All 4 picks must be different countries" }) + }) + + it("rejects last same as first", () => { + const result = gm.submitPrediction("p1", "SE", "DE", "IT", "SE") + expect(result).toEqual({ error: "All 4 picks must be different countries" }) + }) + + it("allows overwriting a prediction", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + gm.submitPrediction("p1", "NO", "DE", "IT", "GB") + expect(gm.getPrediction("p1")?.first).toBe("NO") + }) + + it("rejects prediction when locked", () => { + gm.lockPredictions() + const result = gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + expect(result).toEqual({ error: "Predictions are locked" }) + }) + + it("tracks prediction submission status", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + expect(gm.hasPrediction("p1")).toBe(true) + expect(gm.hasPrediction("p2")).toBe(false) + }) + }) + + describe("getGameStateForPlayer", () => { + it("includes only the requesting player's prediction", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + gm.submitPrediction("p2", "NO", "DE", "IT", "GB") + const state = gm.getGameStateForPlayer("p1", ["p1", "p2"]) + expect(state.myPrediction?.first).toBe("SE") + }) + + it("includes predictionSubmitted for all players", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + const state = gm.getGameStateForPlayer("p1", ["p1", "p2"]) + expect(state.predictionSubmitted).toEqual({ p1: true, p2: false }) + }) + }) + + describe("getGameStateForDisplay", () => { + it("returns null myPrediction", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + const state = gm.getGameStateForDisplay(["p1"]) + expect(state.myPrediction).toBeNull() + }) + + it("includes predictionSubmitted", () => { + gm.submitPrediction("p1", "SE", "DE", "IT", "GB") + const state = gm.getGameStateForDisplay(["p1", "p2"]) + expect(state.predictionSubmitted).toEqual({ p1: true, p2: false }) + }) + }) +}) +``` + +- [ ] **Step 2: Update ws-handler.test.ts — fix act references** + +In `packages/server/tests/ws-handler.test.ts`, no structural changes are needed — the test creates a room, connects via WS, and tests join. The `waitForMessageType` helper already handles draining game_state messages. The tests should still pass since the WS handler changes are backward-compatible at the protocol level for room_state and player_joined messages. + +Run the tests to verify: + +```bash +cd packages/server && bun test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/tests/game-manager.test.ts packages/server/tests/ws-handler.test.ts +git commit -m "rewrite game manager tests for new prediction model" +``` + +### Task 11: Push DB schema to Postgres + +- [ ] **Step 1: Push the new schema to the database** + +Since there is no production data to preserve, use Drizzle push to recreate the schema: + +```bash +cd packages/server && bunx drizzle-kit push +``` + +If the push fails due to enum conflicts, connect to the database and manually drop the old enums and tables first: + +```bash +# Only if push fails: +psql -p 5433 esc -c "DROP TABLE IF EXISTS dish_guesses, dishes, quiz_answers, quiz_rounds, bingo_cards, jury_votes, jury_rounds CASCADE;" +psql -p 5433 esc -c "DROP TYPE IF EXISTS jury_round_status, quiz_round_status CASCADE;" +# Then re-run: bunx drizzle-kit push +``` + +- [ ] **Step 2: Commit (no file changes, but verify)** + +No git commit needed — this is a runtime operation. + +--- + +## Chunk 3: Client Changes + +### Task 12: Simplify room store and WebSocket hook + +**Files:** +- Modify: `packages/client/src/stores/room-store.ts` +- Modify: `packages/client/src/hooks/use-websocket.ts` + +- [ ] **Step 1: Simplify room-store.ts — remove all dish state** + +Replace the entire file: + +```tsx +import { create } from "zustand" +import type { RoomState, Player, GameState } from "@celebrate-esc/shared" + +interface RoomStore { + room: RoomState | null + mySessionId: string | null + connectionStatus: "disconnected" | "connecting" | "connected" + gameState: GameState | null + + 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 + reset: () => void +} + +export const useRoomStore = create((set) => ({ + room: null, + mySessionId: null, + connectionStatus: "disconnected", + gameState: null, + + 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 }, + } + }), + + reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected", gameState: null }), +})) +``` + +- [ ] **Step 2: Simplify use-websocket.ts — remove dish message handlers** + +Replace the entire file: + +```tsx +import { useEffect, useRef, useCallback } from "react" +import type { ClientMessage, ServerMessage } from "@celebrate-esc/shared" +import { useRoomStore } from "@/stores/room-store" + +const SESSION_KEY = "esc-party-session" + +function getStoredSession(): { roomCode: string; sessionId: string } | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +function storeSession(roomCode: string, sessionId: string) { + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ roomCode, sessionId })) +} + +export function useWebSocket(roomCode: string) { + const wsRef = useRef(null) + const { + setRoom, + setMySessionId, + setConnectionStatus, + updatePlayerConnected, + addPlayer, + setAct, + reset, + setGameState, + lockPredictions, + } = useRoomStore() + + const send = useCallback((message: ClientMessage) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)) + } + }, []) + + useEffect(() => { + const stored = getStoredSession() + const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const base = import.meta.env.BASE_URL.replace(/\/$/, "") + const wsUrl = sessionId + ? `${protocol}//${window.location.host}${base}/api/ws/${roomCode}?sessionId=${sessionId}` + : `${protocol}//${window.location.host}${base}/api/ws/${roomCode}` + + setConnectionStatus("connecting") + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnectionStatus("connected") + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) as ServerMessage + + switch (msg.type) { + case "room_state": { + setRoom(msg.room) + if (msg.sessionId) { + setMySessionId(msg.sessionId) + storeSession(roomCode, msg.sessionId) + } else if (sessionId) { + setMySessionId(sessionId) + } + break + } + case "player_joined": + addPlayer(msg.player) + break + case "player_disconnected": + updatePlayerConnected(msg.playerId, false) + break + case "player_reconnected": + updatePlayerConnected(msg.playerId, true) + break + case "act_changed": + setAct(msg.newAct) + break + case "room_ended": + setAct("ended") + break + case "game_state": + setGameState(msg.gameState) + break + case "predictions_locked": + lockPredictions() + break + case "error": + console.error("Server error:", msg.message) + break + } + } + + ws.onclose = () => { + setConnectionStatus("disconnected") + } + + return () => { + ws.close() + reset() + } + }, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset, setGameState, lockPredictions]) + + return { send } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/client/src/stores/room-store.ts packages/client/src/hooks/use-websocket.ts +git commit -m "simplify room store, remove dish message handlers from websocket hook" +``` + +### Task 13: Rewrite predictions form (tap-to-assign) + +**Files:** +- Modify: `packages/client/src/components/predictions-form.tsx` + +- [ ] **Step 1: Rewrite as tap-to-assign UI** + +Replace the entire file: + +```tsx +import { useState } from "react" +import type { Entry, Prediction } from "@celebrate-esc/shared" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +type SlotKey = "first" | "second" | "third" | "last" + +const SLOTS: { key: SlotKey; label: string }[] = [ + { key: "first", label: "1st Place" }, + { key: "second", label: "2nd Place" }, + { key: "third", label: "3rd Place" }, + { key: "last", label: "Last Place" }, +] + +function formatEntry(entry: Entry): string { + return `${entry.country.flag} ${entry.artist} — ${entry.song}` +} + +interface PredictionsFormProps { + entries: Entry[] + existingPrediction: Prediction | null + locked: boolean + onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void +} + +export function PredictionsForm({ entries, existingPrediction, locked, onSubmit }: PredictionsFormProps) { + const [slots, setSlots] = useState>(() => { + if (existingPrediction) { + return { + first: existingPrediction.first, + second: existingPrediction.second, + third: existingPrediction.third, + last: existingPrediction.last, + } + } + return { first: null, second: null, third: null, last: null } + }) + const [pickerForEntry, setPickerForEntry] = useState(null) + + const assignedCodes = new Set(Object.values(slots).filter(Boolean)) + const emptySlots = SLOTS.filter((s) => !slots[s.key]) + const allFilled = SLOTS.every((s) => slots[s.key]) + + function findEntry(code: string): Entry | undefined { + return entries.find((e) => e.country.code === code) + } + + function assignToSlot(entryCode: string, slotKey: SlotKey) { + setSlots((prev) => ({ ...prev, [slotKey]: entryCode })) + setPickerForEntry(null) + } + + function removeFromSlot(slotKey: SlotKey) { + setSlots((prev) => ({ ...prev, [slotKey]: null })) + } + + if (locked) { + if (!existingPrediction) { + return ( + + + Predictions are locked. You didn't submit one in time. + + + ) + } + return ( + + + Your Predictions (locked) + + + {SLOTS.map((slot) => { + const entry = findEntry(existingPrediction[slot.key]) + return ( +
+ {slot.label} + {entry ? formatEntry(entry) : "—"} +
+ ) + })} +
+
+ ) + } + + // Already submitted — show read-only with option to resubmit later + if (existingPrediction && allFilled) { + const hasChanges = SLOTS.some((s) => slots[s.key] !== existingPrediction[s.key]) + if (!hasChanges) { + return ( + + + Your Predictions (submitted) + + + {SLOTS.map((slot) => { + const entry = findEntry(existingPrediction[slot.key]) + return ( +
+
+ {slot.label} + {entry ? formatEntry(entry) : "—"} +
+ +
+ ) + })} +
+
+ ) + } + } + + return ( + + + Predictions + + + {/* Slot cards */} +
+ {SLOTS.map((slot) => { + const code = slots[slot.key] + const entry = code ? findEntry(code) : null + return ( +
+
+ {slot.label} + {entry ? ( + {formatEntry(entry)} + ) : ( + Tap an entry below + )} +
+ {code && ( + + )} +
+ ) + })} +
+ + {/* Submit button */} + {allFilled && ( + + )} + + {/* Entry list */} +
+

Entries

+ {entries.map((entry) => { + const isAssigned = assignedCodes.has(entry.country.code) + const isPickerOpen = pickerForEntry === entry.country.code + return ( +
+ + {isPickerOpen && !isAssigned && ( +
+ {emptySlots.map((slot) => ( + + ))} +
+ )} +
+ ) + })} +
+
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/predictions-form.tsx +git commit -m "rewrite predictions form as tap-to-assign with 4 ranked slots" +``` + +### Task 14: Update player list with prediction checkmark + +**Files:** +- Modify: `packages/client/src/components/player-list.tsx` + +- [ ] **Step 1: Add predictionSubmitted prop and render checkmarks** + +Replace the entire file: + +```tsx +import { Badge } from "@/components/ui/badge" +import type { Player } from "@celebrate-esc/shared" + +interface PlayerListProps { + players: Player[] + mySessionId: string | null + predictionSubmitted?: Record +} + +export function PlayerList({ players, mySessionId, predictionSubmitted }: PlayerListProps) { + return ( +
+

Players ({players.length})

+
    + {players.map((player) => ( +
  • + + + {player.displayName} + + {player.isHost && ( + + Host + + )} + {player.sessionId === mySessionId && ( + (you) + )} + {predictionSubmitted?.[player.id] && ( + + )} +
  • + ))} +
+
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/player-list.tsx +git commit -m "add prediction submission checkmark to player list" +``` + +### Task 15: Update room header with new act labels + +**Files:** +- Modify: `packages/client/src/components/room-header.tsx` + +- [ ] **Step 1: Use ACT_LABELS from shared constants** + +Replace the entire file: + +```tsx +import { Badge } from "@/components/ui/badge" +import { ACT_LABELS } from "@celebrate-esc/shared" +import type { Act } from "@celebrate-esc/shared" + +interface RoomHeaderProps { + roomCode: string + currentAct: Act + connectionStatus: "disconnected" | "connecting" | "connected" +} + +export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) { + return ( +
+
+ {roomCode} + {ACT_LABELS[currentAct]} +
+ +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/client/src/components/room-header.tsx +git commit -m "use ACT_LABELS from shared constants in room header" +``` + +### Task 16: Update routes — remove dish UI, update act references + +**Files:** +- Modify: `packages/client/src/routes/play.$roomCode.tsx` +- Modify: `packages/client/src/routes/host.$roomCode.tsx` +- Modify: `packages/client/src/routes/display.$roomCode.tsx` +- Delete: `packages/client/src/components/dish-list.tsx` +- Delete: `packages/client/src/components/dish-host.tsx` +- Delete: `packages/client/src/components/dish-results.tsx` + +- [ ] **Step 1: Rewrite play.$roomCode.tsx** + +Replace the entire file: + +```tsx +import { useEffect, useRef, useState } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { PredictionsForm } from "@/components/predictions-form" +import { RoomHeader } from "@/components/room-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export const Route = createFileRoute("/play/$roomCode")({ + component: PlayerView, +}) + +function PlayerView() { + const { roomCode } = Route.useParams() + const { send } = useWebSocket(roomCode) + const { room, mySessionId, connectionStatus, gameState } = 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 ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) { + return ( +
+

Join Room {roomCode}

+ setManualName(e.target.value)} + maxLength={20} + onKeyDown={(e) => { + if (e.key === "Enter" && manualName.trim()) { + joinSentRef.current = true + send({ type: "join_room", displayName: manualName.trim() }) + } + }} + /> + +
+ ) + } + + if (!mySessionId) { + return ( +
+

Joining room...

+
+ ) + } + + return ( +
+ +
+ {room.currentAct === "lobby" && !gameState && ( +
+

Waiting for the host to start...

+
+ )} + + {gameState && room.currentAct !== "ended" && ( +
+ + send({ type: "submit_prediction", ...prediction }) + } + /> +
+ )} + + {room.currentAct === "ended" && ( +
+

The party has ended. Thanks for playing!

+
+ )} + + +
+
+ ) +} +``` + +- [ ] **Step 2: Rewrite host.$roomCode.tsx** + +Replace the entire file: + +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { PredictionsForm } from "@/components/predictions-form" +import { RoomHeader } from "@/components/room-header" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import type { Act } from "@celebrate-esc/shared" + +export const Route = createFileRoute("/host/$roomCode")({ + component: HostView, +}) + +const nextActLabels: Partial> = { + lobby: "Start Pre-Show", + "pre-show": "Start Live Event", + "live-event": "Start Scoring", + scoring: "End Party", +} + +function HostView() { + const { roomCode } = Route.useParams() + const { send } = useWebSocket(roomCode) + const { room, mySessionId, connectionStatus, gameState } = useRoomStore() + + if (!room) { + return ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + return ( +
+ + + + + Play + + + Host + + + + {gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && ( +
+ + send({ type: "submit_prediction", ...prediction }) + } + /> +
+ )} + +
+ +
+ + + Room Controls + + + {room.currentAct !== "ended" && ( + + )} + {room.currentAct !== "ended" && ( + + )} + {room.currentAct === "ended" && ( +

+ The party has ended. Thanks for playing! +

+ )} +
+
+ +
+
+
+
+ ) +} +``` + +- [ ] **Step 3: Rewrite display.$roomCode.tsx — remove dishes, add copy-to-clipboard** + +Replace the entire file: + +```tsx +import { useState } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { useWebSocket } from "@/hooks/use-websocket" +import { useRoomStore } from "@/stores/room-store" +import { PlayerList } from "@/components/player-list" +import { RoomHeader } from "@/components/room-header" +import { ACT_LABELS } from "@celebrate-esc/shared" + +export const Route = createFileRoute("/display/$roomCode")({ + component: DisplayView, +}) + +function DisplayView() { + const { roomCode } = Route.useParams() + useWebSocket(roomCode) + const { room, connectionStatus, gameState } = useRoomStore() + + if (!room) { + return ( +
+

+ {connectionStatus === "connecting" ? "Connecting..." : "Room not found"} +

+
+ ) + } + + return ( +
+ +
+ {room.currentAct === "lobby" && } + + {room.currentAct === "pre-show" && gameState && ( +
+

Pre-Show — Predictions

+

+ {Object.values(gameState.predictionSubmitted).filter(Boolean).length} / {Object.keys(gameState.predictionSubmitted).length} predictions submitted +

+
+ )} + + {room.currentAct !== "lobby" && room.currentAct !== "ended" && room.currentAct !== "pre-show" && ( +
+

{ACT_LABELS[room.currentAct]}

+
+ )} + + {room.currentAct === "ended" && ( +
+

The party has ended. Thanks for playing!

+
+ )} + + +
+
+ ) +} + +function LobbyDisplay({ roomCode }: { roomCode: string }) { + const [copied, setCopied] = useState(false) + const base = import.meta.env.BASE_URL.replace(/\/$/, "") + const joinUrl = `${window.location.origin}${base}/play/${roomCode}` + + function copyCode() { + navigator.clipboard.writeText(roomCode).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( +
+

Join the party!

+ +

+ {copied ? ( + Copied! + ) : ( + <>Tap the code to copy + )} +

+

+ Go to {joinUrl} +

+
+ ) +} +``` + +- [ ] **Step 4: Delete dish components** + +```bash +rm packages/client/src/components/dish-list.tsx +rm packages/client/src/components/dish-host.tsx +rm packages/client/src/components/dish-results.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add -u packages/client/src/components/dish-list.tsx packages/client/src/components/dish-host.tsx packages/client/src/components/dish-results.tsx +git add packages/client/src/routes/play.\$roomCode.tsx packages/client/src/routes/host.\$roomCode.tsx packages/client/src/routes/display.\$roomCode.tsx +git commit -m "update routes: remove dish UI, update act refs, add copy-to-clipboard on lobby display" +``` + +### Task 17: Final verification + +- [ ] **Step 1: Run all server tests** + +```bash +cd packages/server && bun test +``` + +Expected: All tests pass (lineup tests updated for 2025, prediction tests use new 4-pick model, no dish tests remain). + +- [ ] **Step 2: Build the client** + +```bash +cd packages/client && bun run build +``` + +Expected: Build succeeds with no errors. No imports of deleted dish components remain. + +- [ ] **Step 3: Build the shared package** + +```bash +cd packages/shared && bun run build +``` + +Expected: Build succeeds. + +- [ ] **Step 4: Commit any remaining fixes** + +If any build/test issues were discovered and fixed, commit them now.