63 KiB
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 namespackages/shared/src/game-types.ts— entry/lineup schemas, prediction schema, gameState schemapackages/shared/src/ws-messages.ts— remove dish messages, update prediction messagepackages/server/src/games/game-manager.ts— remove dish logic, update predictions, add predictionSubmittedpackages/server/src/games/game-service.ts— remove dish persistence, update prediction columnspackages/server/src/db/schema.ts— remove dish tables, update prediction columns, update actEnumpackages/server/src/rooms/room-manager.ts— act name reference in advanceActpackages/server/src/ws/handler.ts— remove dish handlers, update prediction handler, update act lockpackages/server/tests/game-manager.test.ts— rewrite for new modelpackages/server/tests/ws-handler.test.ts— update for changed messagespackages/client/src/stores/room-store.ts— remove dish state, simplifypackages/client/src/hooks/use-websocket.ts— remove dish message handlerspackages/client/src/components/predictions-form.tsx— rewrite as tap-to-assignpackages/client/src/components/player-list.tsx— add prediction checkmarkpackages/client/src/components/room-header.tsx— update act labelspackages/client/src/routes/play.$roomCode.tsx— remove dish UI, update act refspackages/client/src/routes/host.$roomCode.tsx— remove dish UI, update act labelspackages/client/src/routes/display.$roomCode.tsx— remove dish UI, add copy-to-clipboard
Deleted
packages/client/src/components/dish-list.tsxpackages/client/src/components/dish-host.tsxpackages/client/src/components/dish-results.tsxpackages/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:
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<Act, string> = {
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
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:
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<typeof countrySchema>
export const entrySchema = z.object({
country: countrySchema,
artist: z.string(),
song: z.string(),
})
export type Entry = z.infer<typeof entrySchema>
export const lineupSchema = z.object({
year: z.number(),
entries: z.array(entrySchema),
})
export type Lineup = z.infer<typeof lineupSchema>
// ─── 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<typeof predictionSchema>
// ─── 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<typeof gameStateSchema>
- Step 2: Commit
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:
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<typeof clientMessage>
// ─── 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<typeof serverMessage>
- Step 2: Commit
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):
{
"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
rm packages/server/data/esc-2026.json
- Step 3: Commit
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:
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<string, Prediction>() // 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<string, Prediction> {
return this.predictions
}
hasPrediction(playerId: string): boolean {
return this.predictions.has(playerId)
}
// ─── State for client ───────────────────────────────────────────
private buildPredictionSubmitted(playerIds: string[]): Record<string, boolean> {
const result: Record<string, boolean> = {}
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
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:
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
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:
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
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:
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<string, Set<Connection>>()
function getConnections(roomCode: string): Set<Connection> {
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:
-
sendGameStateandsendDisplayGameStatenow passallPlayerIdsinstead ofplayerLookup -
submit_predictionnow broadcasts game state to ALL connections (so checkmarks update for everyone) -
broadcastGameStateToAllhelper sends personalized game state to each connection -
Act lock trigger changed from
"act2"to"live-event" -
All dish message handlers removed
-
Step 2: Commit
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:
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
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:
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:
cd packages/server && bun test
Expected: All tests pass.
- Step 3: Commit
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:
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:
# 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:
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<RoomStore>((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:
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<WebSocket | null>(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
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:
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<Record<SlotKey, string | null>>(() => {
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<string | null>(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 (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
Predictions are locked. You didn't submit one in time.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions (locked)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
return (
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
</div>
)
})}
</CardContent>
</Card>
)
}
// 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 (
<Card>
<CardHeader>
<CardTitle>Your Predictions (submitted)</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
return (
<div key={slot.key} className="flex items-center justify-between rounded-md border p-2">
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
</div>
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-xs text-muted-foreground hover:text-foreground"
>
change
</button>
</div>
)
})}
</CardContent>
</Card>
)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Predictions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* Slot cards */}
<div className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const code = slots[slot.key]
const entry = code ? findEntry(code) : null
return (
<div
key={slot.key}
className={`flex items-center justify-between rounded-md border p-2 ${
code ? "border-primary/30 bg-primary/5" : "border-dashed"
}`}
>
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
{entry ? (
<span className="text-sm">{formatEntry(entry)}</span>
) : (
<span className="text-sm text-muted-foreground">Tap an entry below</span>
)}
</div>
{code && (
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${slot.label}`}
>
✕
</button>
)}
</div>
)
})}
</div>
{/* Submit button */}
{allFilled && (
<Button
onClick={() =>
onSubmit({
first: slots.first!,
second: slots.second!,
third: slots.third!,
last: slots.last!,
})
}
>
{existingPrediction ? "Update Prediction" : "Submit Prediction"}
</Button>
)}
{/* Entry list */}
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
{entries.map((entry) => {
const isAssigned = assignedCodes.has(entry.country.code)
const isPickerOpen = pickerForEntry === entry.country.code
return (
<div key={entry.country.code}>
<button
type="button"
disabled={isAssigned}
onClick={() => {
if (emptySlots.length === 1) {
assignToSlot(entry.country.code, emptySlots[0]!.key)
} else {
setPickerForEntry(isPickerOpen ? null : entry.country.code)
}
}}
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isAssigned
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isPickerOpen
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{formatEntry(entry)}
</button>
{isPickerOpen && !isAssigned && (
<div className="mt-1 ml-4 flex gap-1">
{emptySlots.map((slot) => (
<button
type="button"
key={slot.key}
onClick={() => assignToSlot(entry.country.code, slot.key)}
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
>
{slot.label}
</button>
))}
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
- Step 2: Commit
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:
import { Badge } from "@/components/ui/badge"
import type { Player } from "@celebrate-esc/shared"
interface PlayerListProps {
players: Player[]
mySessionId: string | null
predictionSubmitted?: Record<string, boolean>
}
export function PlayerList({ players, mySessionId, predictionSubmitted }: PlayerListProps) {
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Players ({players.length})</h3>
<ul className="flex flex-col gap-1">
{players.map((player) => (
<li key={player.id} className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
/>
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
{player.displayName}
</span>
{player.isHost && (
<Badge variant="secondary" className="text-xs">
Host
</Badge>
)}
{player.sessionId === mySessionId && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
{predictionSubmitted?.[player.id] && (
<span className="text-green-600" title="Prediction submitted">✓</span>
)}
</li>
))}
</ul>
</div>
)
}
- Step 2: Commit
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:
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 (
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-3">
<span className="font-mono text-2xl font-bold tracking-widest">{roomCode}</span>
<Badge variant="outline">{ACT_LABELS[currentAct]}</Badge>
</div>
<span
className={`h-2 w-2 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500"
: "bg-red-500"
}`}
title={connectionStatus}
/>
</div>
)
}
- Step 2: Commit
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:
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 (
<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 (
<div className="flex min-h-screen flex-col">
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<div className="flex-1 p-4">
{room.currentAct === "lobby" && !gameState && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
</div>
)}
{gameState && room.currentAct !== "ended" && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
</div>
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</div>
)
}
- Step 2: Rewrite host.$roomCode.tsx
Replace the entire file:
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<Record<Act, string>> = {
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 (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
</p>
</div>
)
}
return (
<div className="flex min-h-screen flex-col">
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<Tabs defaultValue="host" className="flex-1">
<TabsList className="w-full rounded-none">
<TabsTrigger value="play" className="flex-1">
Play
</TabsTrigger>
<TabsTrigger value="host" className="flex-1">
Host
</TabsTrigger>
</TabsList>
<TabsContent value="play" className="p-4">
{gameState && (room.currentAct === "lobby" || room.currentAct === "pre-show") && (
<div className="flex flex-col gap-4">
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={gameState.predictionsLocked}
onSubmit={(prediction) =>
send({ type: "submit_prediction", ...prediction })
}
/>
</div>
)}
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</TabsContent>
<TabsContent value="host" className="p-4">
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Room Controls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{room.currentAct !== "ended" && (
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
{nextActLabels[room.currentAct] ?? "Next"}
</Button>
)}
{room.currentAct !== "ended" && (
<Button
variant="destructive"
onClick={() => send({ type: "end_room" })}
className="w-full"
>
End Party
</Button>
)}
{room.currentAct === "ended" && (
<p className="text-center text-muted-foreground">
The party has ended. Thanks for playing!
</p>
)}
</CardContent>
</Card>
<PlayerList
players={room.players}
mySessionId={mySessionId}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</TabsContent>
</Tabs>
</div>
)
}
- Step 3: Rewrite display.$roomCode.tsx — remove dishes, add copy-to-clipboard
Replace the entire file:
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 (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
</p>
</div>
)
}
return (
<div className="flex min-h-screen flex-col">
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
<div className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
{room.currentAct === "lobby" && <LobbyDisplay roomCode={roomCode} />}
{room.currentAct === "pre-show" && gameState && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">Pre-Show — Predictions</p>
<p className="text-lg text-muted-foreground">
{Object.values(gameState.predictionSubmitted).filter(Boolean).length} / {Object.keys(gameState.predictionSubmitted).length} predictions submitted
</p>
</div>
)}
{room.currentAct !== "lobby" && room.currentAct !== "ended" && room.currentAct !== "pre-show" && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">{ACT_LABELS[room.currentAct]}</p>
</div>
)}
{room.currentAct === "ended" && (
<div className="flex flex-col items-center gap-4 py-12">
<p className="text-2xl text-muted-foreground">The party has ended. Thanks for playing!</p>
</div>
)}
<PlayerList
players={room.players}
mySessionId={null}
predictionSubmitted={gameState?.predictionSubmitted}
/>
</div>
</div>
)
}
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 (
<div className="flex flex-col items-center gap-6">
<h2 className="text-2xl text-muted-foreground">Join the party!</h2>
<button
type="button"
onClick={copyCode}
className="cursor-pointer rounded-lg border-4 border-dashed border-muted p-8 transition-colors hover:border-primary/50"
title="Click to copy room code"
>
<span className="font-mono text-8xl font-bold tracking-[0.3em]">{roomCode}</span>
</button>
<p className="text-muted-foreground">
{copied ? (
<span className="font-medium text-green-600">Copied!</span>
) : (
<>Tap the code to copy</>
)}
</p>
<p className="text-muted-foreground">
Go to <span className="font-mono font-medium">{joinUrl}</span>
</p>
</div>
)
}
- Step 4: Delete dish components
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
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
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
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
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.