feat: enhance autodraft functionality with detailed settings and commissioner controls (#61)

This commit is contained in:
Chris Parsons 2026-03-03 20:14:38 -08:00 committed by GitHub
parent d53bd271c6
commit 40167cfa5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 224 additions and 46 deletions

View file

@ -82,6 +82,14 @@ function toAutodraftState(
return queueOnly ? "all_queue" : "all_picks"; return queueOnly ? "all_queue" : "all_picks";
} }
export function getAutodraftLabel(
isEnabled: boolean,
mode: AutodraftMode,
queueOnly: boolean
): string {
return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label;
}
interface AutodraftSettingsProps { interface AutodraftSettingsProps {
seasonId: string; seasonId: string;
teamId: string; teamId: string;

View file

@ -47,10 +47,11 @@ interface DraftGridSectionProps {
>; >;
currentPick: number; currentPick: number;
teamTimers: Record<string, number | undefined>; teamTimers: Record<string, number | undefined>;
autodraftStatus: Record<string, boolean>; autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
connectedTeams: Set<string>; connectedTeams: Set<string>;
isCommissioner: boolean; isCommissioner: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void; onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (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;
@ -67,6 +68,7 @@ export const DraftGridSection = memo(function DraftGridSection({
connectedTeams, connectedTeams,
isCommissioner, isCommissioner,
onAdjustTimeBankOpen, onAdjustTimeBankOpen,
onSetAutodraftOpen,
onForceAutopick, onForceAutopick,
onForceManualPickOpen, onForceManualPickOpen,
onReplacePick, onReplacePick,
@ -91,7 +93,7 @@ export const DraftGridSection = memo(function DraftGridSection({
<div className="w-8 flex-shrink-0 sticky left-0 z-[6] bg-background/95" /> <div className="w-8 flex-shrink-0 sticky left-0 z-[6] bg-background/95" />
{draftSlots.map((slot) => { {draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id]; const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id] || false; const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
const isConnected = connectedTeams.has(slot.team.id); const isConnected = connectedTeams.has(slot.team.id);
const headerInner = ( const headerInner = (
@ -117,7 +119,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</span> </span>
)} )}
</div> </div>
{isCommissioner && onAdjustTimeBankOpen && ( {isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
<button <button
className="absolute top-0 right-0 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted" className="absolute top-0 right-0 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })} onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })}
@ -128,7 +130,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</div> </div>
); );
if (isCommissioner && onAdjustTimeBankOpen) { if (isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen)) {
return ( return (
<ContextMenu key={slot.id}> <ContextMenu key={slot.id}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@ -137,11 +139,16 @@ export const DraftGridSection = memo(function DraftGridSection({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem {onAdjustTimeBankOpen && (
onClick={() => onAdjustTimeBankOpen(slot.team.id)} <ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
> Adjust Time Bank...
Adjust Time Bank... </ContextMenuItem>
</ContextMenuItem> )}
{onSetAutodraftOpen && (
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
Set Autodraft...
</ContextMenuItem>
)}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );
@ -316,15 +323,30 @@ export const DraftGridSection = memo(function DraftGridSection({
<SheetTitle>Commissioner Actions</SheetTitle> <SheetTitle>Commissioner Actions</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
{mobileSheet?.type === "team" && onAdjustTimeBankOpen && ( {mobileSheet?.type === "team" && (
<Button <>
onClick={() => { {onAdjustTimeBankOpen && (
onAdjustTimeBankOpen(mobileSheet.teamId); <Button
setMobileSheet(null); onClick={() => {
}} onAdjustTimeBankOpen(mobileSheet.teamId);
> setMobileSheet(null);
Adjust Time Bank... }}
</Button> >
Adjust Time Bank...
</Button>
)}
{onSetAutodraftOpen && (
<Button
variant="outline"
onClick={() => {
onSetAutodraftOpen(mobileSheet.teamId);
setMobileSheet(null);
}}
>
Set Autodraft...
</Button>
)}
</>
)} )}
{mobileSheet?.type === "current-cell" && ( {mobileSheet?.type === "current-cell" && (
<> <>

View file

@ -1,8 +1,10 @@
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and, asc } from "drizzle-orm";
import { getSocketIO } from "~/server/socket"; import { getSocketIO } from "~/server/socket";
import { isUserAdminByClerkId } from "~/models/user";
import { getTeamForPick } from "~/lib/draft-order";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -26,15 +28,38 @@ export async function action(args: ActionFunctionArgs) {
const db = database(); const db = database();
// Verify user owns this team // Fetch team and season in parallel
const team = await db.query.teams.findFirst({ const [team, season] = await Promise.all([
where: eq(schema.teams.id, teamId), db.query.teams.findFirst({ where: eq(schema.teams.id, teamId) }),
}); db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }),
]);
if (!team || team.ownerId !== userId) { if (!team || !season) {
return Response.json({ error: "Unauthorized" }, { status: 403 }); return Response.json({ error: "Team or season not found" }, { status: 404 });
} }
const isOwner = team.ownerId === userId;
let isActingAsCommissioner = false;
if (!isOwner) {
const [commissioner, userIsAdmin] = await Promise.all([
db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, season.leagueId),
eq(schema.commissioners.userId, userId)
),
}),
isUserAdminByClerkId(userId),
]);
if (!commissioner && !userIsAdmin) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
isActingAsCommissioner = true;
}
const resolvedSource: "commissioner" | "user" = isActingAsCommissioner ? "commissioner" : "user";
// Check if autodraft settings exist // Check if autodraft settings exist
const existingSettings = await db.query.autodraftSettings.findFirst({ const existingSettings = await db.query.autodraftSettings.findFirst({
where: and( where: and(
@ -77,7 +102,38 @@ export async function action(args: ActionFunctionArgs) {
isEnabled, isEnabled,
mode, mode,
queueOnly, queueOnly,
source: resolvedSource,
}); });
// If commissioner enables autodraft for the team currently on the clock, fire immediately
if (isEnabled && isActingAsCommissioner) {
import("~/models/draft-utils").then(async ({ executeAutoPick }) => {
const freshSeason = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!freshSeason || freshSeason.status !== "draft") return;
const currentPickNumber = freshSeason.currentPickNumber ?? 1;
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: asc(schema.draftSlots.draftOrder),
});
const currentSlot = getTeamForPick(currentPickNumber, draftSlots);
if (!currentSlot || currentSlot.teamId !== teamId) return;
// Team is on the clock — trigger autopick
await executeAutoPick({
seasonId,
teamId,
pickNumber: currentPickNumber,
triggeredBy: "commissioner",
db,
});
}).catch((err) => {
console.error("[AutodraftUpdate] Mid-turn autopick failed:", err);
});
}
return Response.json({ success: true, settings }); return Response.json({ success: true, settings });
} }

View file

@ -28,6 +28,7 @@ import { getTeamForPick } from "~/lib/draft-order";
import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { useMediaQuery } from "~/hooks/useMediaQuery"; import { useMediaQuery } from "~/hooks/useMediaQuery";
import { NotificationSettings } from "~/components/NotificationSettings"; import { NotificationSettings } from "~/components/NotificationSettings";
import { AutodraftSettings, getAutodraftLabel } from "~/components/AutodraftSettings";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
@ -35,6 +36,12 @@ import type { Route } from "./+types/$leagueId.draft.$seasonId";
type QueueItem = typeof schema.draftQueue.$inferSelect; type QueueItem = typeof schema.draftQueue.$inferSelect;
type AutodraftStatusEntry = {
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
};
const MOBILE_TABS_BASE = [ const MOBILE_TABS_BASE = [
{ id: "available" as const, label: "Available", Icon: Users }, { id: "available" as const, label: "Available", Icon: Users },
{ id: "board" as const, label: "Board", Icon: LayoutGrid }, { id: "board" as const, label: "Board", Icon: LayoutGrid },
@ -492,9 +499,13 @@ export default function DraftRoom() {
// Sync autodraft settings — teams may have toggled autodraft while // Sync autodraft settings — teams may have toggled autodraft while
// we were away. // we were away.
setAutodraftStatus(() => { setAutodraftStatus(() => {
const status: Record<string, boolean> = {}; const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => { autodraftSettings.forEach((setting: any) => {
status[setting.teamId] = setting.isEnabled; status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
}); });
return status; return status;
}); });
@ -528,10 +539,14 @@ export default function DraftRoom() {
const isMobile = useMediaQuery("(max-width: 767px)"); const isMobile = useMediaQuery("(max-width: 767px)");
// Track autodraft status for all teams // Track autodraft status for all teams
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => { const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
const status: Record<string, boolean> = {}; const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => { autodraftSettings.forEach((setting: any) => {
status[setting.teamId] = setting.isEnabled; status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
}); });
return status; return status;
}); });
@ -579,6 +594,8 @@ export default function DraftRoom() {
// Time bank adjustment dialog state // Time bank adjustment dialog state
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null); const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false);
const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState<string | null>(null);
const [timeBankAmount, setTimeBankAmount] = useState("1"); const [timeBankAmount, setTimeBankAmount] = useState("1");
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes"); const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add"); const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
@ -743,18 +760,32 @@ export default function DraftRoom() {
setIsDraftComplete(true); setIsDraftComplete(true);
}; };
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => { const handleAutodraftUpdated = (data: {
teamId: string;
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
source?: "commissioner" | "user";
}) => {
setAutodraftStatus((prev) => ({ setAutodraftStatus((prev) => ({
...prev, ...prev,
[data.teamId]: data.isEnabled, [data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly },
})); }));
// Update user's local state if it's their team // Update user's local state if it's their team
if (userTeam && data.teamId === userTeam.id) { if (userTeam && data.teamId === userTeam.id) {
// Detect server-side auto-disable: was enabled with queueOnly, now disabled if (data.source === "commissioner") {
const prev = userAutodraftRef.current; if (!data.isEnabled) {
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") { toast.info("Commissioner turned off your autodraft");
toast.info("Autodraft disabled — your queue is empty"); } else {
toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`);
}
} else {
// Detect server-side auto-disable: was enabled with queueOnly, now disabled
const prev = userAutodraftRef.current;
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
toast.info("Autodraft disabled — your queue is empty");
}
} }
setUserAutodraft({ setUserAutodraft({
@ -1235,6 +1266,16 @@ export default function DraftRoom() {
setTimeBankDialogOpen(true); setTimeBankDialogOpen(true);
}, []); }, []);
const handleSetAutodraftOpen = useCallback((teamId: string) => {
setCommissionerAutodraftTeamId(teamId);
setCommissionerAutodraftDialogOpen(true);
}, []);
const handleCommissionerAutodraftUpdate = useCallback(() => {
setCommissionerAutodraftDialogOpen(false);
setCommissionerAutodraftTeamId(null);
}, []);
const handleConfirmAdjustTimeBank = async () => { const handleConfirmAdjustTimeBank = async () => {
if (!timeBankTeamId || isAdjustingTimeBank) return; if (!timeBankTeamId || isAdjustingTimeBank) return;
@ -1452,6 +1493,7 @@ export default function DraftRoom() {
isCommissioner, isCommissioner,
ownerMap, ownerMap,
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: handleForceAutopick, onForceAutopick: handleForceAutopick,
onForceManualPickOpen: handleForceManualPickOpen, onForceManualPickOpen: handleForceManualPickOpen,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
@ -1990,6 +2032,37 @@ export default function DraftRoom() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Commissioner Autodraft Override Dialog */}
<Dialog
open={commissionerAutodraftDialogOpen}
onOpenChange={(open) => {
setCommissionerAutodraftDialogOpen(open);
if (!open) setCommissionerAutodraftTeamId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Autodraft</DialogTitle>
<DialogDescription>
{commissionerAutodraftTeamId
? `Change autodraft for ${draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name ?? "this team"}`
: "Set a team's autodraft settings"}
</DialogDescription>
</DialogHeader>
{commissionerAutodraftTeamId && (
<AutodraftSettings
seasonId={season.id}
teamId={commissionerAutodraftTeamId}
isEnabled={autodraftStatus[commissionerAutodraftTeamId]?.isEnabled ?? false}
mode={autodraftStatus[commissionerAutodraftTeamId]?.mode ?? "next_pick"}
queueOnly={autodraftStatus[commissionerAutodraftTeamId]?.queueOnly ?? false}
isMyTurn={false}
onUpdate={handleCommissionerAutodraftUpdate}
/>
)}
</DialogContent>
</Dialog>
{/* Connection Overlay - blocks interaction until socket connects */} {/* Connection Overlay - blocks interaction until socket connects */}
<ConnectionOverlay <ConnectionOverlay
isConnected={isConnected} isConnected={isConnected}

View file

@ -12,6 +12,9 @@ vi.mock("~/server/socket", () => ({
vi.mock("@clerk/react-router/server", () => ({ vi.mock("@clerk/react-router/server", () => ({
getAuth: vi.fn(), getAuth: vi.fn(),
})); }));
vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn().mockResolvedValue(false),
}));
describe("Autodraft Settings API", () => { describe("Autodraft Settings API", () => {
let mockDb: any; let mockDb: any;
@ -39,6 +42,12 @@ describe("Autodraft Settings API", () => {
teams: { teams: {
findFirst: vi.fn(), findFirst: vi.fn(),
}, },
seasons: {
findFirst: vi.fn().mockResolvedValue({ id: "season-123", leagueId: "league-1" }),
},
commissioners: {
findFirst: vi.fn().mockResolvedValue(null),
},
autodraftSettings: { autodraftSettings: {
findFirst: vi.fn(), findFirst: vi.fn(),
}, },
@ -115,6 +124,7 @@ describe("Autodraft Settings API", () => {
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: false, queueOnly: false,
source: "user",
}); });
}); });
@ -179,6 +189,7 @@ describe("Autodraft Settings API", () => {
isEnabled: true, isEnabled: true,
mode: "while_on", mode: "while_on",
queueOnly: false, queueOnly: false,
source: "user",
}); });
}); });
@ -233,6 +244,7 @@ describe("Autodraft Settings API", () => {
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: true, queueOnly: true,
source: "user",
}); });
}); });
@ -418,6 +430,7 @@ describe("Autodraft Settings API", () => {
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: true, queueOnly: true,
source: "user",
}); });
}); });
}); });

View file

@ -708,26 +708,31 @@ describe("full mobile reconnection scenario", () => {
describe("autodraft and timer sync on revalidation completion", () => { describe("autodraft and timer sync on revalidation completion", () => {
it("syncs autodraftStatus from loader data on revalidation", () => { it("syncs autodraftStatus from loader data on revalidation", () => {
let autodraftStatus: Record<string, boolean> = { type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean };
"team-1": false, let autodraftStatus: Record<string, AutodraftStatusEntry> = {
"team-2": false, "team-1": { isEnabled: false, mode: "next_pick", queueOnly: false },
"team-2": { isEnabled: false, mode: "next_pick", queueOnly: false },
}; };
// Simulate what happens on revalidation completion // Simulate what happens on revalidation completion
const autodraftSettings = [ const autodraftSettings = [
{ teamId: "team-1", isEnabled: true }, { teamId: "team-1", isEnabled: true, mode: "while_on" as const, queueOnly: false },
{ teamId: "team-2", isEnabled: false }, { teamId: "team-2", isEnabled: false, mode: "next_pick" as const, queueOnly: false },
{ teamId: "team-3", isEnabled: true }, { teamId: "team-3", isEnabled: true, mode: "next_pick" as const, queueOnly: true },
]; ];
autodraftStatus = {}; autodraftStatus = {};
autodraftSettings.forEach((setting) => { autodraftSettings.forEach((setting) => {
autodraftStatus[setting.teamId] = setting.isEnabled; autodraftStatus[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
}); });
expect(autodraftStatus["team-1"]).toBe(true); expect(autodraftStatus["team-1"].isEnabled).toBe(true);
expect(autodraftStatus["team-2"]).toBe(false); expect(autodraftStatus["team-2"].isEnabled).toBe(false);
expect(autodraftStatus["team-3"]).toBe(true); expect(autodraftStatus["team-3"].isEnabled).toBe(true);
}); });
it("syncs teamTimers from loader data on revalidation", () => { it("syncs teamTimers from loader data on revalidation", () => {

View file

@ -39,6 +39,7 @@ interface ServerToClientEvents {
isEnabled: boolean; isEnabled: boolean;
mode: "next_pick" | "while_on"; mode: "next_pick" | "while_on";
queueOnly: boolean; queueOnly: boolean;
source?: "commissioner" | "user";
}) => void; }) => void;
"team-connected": (data: { teamId: string }) => void; "team-connected": (data: { teamId: string }) => void;
"team-disconnected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void;