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 */}