Add commissioner time bank adjustment via right-click on draft board team headers

Commissioners can right-click any team header on the Draft Board tab to open
an "Adjust Time Bank..." dialog. The dialog supports adding or removing an
arbitrary amount of time in seconds, minutes, or hours. The change is applied
immediately to the database and broadcast to all clients via the existing
timer-update Socket.IO event so every participant sees the updated clock
in real time.

- New API route: POST /api/draft/adjust-time-bank (commissioner-only)
- DraftGridSection: wraps team headers in a ContextMenu for commissioners
- Draft room: dialog state, handler, and updated DraftGridSection props

https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL
This commit is contained in:
Claude 2026-02-22 18:46:23 +00:00
parent 8a444a51a1
commit 554a24eafb
No known key found for this signature in database
4 changed files with 258 additions and 2 deletions

View file

@ -37,6 +37,7 @@ interface DraftGridSectionProps {
autodraftStatus: Record<string, boolean>; autodraftStatus: Record<string, boolean>;
connectedTeams: Set<string>; connectedTeams: Set<string>;
isCommissioner: boolean; isCommissioner: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onForceAutopick: (pickNumber: number, teamId: string) => void; onForceAutopick: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen: (pickNumber: number, teamId: string) => void; onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void; onReplacePick?: (pickNumber: number, teamId: string) => void;
@ -51,6 +52,7 @@ export function DraftGridSection({
autodraftStatus, autodraftStatus,
connectedTeams, connectedTeams,
isCommissioner, isCommissioner,
onAdjustTimeBankOpen,
onForceAutopick, onForceAutopick,
onForceManualPickOpen, onForceManualPickOpen,
onReplacePick, onReplacePick,
@ -73,8 +75,8 @@ export function DraftGridSection({
const isAutodraft = autodraftStatus[slot.team.id] || false; const isAutodraft = autodraftStatus[slot.team.id] || false;
const isConnected = connectedTeams.has(slot.team.id); const isConnected = connectedTeams.has(slot.team.id);
return ( const headerInner = (
<div key={slot.id} className="flex-1 min-w-32 text-center"> <>
<div <div
className={`font-semibold text-sm truncate px-2 ${ className={`font-semibold text-sm truncate px-2 ${
slot.team.id === currentTeamId slot.team.id === currentTeamId
@ -96,6 +98,31 @@ export function DraftGridSection({
</span> </span>
)} )}
</div> </div>
</>
);
if (isCommissioner && onAdjustTimeBankOpen) {
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
<div className="flex-1 min-w-32 text-center cursor-context-menu">
{headerInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => onAdjustTimeBankOpen(slot.team.id)}
>
Adjust Time Bank...
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div key={slot.id} className="flex-1 min-w-32 text-center">
{headerInner}
</div> </div>
); );
})} })}

View file

@ -40,6 +40,7 @@ export default [
route("api/draft/force-manual-pick", "routes/api/draft.force-manual-pick.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/replace-pick", "routes/api/draft.replace-pick.ts"),
route("api/draft/rollback", "routes/api/draft.rollback.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("api/autodraft/update", "routes/api/autodraft.update.ts"),
route("user-profile", "routes/user-profile.tsx"), route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"), route("how-to-play", "routes/how-to-play.tsx"),

View file

@ -0,0 +1,85 @@
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 });
}
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 });
}

View file

@ -296,6 +296,14 @@ export default function DraftRoom() {
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null); const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
const [isRollingBack, setIsRollingBack] = useState(false); const [isRollingBack, setIsRollingBack] = useState(false);
// Time bank adjustment dialog state
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(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 // Shared transforms for eligibility calculations
const transformedPicks = useMemo( const transformedPicks = useMemo(
() => () =>
@ -810,6 +818,53 @@ 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]);
const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds;
setIsAdjustingTimeBank(true);
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,
});
setIsAdjustingTimeBank(false);
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");
}
};
// Calculate current round // Calculate current round
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1; const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
@ -1159,6 +1214,7 @@ export default function DraftRoom() {
autodraftStatus={autodraftStatus} autodraftStatus={autodraftStatus}
connectedTeams={connectedTeams} connectedTeams={connectedTeams}
isCommissioner={isCommissioner} isCommissioner={isCommissioner}
onAdjustTimeBankOpen={isCommissioner ? handleAdjustTimeBankOpen : undefined}
onForceAutopick={handleForceAutopick} onForceAutopick={handleForceAutopick}
onForceManualPickOpen={(pickNumber, teamId) => { onForceManualPickOpen={(pickNumber, teamId) => {
setSelectedPickSlot({ pickNumber, teamId }); setSelectedPickSlot({ pickNumber, teamId });
@ -1438,6 +1494,93 @@ export default function DraftRoom() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Time Bank Adjustment Dialog */}
<Dialog
open={timeBankDialogOpen}
onOpenChange={(open) => {
setTimeBankDialogOpen(open);
if (!open) setTimeBankTeamId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Adjust Time Bank</DialogTitle>
<DialogDescription>
{timeBankTeamId
? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}`
: "Adjust a team's time bank"}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => setTimeBankDirection("add")}
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
timeBankDirection === "add"
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
: "border-border text-muted-foreground hover:bg-muted"
}`}
>
Add Time
</button>
<button
type="button"
onClick={() => setTimeBankDirection("remove")}
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
timeBankDirection === "remove"
? "bg-destructive/20 border-destructive text-destructive"
: "border-border text-muted-foreground hover:bg-muted"
}`}
>
Remove Time
</button>
</div>
<div className="flex gap-2">
<input
type="number"
min="0"
step="any"
value={timeBankAmount}
onChange={(e) => setTimeBankAmount(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm"
placeholder="Amount"
/>
<select
value={timeBankUnit}
onChange={(e) =>
setTimeBankUnit(e.target.value as "seconds" | "minutes" | "hours")
}
className="px-3 py-2 border rounded-md bg-background text-sm"
>
<option value="seconds">Seconds</option>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTimeBankDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleConfirmAdjustTimeBank}
disabled={isAdjustingTimeBank}
variant={timeBankDirection === "remove" ? "destructive" : "default"}
>
{isAdjustingTimeBank
? "Adjusting..."
: timeBankDirection === "add"
? "Add Time"
: "Remove Time"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Connection Overlay - blocks interaction until socket connects */} {/* Connection Overlay - blocks interaction until socket connects */}
<ConnectionOverlay <ConnectionOverlay
isConnected={isConnected} isConnected={isConnected}