Files
esc/docs/superpowers/plans/2026-03-12-issue1-fixes.md
2026-03-12 14:54:41 +01:00

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 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:

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:

  • 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

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.