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 [isOvernightPause, setIsOvernightPause] = useState(false);
|
||||||
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
|
const [overnightResumesAt, setOvernightResumesAt] = useState<Date | null>(null);
|
||||||
|
const [roomClosed, setRoomClosed] = useState(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
picks, setPicks,
|
picks, setPicks,
|
||||||
|
|
@ -175,5 +176,6 @@ export function useDraftRoomState({
|
||||||
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
||||||
isOvernightPause, setIsOvernightPause,
|
isOvernightPause, setIsOvernightPause,
|
||||||
overnightResumesAt, setOvernightResumesAt,
|
overnightResumesAt, setOvernightResumesAt,
|
||||||
|
roomClosed, setRoomClosed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ interface UseDraftSocketEventsParams {
|
||||||
setIsOvernightPause: (value: boolean) => void;
|
setIsOvernightPause: (value: boolean) => void;
|
||||||
setOvernightResumesAt: (value: Date | null) => void;
|
setOvernightResumesAt: (value: Date | null) => void;
|
||||||
setWatchedParticipantIds: (value: Set<string>) => void;
|
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||||
|
setRoomClosed: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDraftSocketEvents({
|
export function useDraftSocketEvents({
|
||||||
|
|
@ -66,6 +67,7 @@ export function useDraftSocketEvents({
|
||||||
setIsOvernightPause,
|
setIsOvernightPause,
|
||||||
setOvernightResumesAt,
|
setOvernightResumesAt,
|
||||||
setWatchedParticipantIds,
|
setWatchedParticipantIds,
|
||||||
|
setRoomClosed,
|
||||||
}: UseDraftSocketEventsParams) {
|
}: UseDraftSocketEventsParams) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
type PickMadePayload = {
|
type PickMadePayload = {
|
||||||
|
|
@ -229,6 +231,10 @@ export function useDraftSocketEvents({
|
||||||
setWatchedParticipantIds(new Set(data.participantIds));
|
setWatchedParticipantIds(new Set(data.participantIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDraftRoomClosed = () => {
|
||||||
|
setRoomClosed(true);
|
||||||
|
};
|
||||||
|
|
||||||
on("pick-made", handlePickMade as (data: unknown) => void);
|
on("pick-made", handlePickMade as (data: unknown) => void);
|
||||||
on("timer-update", handleTimerUpdate as (data: unknown) => void);
|
on("timer-update", handleTimerUpdate as (data: unknown) => void);
|
||||||
on("draft-paused", handleDraftPaused 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-rolled-back", handleDraftRolledBack as (data: unknown) => void);
|
||||||
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||||
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
|
||||||
|
on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
off("pick-made", handlePickMade as (data: unknown) => void);
|
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-rolled-back", handleDraftRolledBack as (data: unknown) => void);
|
||||||
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
|
||||||
off("watchlist-updated", handleWatchlistUpdated 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [on, off, socketVersion]);
|
}, [on, off, socketVersion]);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { executeAutoPick } from "../draft-utils";
|
||||||
|
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/models/draft-pick", () => ({
|
vi.mock("~/models/draft-pick", () => ({
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo
|
||||||
import { getParticipantsForSeasonWithSports } from "./participant";
|
import { getParticipantsForSeasonWithSports } from "./participant";
|
||||||
import { getSeasonSportsSimple } from "./season-sport";
|
import { getSeasonSportsSimple } from "./season-sport";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
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
|
* Check if the next team has autodraft enabled and immediately execute their pick
|
||||||
|
|
@ -719,6 +719,7 @@ export async function executeAutoPick(params: {
|
||||||
.set({
|
.set({
|
||||||
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
||||||
status: isDraftComplete ? "active" : season.status,
|
status: isDraftComplete ? "active" : season.status,
|
||||||
|
draftCompletedAt: isDraftComplete ? new Date() : undefined,
|
||||||
})
|
})
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
.where(eq(schema.seasons.id, seasonId));
|
||||||
|
|
||||||
|
|
@ -804,6 +805,7 @@ export async function executeAutoPick(params: {
|
||||||
// Emit draft-completed event if applicable
|
// Emit draft-completed event if applicable
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
|
scheduleDraftRoomClosure(seasonId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[AutoPick] Socket.IO events error:", 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("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
auth: { api: { getSession: vi.fn() } },
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const ctx = {} as unknown as RouterContextProvider;
|
||||||
vi.mock("~/database/context");
|
vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
auth: { api: { getSession: vi.fn() } },
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const ctx = {} as unknown as RouterContextProvider;
|
||||||
vi.mock("~/database/context");
|
vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
auth: { api: { getSession: vi.fn() } },
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const ctx = {} as unknown as RouterContextProvider;
|
||||||
vi.mock("~/database/context");
|
vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
auth: { api: { getSession: vi.fn() } },
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||||
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
|
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
|
|
@ -211,6 +211,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
.set({
|
.set({
|
||||||
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
||||||
status: isDraftComplete ? "active" : season.status,
|
status: isDraftComplete ? "active" : season.status,
|
||||||
|
draftCompletedAt: isDraftComplete ? new Date() : undefined,
|
||||||
})
|
})
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
.where(eq(schema.seasons.id, seasonId));
|
||||||
|
|
||||||
|
|
@ -254,6 +255,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
|
scheduleDraftRoomClosure(seasonId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Socket.IO error:", error);
|
logger.error("Socket.IO error:", error);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d
|
||||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||||
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
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 { logger } from "~/lib/logger";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
@ -272,6 +272,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
.set({
|
.set({
|
||||||
currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
||||||
status: isDraftComplete ? "active" : season.status,
|
status: isDraftComplete ? "active" : season.status,
|
||||||
|
draftCompletedAt: isDraftComplete ? new Date() : undefined,
|
||||||
})
|
})
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
.where(eq(schema.seasons.id, seasonId));
|
||||||
|
|
||||||
|
|
@ -293,6 +294,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
|
scheduleDraftRoomClosure(seasonId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Socket.IO error:", 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 { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 });
|
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
|
const userTeam = userId
|
||||||
? season.teams.find((team) => team.ownerId === userId)
|
? season.teams.find((team) => team.ownerId === userId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -184,6 +191,7 @@ export default function DraftRoom() {
|
||||||
ownerMap,
|
ownerMap,
|
||||||
teamTimezoneMap,
|
teamTimezoneMap,
|
||||||
} = useLoaderData<typeof loader>();
|
} = useLoaderData<typeof loader>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||||
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||||
const {
|
const {
|
||||||
|
|
@ -245,6 +253,7 @@ export default function DraftRoom() {
|
||||||
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
||||||
isOvernightPause, setIsOvernightPause,
|
isOvernightPause, setIsOvernightPause,
|
||||||
overnightResumesAt, setOvernightResumesAt,
|
overnightResumesAt, setOvernightResumesAt,
|
||||||
|
roomClosed, setRoomClosed,
|
||||||
} = useDraftRoomState({
|
} = useDraftRoomState({
|
||||||
draftPicks,
|
draftPicks,
|
||||||
season,
|
season,
|
||||||
|
|
@ -264,6 +273,27 @@ export default function DraftRoom() {
|
||||||
return () => clearInterval(id);
|
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.
|
// Compute which teams are currently in their overnight pause window.
|
||||||
const pausedTeamIds = useMemo(() => {
|
const pausedTeamIds = useMemo(() => {
|
||||||
const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season;
|
const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season;
|
||||||
|
|
@ -432,6 +462,7 @@ export default function DraftRoom() {
|
||||||
setIsOvernightPause,
|
setIsOvernightPause,
|
||||||
setOvernightResumesAt,
|
setOvernightResumesAt,
|
||||||
setWatchedParticipantIds,
|
setWatchedParticipantIds,
|
||||||
|
setRoomClosed,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist sidebar collapsed state
|
// Persist sidebar collapsed state
|
||||||
|
|
@ -1180,18 +1211,18 @@ export default function DraftRoom() {
|
||||||
<div className="h-dvh bg-background flex flex-col overflow-hidden">
|
<div className="h-dvh bg-background flex flex-col overflow-hidden">
|
||||||
{/* Draft Completion Banner */}
|
{/* Draft Completion Banner */}
|
||||||
{isDraftComplete && (
|
{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">
|
<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">
|
||||||
🎉 Draft Complete! The season is now active.
|
<span>Draft Complete! The season is now active.</span>
|
||||||
{season.league.isPublicDraftBoard && (
|
<a
|
||||||
<>
|
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"
|
||||||
<a
|
>
|
||||||
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
|
View Draft Board
|
||||||
className="underline hover:text-emerald-300"
|
</a>
|
||||||
>
|
{roomClosureCountdown !== null && roomClosureCountdown > 0 && (
|
||||||
View Draft Board
|
<span className="text-sm text-emerald-500">
|
||||||
</a>
|
Room closes in {Math.floor(roomClosureCountdown / 60)}:{String(roomClosureCountdown % 60).padStart(2, "0")}
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const ctx = {} as unknown as RouterContextProvider;
|
||||||
vi.mock("~/database/context");
|
vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
|
scheduleDraftRoomClosure: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/auth.server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
auth: { api: { getSession: vi.fn() } },
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const createMockSeason = (overrides: {
|
||||||
draftTimerMode: 'chess_clock' as const,
|
draftTimerMode: 'chess_clock' as const,
|
||||||
currentPickNumber: null,
|
currentPickNumber: null,
|
||||||
draftStartedAt: null,
|
draftStartedAt: null,
|
||||||
|
draftCompletedAt: null,
|
||||||
draftPaused: false,
|
draftPaused: false,
|
||||||
overnightPauseMode: 'none' as const,
|
overnightPauseMode: 'none' as const,
|
||||||
overnightPauseStart: null,
|
overnightPauseStart: null,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const mockSeason = {
|
||||||
draftTimerMode: 'chess_clock' as const,
|
draftTimerMode: 'chess_clock' as const,
|
||||||
currentPickNumber: null,
|
currentPickNumber: null,
|
||||||
draftStartedAt: null,
|
draftStartedAt: null,
|
||||||
|
draftCompletedAt: null,
|
||||||
draftPaused: false,
|
draftPaused: false,
|
||||||
overnightPauseMode: 'none' as const,
|
overnightPauseMode: 'none' as const,
|
||||||
overnightPauseStart: null,
|
overnightPauseStart: null,
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@ export const seasons = pgTable("seasons", {
|
||||||
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),
|
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),
|
||||||
currentPickNumber: integer("current_pick_number").default(1),
|
currentPickNumber: integer("current_pick_number").default(1),
|
||||||
draftStartedAt: timestamp("draft_started_at"),
|
draftStartedAt: timestamp("draft_started_at"),
|
||||||
|
draftCompletedAt: timestamp("draft_completed_at"),
|
||||||
draftPaused: boolean("draft_paused").notNull().default(false),
|
draftPaused: boolean("draft_paused").notNull().default(false),
|
||||||
overnightPauseMode: overnightPauseModeEnum("overnight_pause_mode").notNull().default("none"),
|
overnightPauseMode: overnightPauseModeEnum("overnight_pause_mode").notNull().default("none"),
|
||||||
overnightPauseStart: varchar("overnight_pause_start", { length: 5 }), // "23:00"
|
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,
|
"when": 1777483407743,
|
||||||
"tag": "0085_young_cobalt_man",
|
"tag": "0085_young_cobalt_man",
|
||||||
"breakpoints": true
|
"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;
|
"pick-made": (data: unknown) => void;
|
||||||
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
|
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
|
||||||
"draft-completed": () => void;
|
"draft-completed": () => void;
|
||||||
|
"draft-room-closed": () => void;
|
||||||
"timer-update": (data: {
|
"timer-update": (data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
@ -80,11 +81,28 @@ declare global {
|
||||||
|
|
||||||
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
|
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).
|
// Track connected teams per season (in-memory, single-instance only).
|
||||||
// If the server restarts or runs as multiple instances this map resets.
|
// If the server restarts or runs as multiple instances this map resets.
|
||||||
// For multi-instance deployments, replace with a Redis-backed Socket.IO adapter.
|
// For multi-instance deployments, replace with a Redis-backed Socket.IO adapter.
|
||||||
const connectedTeams = new Map<string, Set<string>>(); // seasonId -> Set<teamId>
|
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
|
* Initialize Socket.IO server
|
||||||
*/
|
*/
|
||||||
|
|
@ -124,6 +142,35 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
logger.error("No seasonId provided for join-draft");
|
logger.error("No seasonId provided for join-draft");
|
||||||
return;
|
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}`);
|
socket.join(`draft-${seasonId}`);
|
||||||
currentSeasonId = seasonId;
|
currentSeasonId = seasonId;
|
||||||
logger.log(`Socket ${socket.id} joined draft-${seasonId}`);
|
logger.log(`Socket ${socket.id} joined draft-${seasonId}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue