Add commissioner time bank adjustment feature for draft (#22)
* 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 * Fix code review issues in commissioner time bank adjustment - Wrap fetch in try/finally so isAdjustingTimeBank is always reset, even on network errors that cause fetch to throw - Guard against totalSeconds rounding to 0 for tiny fractional inputs - Reject API requests when the draft is not in 'draft' status (409) - Set input min to 0.001 so the browser rejects zero in native validation https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8a444a51a1
commit
51cffe1762
4 changed files with 272 additions and 2 deletions
|
|
@ -37,6 +37,7 @@ interface DraftGridSectionProps {
|
|||
autodraftStatus: Record<string, boolean>;
|
||||
connectedTeams: Set<string>;
|
||||
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 (
|
||||
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
||||
const headerInner = (
|
||||
<>
|
||||
<div
|
||||
className={`font-semibold text-sm truncate px-2 ${
|
||||
slot.team.id === currentTeamId
|
||||
|
|
@ -96,6 +98,31 @@ export function DraftGridSection({
|
|||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
89
app/routes/api/draft.adjust-time-bank.ts
Normal file
89
app/routes/api/draft.adjust-time-bank.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -296,6 +296,14 @@ export default function DraftRoom() {
|
|||
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
|
||||
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
|
||||
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() {
|
|||
</DialogContent>
|
||||
</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.001"
|
||||
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 */}
|
||||
<ConnectionOverlay
|
||||
isConnected={isConnected}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue