feat: enhance autodraft functionality with detailed settings and commissioner controls (#61)
This commit is contained in:
parent
d53bd271c6
commit
40167cfa5e
7 changed files with 224 additions and 46 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue