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:
parent
ef0ddeff39
commit
437ac2ce24
19 changed files with 5348 additions and 16 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { executeAutoPick } from "../draft-utils";
|
|||
|
||||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
scheduleDraftRoomClosure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/draft-pick", () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
1
drizzle/0086_marvelous_infant_terrible.sql
Normal file
1
drizzle/0086_marvelous_infant_terrible.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "seasons" ADD COLUMN "draft_completed_at" timestamp;
|
||||
5221
drizzle/meta/0086_snapshot.json
Normal file
5221
drizzle/meta/0086_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue