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";
}
export function getAutodraftLabel(
isEnabled: boolean,
mode: AutodraftMode,
queueOnly: boolean
): string {
return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label;
}
interface AutodraftSettingsProps {
seasonId: string;
teamId: string;

View file

@ -47,10 +47,11 @@ interface DraftGridSectionProps {
>;
currentPick: number;
teamTimers: Record<string, number | undefined>;
autodraftStatus: Record<string, boolean>;
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
connectedTeams: Set<string>;
isCommissioner: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
@ -67,6 +68,7 @@ export const DraftGridSection = memo(function DraftGridSection({
connectedTeams,
isCommissioner,
onAdjustTimeBankOpen,
onSetAutodraftOpen,
onForceAutopick,
onForceManualPickOpen,
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" />
{draftSlots.map((slot) => {
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 headerInner = (
@ -117,7 +119,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</span>
)}
</div>
{isCommissioner && onAdjustTimeBankOpen && (
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
<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"
onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })}
@ -128,7 +130,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</div>
);
if (isCommissioner && onAdjustTimeBankOpen) {
if (isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen)) {
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
@ -137,11 +139,16 @@ export const DraftGridSection = memo(function DraftGridSection({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => onAdjustTimeBankOpen(slot.team.id)}
>
{onAdjustTimeBankOpen && (
<ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
Adjust Time Bank...
</ContextMenuItem>
)}
{onSetAutodraftOpen && (
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
Set Autodraft...
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
@ -316,7 +323,9 @@ export const DraftGridSection = memo(function DraftGridSection({
<SheetTitle>Commissioner Actions</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-3 p-4">
{mobileSheet?.type === "team" && onAdjustTimeBankOpen && (
{mobileSheet?.type === "team" && (
<>
{onAdjustTimeBankOpen && (
<Button
onClick={() => {
onAdjustTimeBankOpen(mobileSheet.teamId);
@ -326,6 +335,19 @@ export const DraftGridSection = memo(function DraftGridSection({
Adjust Time Bank...
</Button>
)}
{onSetAutodraftOpen && (
<Button
variant="outline"
onClick={() => {
onSetAutodraftOpen(mobileSheet.teamId);
setMobileSheet(null);
}}
>
Set Autodraft...
</Button>
)}
</>
)}
{mobileSheet?.type === "current-cell" && (
<>
<Button

View file

@ -1,8 +1,10 @@
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 { eq, and, asc } from "drizzle-orm";
import { getSocketIO } from "~/server/socket";
import { isUserAdminByClerkId } from "~/models/user";
import { getTeamForPick } from "~/lib/draft-order";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
@ -26,14 +28,37 @@ export async function action(args: ActionFunctionArgs) {
const db = database();
// Verify user owns this team
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId),
});
// Fetch team and season in parallel
const [team, season] = await Promise.all([
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: "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
const existingSettings = await db.query.autodraftSettings.findFirst({
@ -77,7 +102,38 @@ export async function action(args: ActionFunctionArgs) {
isEnabled,
mode,
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 });
}

View file

@ -28,6 +28,7 @@ import { getTeamForPick } from "~/lib/draft-order";
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { NotificationSettings } from "~/components/NotificationSettings";
import { AutodraftSettings, getAutodraftLabel } from "~/components/AutodraftSettings";
import { toast } from "sonner";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
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 AutodraftStatusEntry = {
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
};
const MOBILE_TABS_BASE = [
{ id: "available" as const, label: "Available", Icon: Users },
{ 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
// we were away.
setAutodraftStatus(() => {
const status: Record<string, boolean> = {};
const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => {
status[setting.teamId] = setting.isEnabled;
status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
});
return status;
});
@ -528,10 +539,14 @@ export default function DraftRoom() {
const isMobile = useMediaQuery("(max-width: 767px)");
// Track autodraft status for all teams
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
const status: Record<string, boolean> = {};
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
const status: Record<string, AutodraftStatusEntry> = {};
autodraftSettings.forEach((setting: any) => {
status[setting.teamId] = setting.isEnabled;
status[setting.teamId] = {
isEnabled: setting.isEnabled,
mode: setting.mode,
queueOnly: setting.queueOnly,
};
});
return status;
});
@ -579,6 +594,8 @@ export default function DraftRoom() {
// Time bank adjustment dialog state
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
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 [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
@ -743,19 +760,33 @@ export default function DraftRoom() {
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) => ({
...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
if (userTeam && data.teamId === userTeam.id) {
if (data.source === "commissioner") {
if (!data.isEnabled) {
toast.info("Commissioner turned off your autodraft");
} 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({
isEnabled: data.isEnabled,
@ -1235,6 +1266,16 @@ export default function DraftRoom() {
setTimeBankDialogOpen(true);
}, []);
const handleSetAutodraftOpen = useCallback((teamId: string) => {
setCommissionerAutodraftTeamId(teamId);
setCommissionerAutodraftDialogOpen(true);
}, []);
const handleCommissionerAutodraftUpdate = useCallback(() => {
setCommissionerAutodraftDialogOpen(false);
setCommissionerAutodraftTeamId(null);
}, []);
const handleConfirmAdjustTimeBank = async () => {
if (!timeBankTeamId || isAdjustingTimeBank) return;
@ -1452,6 +1493,7 @@ export default function DraftRoom() {
isCommissioner,
ownerMap,
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: handleForceAutopick,
onForceManualPickOpen: handleForceManualPickOpen,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
@ -1990,6 +2032,37 @@ export default function DraftRoom() {
</DialogContent>
</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 */}
<ConnectionOverlay
isConnected={isConnected}

View file

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

View file

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

View file

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