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 */}
+
+
+ {/* Rollback Confirmation Dialog */}
+
+
{/* Connection Overlay - blocks interaction until socket connects */}