Add draft room closure after completion (#359)

* Add draft room closure: redirect to draft board 5 min after completion

* Fix lint: use nowForPauseCheck instead of Date.now() in room closure countdown

Fixes #253
This commit is contained in:
Chris Parsons 2026-04-30 10:14:14 -07:00 committed by GitHub
parent ef0ddeff39
commit 437ac2ce24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 5348 additions and 16 deletions

View file

@ -135,6 +135,7 @@ export function useDraftRoomState({
const [isOvernightPause, setIsOvernightPause] = useState(false);
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
const [roomClosed, setRoomClosed] = useState(false);
return {
picks, setPicks,
@ -175,5 +176,6 @@ export function useDraftRoomState({
isAdjustingTimeBank, setIsAdjustingTimeBank,
isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt,
roomClosed, setRoomClosed,
};
}

View file

@ -39,6 +39,7 @@ interface UseDraftSocketEventsParams {
setIsOvernightPause: (value: boolean) => void;
setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set<string>) => void;
setRoomClosed: (value: boolean) => void;
}
export function useDraftSocketEvents({
@ -66,6 +67,7 @@ export function useDraftSocketEvents({
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
}: UseDraftSocketEventsParams) {
useEffect(() => {
type PickMadePayload = {
@ -229,6 +231,10 @@ export function useDraftSocketEvents({
setWatchedParticipantIds(new Set(data.participantIds));
};
const handleDraftRoomClosed = () => {
setRoomClosed(true);
};
on("pick-made", handlePickMade as (data: unknown) => void);
on("timer-update", handleTimerUpdate as (data: unknown) => void);
on("draft-paused", handleDraftPaused as (data: unknown) => void);
@ -245,6 +251,7 @@ export function useDraftSocketEvents({
on("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
return () => {
off("pick-made", handlePickMade as (data: unknown) => void);
@ -263,6 +270,7 @@ export function useDraftSocketEvents({
off("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
off("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]);

View file

@ -12,6 +12,7 @@ import { executeAutoPick } from "../draft-utils";
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/models/draft-pick", () => ({

View file

@ -8,7 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo
import { getParticipantsForSeasonWithSports } from "./participant";
import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getSocketIO } from "../../server/socket";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
/**
* Check if the next team has autodraft enabled and immediately execute their pick
@ -719,6 +719,7 @@ export async function executeAutoPick(params: {
.set({
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
status: isDraftComplete ? "active" : season.status,
draftCompletedAt: isDraftComplete ? new Date() : undefined,
})
.where(eq(schema.seasons.id, seasonId));
@ -804,6 +805,7 @@ export async function executeAutoPick(params: {
// Emit draft-completed event if applicable
if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId);
}
} catch (error) {
logger.error("[AutoPick] Socket.IO events error:", error);

View file

@ -7,6 +7,7 @@ const ctx = {} as unknown as RouterContextProvider;
vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },

View file

@ -13,6 +13,7 @@ const ctx = {} as unknown as RouterContextProvider;
vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },

View file

@ -13,6 +13,7 @@ const ctx = {} as unknown as RouterContextProvider;
vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },

View file

@ -7,6 +7,7 @@ const ctx = {} as unknown as RouterContextProvider;
vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },

View file

@ -9,7 +9,7 @@ import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router";
@ -211,6 +211,7 @@ export async function action(args: ActionFunctionArgs) {
.set({
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
status: isDraftComplete ? "active" : season.status,
draftCompletedAt: isDraftComplete ? new Date() : undefined,
})
.where(eq(schema.seasons.id, seasonId));
@ -254,6 +255,7 @@ export async function action(args: ActionFunctionArgs) {
if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId);
}
} catch (error) {
logger.error("Socket.IO error:", error);

View file

@ -8,7 +8,7 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO } from "../../../server/socket";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router";
@ -272,6 +272,7 @@ export async function action(args: ActionFunctionArgs) {
.set({
currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
status: isDraftComplete ? "active" : season.status,
draftCompletedAt: isDraftComplete ? new Date() : undefined,
})
.where(eq(schema.seasons.id, seasonId));
@ -293,6 +294,7 @@ export async function action(args: ActionFunctionArgs) {
if (isDraftComplete) {
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId);
}
} catch (error) {
logger.error("Socket.IO error:", error);

View file

@ -1,4 +1,4 @@
import { useLoaderData, Link, useRevalidator } from "react-router";
import { useLoaderData, Link, useRevalidator, redirect, useNavigate } from "react-router";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { logger } from "~/lib/logger";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -74,6 +74,13 @@ export async function loader(args: Route.LoaderArgs) {
throw new Response("Season not found", { status: 404 });
}
if (season.draftCompletedAt) {
const elapsed = Date.now() - season.draftCompletedAt.getTime();
if (elapsed >= 5 * 60 * 1000) {
return redirect(`/leagues/${leagueId}/draft-board/${seasonId}`);
}
}
const userTeam = userId
? season.teams.find((team) => team.ownerId === userId)
: undefined;
@ -184,6 +191,7 @@ export default function DraftRoom() {
ownerMap,
teamTimezoneMap,
} = useLoaderData<typeof loader>();
const navigate = useNavigate();
const { revalidate, state: revalidatorState } = useRevalidator();
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
const {
@ -245,6 +253,7 @@ export default function DraftRoom() {
isAdjustingTimeBank, setIsAdjustingTimeBank,
isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt,
roomClosed, setRoomClosed,
} = useDraftRoomState({
draftPicks,
season,
@ -264,6 +273,27 @@ export default function DraftRoom() {
return () => clearInterval(id);
}, []);
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
const roomClosureCountdown = useMemo(() => {
if (!isDraftComplete) return null;
const completedAt = season.draftCompletedAt;
if (!completedAt) return 300;
const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000;
const remaining = Math.max(0, 300 - elapsed);
return Math.ceil(remaining);
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
useEffect(() => {
if (!isDraftComplete) return;
if (roomClosed) {
navigate(draftBoardUrl);
return;
}
if (roomClosureCountdown !== null && roomClosureCountdown <= 0) {
navigate(draftBoardUrl);
}
}, [isDraftComplete, roomClosed, roomClosureCountdown, navigate, draftBoardUrl]);
// Compute which teams are currently in their overnight pause window.
const pausedTeamIds = useMemo(() => {
const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season;
@ -432,6 +462,7 @@ export default function DraftRoom() {
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
});
// Persist sidebar collapsed state
@ -1180,18 +1211,18 @@ export default function DraftRoom() {
<div className="h-dvh bg-background flex flex-col overflow-hidden">
{/* Draft Completion Banner */}
{isDraftComplete && (
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0">
🎉 Draft Complete! The season is now active.
{season.league.isPublicDraftBoard && (
<>
{" "}
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0 flex items-center justify-center gap-2 flex-wrap">
<span>Draft Complete! The season is now active.</span>
<a
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
className="underline hover:text-emerald-300"
href={draftBoardUrl}
className="inline-flex items-center gap-1 bg-emerald-500/30 hover:bg-emerald-500/40 text-emerald-300 px-3 py-1 rounded-md text-sm transition-colors"
>
View Draft Board
</a>
</>
{roomClosureCountdown !== null && roomClosureCountdown > 0 && (
<span className="text-sm text-emerald-500">
Room closes in {Math.floor(roomClosureCountdown / 60)}:{String(roomClosureCountdown % 60).padStart(2, "0")}
</span>
)}
</div>
)}

View file

@ -8,6 +8,7 @@ const ctx = {} as unknown as RouterContextProvider;
vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(),
scheduleDraftRoomClosure: vi.fn(),
}));
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },

View file

@ -25,6 +25,7 @@ const createMockSeason = (overrides: {
draftTimerMode: 'chess_clock' as const,
currentPickNumber: null,
draftStartedAt: null,
draftCompletedAt: null,
draftPaused: false,
overnightPauseMode: 'none' as const,
overnightPauseStart: null,

View file

@ -55,6 +55,7 @@ const mockSeason = {
draftTimerMode: 'chess_clock' as const,
currentPickNumber: null,
draftStartedAt: null,
draftCompletedAt: null,
draftPaused: false,
overnightPauseMode: 'none' as const,
overnightPauseStart: null,

View file

@ -194,6 +194,7 @@ export const seasons = pgTable("seasons", {
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),
currentPickNumber: integer("current_pick_number").default(1),
draftStartedAt: timestamp("draft_started_at"),
draftCompletedAt: timestamp("draft_completed_at"),
draftPaused: boolean("draft_paused").notNull().default(false),
overnightPauseMode: overnightPauseModeEnum("overnight_pause_mode").notNull().default("none"),
overnightPauseStart: varchar("overnight_pause_start", { length: 5 }), // "23:00"

View file

@ -0,0 +1 @@
ALTER TABLE "seasons" ADD COLUMN "draft_completed_at" timestamp;

File diff suppressed because it is too large Load diff

View file

@ -603,6 +603,13 @@
"when": 1777483407743,
"tag": "0085_young_cobalt_man",
"breakpoints": true
},
{
"idx": 86,
"version": "7",
"when": 1777505810235,
"tag": "0086_marvelous_infant_terrible",
"breakpoints": true
}
]
}

View file

@ -17,6 +17,7 @@ interface ServerToClientEvents {
"pick-made": (data: unknown) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"draft-room-closed": () => void;
"timer-update": (data: {
seasonId: string;
teamId: string;
@ -80,11 +81,28 @@ declare global {
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
function scheduleDraftRoomClosure(seasonId: string) {
if (draftRoomClosureTimers.has(seasonId)) return;
const timeout = setTimeout(() => {
logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`);
io?.to(`draft-${seasonId}`).emit("draft-room-closed");
draftRoomClosureTimers.delete(seasonId);
}, ROOM_CLOSURE_DELAY_MS);
draftRoomClosureTimers.set(seasonId, timeout);
}
export { scheduleDraftRoomClosure };
// Track connected teams per season (in-memory, single-instance only).
// If the server restarts or runs as multiple instances this map resets.
// For multi-instance deployments, replace with a Redis-backed Socket.IO adapter.
const connectedTeams = new Map<string, Set<string>>(); // seasonId -> Set<teamId>
const ROOM_CLOSURE_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const draftRoomClosureTimers = new Map<string, NodeJS.Timeout>(); // seasonId -> timeout
/**
* Initialize Socket.IO server
*/
@ -124,6 +142,35 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
logger.error("No seasonId provided for join-draft");
return;
}
try {
const seasonData = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
columns: { id: true, status: true, draftCompletedAt: true },
});
if (seasonData?.draftCompletedAt) {
const elapsed = Date.now() - seasonData.draftCompletedAt.getTime();
if (elapsed >= ROOM_CLOSURE_DELAY_MS) {
socket.emit("draft-room-closed");
logger.log(`[Socket] join-draft rejected for ${seasonId}: room closed (completed ${Math.round(elapsed / 60000)}m ago)`);
return;
}
if (!draftRoomClosureTimers.has(seasonId)) {
const remaining = ROOM_CLOSURE_DELAY_MS - elapsed;
const timeout = setTimeout(() => {
logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`);
io?.to(`draft-${seasonId}`).emit("draft-room-closed");
draftRoomClosureTimers.delete(seasonId);
}, remaining);
draftRoomClosureTimers.set(seasonId, timeout);
}
}
} catch (err) {
logger.error("[Socket] join-draft room-closure check failed:", err);
}
socket.join(`draft-${seasonId}`);
currentSeasonId = seasonId;
logger.log(`Socket ${socket.id} joined draft-${seasonId}`);