From 41f36549567d390d13c44a7d07c62b6e6bc1c2e2 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:47:29 -0800 Subject: [PATCH] Add commissioner replace pick and draft rollback features (#18) Commissioners can now right-click any picked cell in the draft grid to replace the drafted participant (available during and after draft) or roll back the draft to that pick number (available during draft only). Replace pick enforces sport eligibility with the replaced slot treated as free, updates the pick in-place, and broadcasts a pick-replaced socket event so all clients update in real time. Rollback deletes all picks from the selected pick onward, resets the season pick number, clears all timers, and creates a fresh timer for the team now on the clock. Co-authored-by: Claude Sonnet 4.6 --- app/components/draft/DraftGridSection.tsx | 159 ++++++---- app/routes.ts | 2 + app/routes/api/draft.replace-pick.ts | 192 ++++++++++++ app/routes/api/draft.rollback.ts | 125 ++++++++ .../leagues/$leagueId.draft.$seasonId.tsx | 278 ++++++++++++++++++ 5 files changed, 691 insertions(+), 65 deletions(-) create mode 100644 app/routes/api/draft.replace-pick.ts create mode 100644 app/routes/api/draft.rollback.ts diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 0eed212..9d4fa29 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -37,6 +37,8 @@ interface DraftGridSectionProps { isCommissioner: boolean; onForceAutopick: (pickNumber: number, teamId: string) => void; onForceManualPickOpen: (pickNumber: number, teamId: string) => void; + onReplacePick?: (pickNumber: number, teamId: string) => void; + onRollbackToPick?: (pickNumber: number) => void; } export function DraftGridSection({ @@ -49,6 +51,8 @@ export function DraftGridSection({ isCommissioner, onForceAutopick, onForceManualPickOpen, + onReplacePick, + onRollbackToPick, }: DraftGridSectionProps) { const formatTime = (seconds: number | null) => { if (seconds === null) return "--:--"; @@ -120,71 +124,16 @@ export function DraftGridSection({ const isCurrent = cell.pickNumber === currentPick; const isPicked = !!cell.pick; - return isCommissioner && !isPicked && isCurrent ? ( - - -
-
- {cell.round}. - {String(cell.pickInRound).padStart(2, "0")} -
- {isPicked ? ( -
-
- {cell.pick?.participant.name} -
-
- {cell.pick?.sport.name} -
-
- ) : isCurrent ? ( -
- On Clock -
- ) : null} -
-
- - - onForceAutopick(cell.pickNumber, cell.teamId) - } - > - Force Auto Pick - - - onForceManualPickOpen( - cell.pickNumber, - cell.teamId - ) - } - > - Force Manual Pick - - -
- ) : ( -
+ const cellClassName = `flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${ + isCurrent + ? "border-electric bg-electric/15 shadow-lg shadow-electric/10" + : isPicked + ? "bg-emerald-500/10 border-emerald-500/30" + : "border-border bg-card" + }`; + + const cellInner = ( + <>
{cell.round}. {String(cell.pickInRound).padStart(2, "0")} @@ -203,6 +152,86 @@ export function DraftGridSection({ On Clock
) : null} + + ); + + // Commissioner context menu on the current unpicked cell + if (isCommissioner && !isPicked && isCurrent) { + return ( + + +
+ {cellInner} +
+
+ + + onForceAutopick(cell.pickNumber, cell.teamId) + } + > + Force Auto Pick + + + onForceManualPickOpen( + cell.pickNumber, + cell.teamId + ) + } + > + Force Manual Pick + + +
+ ); + } + + // Commissioner context menu on already-picked cells + if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) { + return ( + + +
+ {cellInner} +
+
+ + {onReplacePick && ( + + onReplacePick(cell.pickNumber, cell.teamId) + } + > + Replace Pick + + )} + {onRollbackToPick && ( + onRollbackToPick(cell.pickNumber)} + className="text-destructive focus:text-destructive" + > + Roll Back to This Pick + + )} + +
+ ); + } + + return ( +
+ {cellInner}
); })} diff --git a/app/routes.ts b/app/routes.ts index d580fa5..95d7b64 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -38,6 +38,8 @@ export default [ route("api/draft/resume", "routes/api/draft.resume.ts"), route("api/draft/force-autopick", "routes/api/draft.force-autopick.ts"), route("api/draft/force-manual-pick", "routes/api/draft.force-manual-pick.ts"), + route("api/draft/replace-pick", "routes/api/draft.replace-pick.ts"), + route("api/draft/rollback", "routes/api/draft.rollback.ts"), route("api/autodraft/update", "routes/api/autodraft.update.ts"), route("user-profile", "routes/user-profile.tsx"), route("how-to-play", "routes/how-to-play.tsx"), diff --git a/app/routes/api/draft.replace-pick.ts b/app/routes/api/draft.replace-pick.ts new file mode 100644 index 0000000..cf47755 --- /dev/null +++ b/app/routes/api/draft.replace-pick.ts @@ -0,0 +1,192 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and } from "drizzle-orm"; +import { calculateDraftEligibility } from "~/lib/draft-eligibility"; +import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; +import { getParticipantsForSeasonWithSports } from "~/models/participant"; +import { getSeasonSportsSimple } from "~/models/season-sport"; +import { getSocketIO } from "../../../server/socket"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + const participantId = formData.get("participantId") as string; + const pickNumber = parseInt(formData.get("pickNumber") as string); + + if (!seasonId || !participantId || isNaN(pickNumber) || pickNumber < 1) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const db = database(); + + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + const isCommissioner = await db.query.commissioners.findFirst({ + where: and( + eq(schema.commissioners.leagueId, season.leagueId), + eq(schema.commissioners.userId, userId) + ), + }); + + if (!isCommissioner) { + return Response.json({ error: "Only commissioners can replace picks" }, { status: 403 }); + } + + if (season.status === "pre_draft") { + return Response.json({ error: "Draft has not started" }, { status: 400 }); + } + + // Get the existing pick at this slot + const existingPick = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.pickNumber, pickNumber) + ), + }); + + if (!existingPick) { + return Response.json({ error: "No pick found at this slot" }, { status: 404 }); + } + + // Use the team from the DB record — don't trust the client-supplied teamId + const teamId = existingPick.teamId; + const oldParticipantId = existingPick.participantId; + + // Check new participant isn't already drafted elsewhere (skip if same participant) + if (participantId !== oldParticipantId) { + const alreadyDrafted = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.participantId, participantId) + ), + }); + + if (alreadyDrafted) { + return Response.json({ error: "Participant already drafted" }, { status: 400 }); + } + } + + const participant = await db.query.participants.findFirst({ + where: eq(schema.participants.id, participantId), + with: { + sportsSeason: { with: { sport: true } }, + }, + }); + + if (!participant) { + return Response.json({ error: "Participant not found" }, { status: 404 }); + } + + // Eligibility check — exclude the pick being replaced so its slot is treated as free + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: schema.draftSlots.draftOrder, + with: { team: true }, + }); + + const allPicksWithSports = await getDraftPicksWithSports(seasonId); + const teamPicksWithSports = await getTeamDraftPicksWithSports(teamId, seasonId); + const allParticipants = await getParticipantsForSeasonWithSports(seasonId); + const seasonSports = await getSeasonSportsSimple(seasonId); + + // Exclude old participant so that slot is "open" for eligibility purposes + const allPicksExcluding = allPicksWithSports.filter( + (p) => p.participant.id !== oldParticipantId + ); + const teamPicksExcluding = teamPicksWithSports.filter( + (p) => p.participant.id !== oldParticipantId + ); + + const allTeams = draftSlots.map((slot) => ({ id: slot.teamId })); + + const eligibility = calculateDraftEligibility( + teamId, + teamPicksExcluding, + allPicksExcluding, + allParticipants, + seasonSports, + season.draftRounds, + allTeams + ); + + const sportId = participant.sportsSeason.sport.id; + if (!eligibility.eligibleSportIds.has(sportId)) { + const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport"; + return Response.json({ error: reason }, { status: 400 }); + } + + // Update the pick in-place + const [updatedPick] = await db + .update(schema.draftPicks) + .set({ + participantId, + pickedByUserId: userId, + pickedByType: "commissioner", + }) + .where( + and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.pickNumber, pickNumber) + ) + ) + .returning(); + + // Remove new participant from all team queues + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.participantId, participantId) + ) + ); + + // Emit socket events + try { + const io = getSocketIO(); + const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; + + io.to(`draft-${seasonId}`).emit("pick-replaced", { + seasonId, + pickNumber, + oldParticipantId, + pick: { + id: updatedPick.id, + pickNumber: updatedPick.pickNumber, + round: updatedPick.round, + pickInRound: updatedPick.pickInRound, + timeUsed: updatedPick.timeUsed, + team, + participant: { + id: participant.id, + name: participant.name, + sport: participant.sportsSeason.sport, + }, + sport: participant.sportsSeason.sport, + }, + }); + + io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", { + participantId, + }); + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ success: true }); +} diff --git a/app/routes/api/draft.rollback.ts b/app/routes/api/draft.rollback.ts new file mode 100644 index 0000000..1d7e5d9 --- /dev/null +++ b/app/routes/api/draft.rollback.ts @@ -0,0 +1,125 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, gte } from "drizzle-orm"; +import { getSocketIO } from "../../../server/socket"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + const pickNumber = parseInt(formData.get("pickNumber") as string); + + if (!seasonId || isNaN(pickNumber) || pickNumber < 1) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const db = database(); + + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + const isCommissioner = await db.query.commissioners.findFirst({ + where: and( + eq(schema.commissioners.leagueId, season.leagueId), + eq(schema.commissioners.userId, userId) + ), + }); + + if (!isCommissioner) { + return Response.json({ error: "Only commissioners can roll back the draft" }, { status: 403 }); + } + + if (season.status !== "draft") { + return Response.json( + { error: "Cannot roll back a draft that is not in progress" }, + { status: 400 } + ); + } + + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: schema.draftSlots.draftOrder, + }); + + const totalTeams = draftSlots.length; + + // Calculate which team is on the clock at the rollback pick (snake draft) + const round = Math.ceil(pickNumber / totalTeams); + const isEvenRound = round % 2 === 0; + let pickInRound = ((pickNumber - 1) % totalTeams) + 1; + if (isEvenRound) { + pickInRound = totalTeams - pickInRound + 1; + } + const rollbackSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound); + + // Delete picks from pickNumber onwards + await db + .delete(schema.draftPicks) + .where( + and( + eq(schema.draftPicks.seasonId, seasonId), + gte(schema.draftPicks.pickNumber, pickNumber) + ) + ); + + // Clear all timers and create a fresh one for the team now on the clock + await db + .delete(schema.draftTimers) + .where(eq(schema.draftTimers.seasonId, seasonId)); + + const initialTime = season.draftInitialTime || 120; + + if (rollbackSlot) { + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: rollbackSlot.teamId, + timeRemaining: initialTime, + }); + } + + // Update season: reset pick number and unpause + await db + .update(schema.seasons) + .set({ + currentPickNumber: pickNumber, + draftPaused: false, + }) + .where(eq(schema.seasons.id, seasonId)); + + // Emit socket events + try { + const io = getSocketIO(); + + io.to(`draft-${seasonId}`).emit("draft-rolled-back", { + seasonId, + pickNumber, + teamId: rollbackSlot?.teamId, + }); + + if (rollbackSlot) { + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId: rollbackSlot.teamId, + timeRemaining: initialTime, + currentPickNumber: pickNumber, + }); + } + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ success: true, pickNumber }); +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 3098151..7435d69 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -282,6 +282,19 @@ export default function DraftRoom() { const [dialogSearchQuery, setDialogSearchQuery] = useState(""); const [dialogSportFilter, setDialogSportFilter] = useState("all"); + // Replace pick dialog state + const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false); + const [replacePickSlot, setReplacePickSlot] = useState<{ + pickNumber: number; + teamId: string; + oldParticipantId: string; + } | null>(null); + + // Rollback confirm dialog state + const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false); + const [rollbackPickNumber, setRollbackPickNumber] = useState(null); + const [isRollingBack, setIsRollingBack] = useState(false); + // Shared transforms for eligibility calculations const transformedPicks = useMemo( () => @@ -350,6 +363,26 @@ export default function DraftRoom() { ); }, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); + // Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free + const replacePickEligibility = useMemo(() => { + if (!replacePickSlot) return null; + const allPicksExcluding = transformedPicks.filter( + (p: any) => p.participant.id !== replacePickSlot.oldParticipantId + ); + const selectedTeamPicks = allPicksExcluding.filter( + (p: any) => p.teamId === replacePickSlot.teamId + ); + return calculateDraftEligibility( + replacePickSlot.teamId, + selectedTeamPicks, + allPicksExcluding, + transformedParticipants, + seasonSportsData, + season.draftRounds, + season.teams + ); + }, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); + // Listen for new picks from other users useEffect(() => { const handlePickMade = (data: any) => { @@ -438,6 +471,21 @@ export default function DraftRoom() { ); }; + const handlePickReplaced = (data: any) => { + setPicks((prev: any) => + prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p)) + ); + }; + + const handleDraftRolledBack = (data: any) => { + setPicks((prev: any) => + prev.filter((p: any) => p.pickNumber < data.pickNumber) + ); + setCurrentPick(data.pickNumber); + setIsDraftComplete(false); + setIsPaused(false); + }; + on("pick-made", handlePickMade); on("timer-update", handleTimerUpdate); on("draft-paused", handleDraftPaused); @@ -448,6 +496,8 @@ export default function DraftRoom() { on("team-disconnected", handleTeamDisconnected); on("connected-teams-list", handleConnectedTeamsList); on("participant-removed-from-queues", handleParticipantRemovedFromQueues); + on("pick-replaced", handlePickReplaced); + on("draft-rolled-back", handleDraftRolledBack); return () => { off("pick-made", handlePickMade); @@ -460,6 +510,8 @@ export default function DraftRoom() { off("team-disconnected", handleTeamDisconnected); off("connected-teams-list", handleConnectedTeamsList); off("participant-removed-from-queues", handleParticipantRemovedFromQueues); + off("pick-replaced", handlePickReplaced); + off("draft-rolled-back", handleDraftRolledBack); }; }, [on, off, currentPick, userTeam, draftSlots, season.league.name]); @@ -694,6 +746,69 @@ export default function DraftRoom() { } }; + const handleReplacePickOpen = (pickNumber: number, teamId: string) => { + const pick = picks.find((p: any) => p.pickNumber === pickNumber); + if (!pick) { + toast.error("Pick not found — try refreshing the page"); + return; + } + setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id }); + setDialogSearchQuery(""); + setDialogSportFilter("all"); + setReplacePickDialogOpen(true); + }; + + const handleReplacePick = async (participantId: string) => { + if (!replacePickSlot) return; + + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", replacePickSlot.teamId); + formData.append("participantId", participantId); + formData.append("pickNumber", replacePickSlot.pickNumber.toString()); + + const response = await fetch("/api/draft/replace-pick", { + method: "POST", + body: formData, + }); + + if (response.ok) { + setReplacePickDialogOpen(false); + setReplacePickSlot(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to replace pick"); + } + }; + + const handleRollbackToPick = (pickNumber: number) => { + setRollbackPickNumber(pickNumber); + setRollbackConfirmOpen(true); + }; + + const handleConfirmRollback = async () => { + if (!rollbackPickNumber || isRollingBack) return; + + setIsRollingBack(true); + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("pickNumber", rollbackPickNumber.toString()); + + const response = await fetch("/api/draft/rollback", { + method: "POST", + body: formData, + }); + + setIsRollingBack(false); + if (response.ok) { + setRollbackConfirmOpen(false); + setRollbackPickNumber(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to roll back draft"); + } + }; + // Calculate current round const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1; @@ -770,6 +885,24 @@ export default function DraftRoom() { [availableParticipants, draftedParticipantIds, dialogSearchQuery, dialogSportFilter] ); + // Participants for the replace pick dialog — excludes drafted except the old pick (which is freed) + const replaceDialogFilteredParticipants = useMemo(() => { + if (!replacePickSlot) return []; + return availableParticipants.filter((p: any) => { + // Exclude drafted players, but allow the old participant through since they'll be freed + if (p.id !== replacePickSlot.oldParticipantId && draftedParticipantIds.has(p.id)) + return false; + if ( + dialogSearchQuery && + !p.name.toLowerCase().includes(dialogSearchQuery.toLowerCase()) + ) + return false; + if (dialogSportFilter !== "all" && p.sport.name !== dialogSportFilter) + return false; + return true; + }); + }, [availableParticipants, replacePickSlot, draftedParticipantIds, dialogSearchQuery, dialogSportFilter]); + // Filter participants based on search, sport, drafted status, and eligibility const filteredParticipants = useMemo( () => @@ -997,6 +1130,8 @@ export default function DraftRoom() { setDialogSportFilter("all"); setForcePickDialogOpen(true); }} + onReplacePick={isCommissioner ? handleReplacePickOpen : undefined} + onRollbackToPick={isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined} /> @@ -1124,6 +1259,149 @@ export default function DraftRoom() { + {/* Replace Pick Dialog */} + { + setReplacePickDialogOpen(open); + if (!open) setReplacePickSlot(null); + }} + > + + + Replace Pick + + Select a participant to replace the current pick at slot #{replacePickSlot?.pickNumber} + + + +
+
+ setDialogSearchQuery(e.target.value)} + /> + +
+ +
+ + + + + + + + + + {replaceDialogFilteredParticipants.map((participant: any) => { + const isCurrentPick = participant.id === replacePickSlot?.oldParticipantId; + const isEligible = replacePickEligibility + ? replacePickEligibility.eligibleSportIds.has(participant.sport.id) + : true; + const ineligibleReason = replacePickEligibility?.ineligibleReasons[participant.sport.id]; + + return ( + + + + + + ); + })} + +
ParticipantSportAction
+
+ + {participant.name} + + {isCurrentPick && ( + + Current + + )} + {!isEligible && ( + + Ineligible + + )} +
+
+ {participant.sport.name} + + +
+
+
+ + + + +
+
+ + {/* Rollback Confirmation Dialog */} + { + setRollbackConfirmOpen(open); + if (!open) setRollbackPickNumber(null); + }} + > + + + Roll Back Draft + + {rollbackPickNumber !== null && (() => { + const count = picks.filter((p: any) => p.pickNumber >= rollbackPickNumber).length; + return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`; + })()} + + + + + + + + + {/* Connection Overlay - blocks interaction until socket connects */}