diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index bfc69d3..13bb2fb 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -37,6 +37,7 @@ interface DraftGridSectionProps { autodraftStatus: Record; connectedTeams: Set; isCommissioner: boolean; + onAdjustTimeBankOpen?: (teamId: string) => void; onForceAutopick: (pickNumber: number, teamId: string) => void; onForceManualPickOpen: (pickNumber: number, teamId: string) => void; onReplacePick?: (pickNumber: number, teamId: string) => void; @@ -51,6 +52,7 @@ export function DraftGridSection({ autodraftStatus, connectedTeams, isCommissioner, + onAdjustTimeBankOpen, onForceAutopick, onForceManualPickOpen, onReplacePick, @@ -73,8 +75,8 @@ export function DraftGridSection({ const isAutodraft = autodraftStatus[slot.team.id] || false; const isConnected = connectedTeams.has(slot.team.id); - return ( -
+ const headerInner = ( + <>
)}
+ + ); + + if (isCommissioner && onAdjustTimeBankOpen) { + return ( + + +
+ {headerInner} +
+
+ + onAdjustTimeBankOpen(slot.team.id)} + > + Adjust Time Bank... + + +
+ ); + } + + return ( +
+ {headerInner}
); })} diff --git a/app/routes.ts b/app/routes.ts index 82cf73a..081252e 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -40,6 +40,7 @@ export default [ 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/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.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.adjust-time-bank.ts b/app/routes/api/draft.adjust-time-bank.ts new file mode 100644 index 0000000..9109fbd --- /dev/null +++ b/app/routes/api/draft.adjust-time-bank.ts @@ -0,0 +1,89 @@ +import { getAuth } from "@clerk/react-router/server"; +import { eq, and } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +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 teamId = formData.get("teamId") as string; + const adjustmentRaw = formData.get("adjustment"); + const adjustment = adjustmentRaw !== null ? parseInt(adjustmentRaw as string, 10) : NaN; + + if (!seasonId || !teamId || isNaN(adjustment)) { + return Response.json({ error: "seasonId, teamId, and adjustment are required" }, { 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 }); + } + + if (season.status !== "draft") { + return Response.json({ error: "Draft is not currently active" }, { status: 409 }); + } + + 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 adjust time banks" }, + { status: 403 } + ); + } + + const [currentTimer] = await db + .select() + .from(schema.draftTimers) + .where( + and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, teamId) + ) + ); + + if (!currentTimer) { + return Response.json({ error: "Timer not found for this team" }, { status: 404 }); + } + + const newTime = Math.max(0, currentTimer.timeRemaining + adjustment); + + await db + .update(schema.draftTimers) + .set({ timeRemaining: newTime, updatedAt: new Date() }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + + try { + getSocketIO() + .to(`draft-${seasonId}`) + .emit("timer-update", { + seasonId, + teamId, + timeRemaining: newTime, + currentPickNumber: season.currentPickNumber ?? 1, + }); + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ success: true, timeRemaining: newTime }); +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 96a81fe..1c75430 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -296,6 +296,14 @@ export default function DraftRoom() { const [rollbackPickNumber, setRollbackPickNumber] = useState(null); const [isRollingBack, setIsRollingBack] = useState(false); + // Time bank adjustment dialog state + const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); + const [timeBankTeamId, setTimeBankTeamId] = useState(null); + const [timeBankAmount, setTimeBankAmount] = useState("1"); + const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes"); + const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add"); + const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false); + // Shared transforms for eligibility calculations const transformedPicks = useMemo( () => @@ -810,6 +818,63 @@ export default function DraftRoom() { } }; + const handleAdjustTimeBankOpen = (teamId: string) => { + setTimeBankTeamId(teamId); + setTimeBankAmount("1"); + setTimeBankUnit("minutes"); + setTimeBankDirection("add"); + setTimeBankDialogOpen(true); + }; + + const handleConfirmAdjustTimeBank = async () => { + if (!timeBankTeamId || isAdjustingTimeBank) return; + + const amount = parseFloat(timeBankAmount); + if (isNaN(amount) || amount <= 0) { + toast.error("Please enter a valid positive amount"); + return; + } + + const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 }; + const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]); + + if (totalSeconds === 0) { + toast.error("Adjustment rounds to 0 seconds — please enter a larger value"); + return; + } + + const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds; + + setIsAdjustingTimeBank(true); + try { + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", timeBankTeamId); + formData.append("adjustment", adjustment.toString()); + + const response = await fetch("/api/draft/adjust-time-bank", { + method: "POST", + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining })); + const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name; + toast.success(`Time bank adjusted for ${teamName}`); + setTimeBankDialogOpen(false); + setTimeBankTeamId(null); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to adjust time bank"); + } + } catch { + toast.error("Network error — failed to adjust time bank"); + } finally { + setIsAdjustingTimeBank(false); + } + }; + // Calculate current round const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1; @@ -1159,6 +1224,7 @@ export default function DraftRoom() { autodraftStatus={autodraftStatus} connectedTeams={connectedTeams} isCommissioner={isCommissioner} + onAdjustTimeBankOpen={isCommissioner ? handleAdjustTimeBankOpen : undefined} onForceAutopick={handleForceAutopick} onForceManualPickOpen={(pickNumber, teamId) => { setSelectedPickSlot({ pickNumber, teamId }); @@ -1438,6 +1504,93 @@ export default function DraftRoom() { + {/* Time Bank Adjustment Dialog */} + { + setTimeBankDialogOpen(open); + if (!open) setTimeBankTeamId(null); + }} + > + + + Adjust Time Bank + + {timeBankTeamId + ? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}` + : "Adjust a team's time bank"} + + + +
+
+ + +
+ +
+ setTimeBankAmount(e.target.value)} + className="flex-1 px-3 py-2 border rounded-md bg-background text-sm" + placeholder="Amount" + /> + +
+
+ + + + + +
+
+ {/* Connection Overlay - blocks interaction until socket connects */}