Add auto-start draft at scheduled time (#437)

Commissioners can now opt in to having a draft start automatically at
its scheduled draftDateTime rather than requiring a manual "Start Draft"
click. The 1-second timer loop checks for eligible seasons each tick and
calls the new shared startDraft() service, which is also used by the
existing commissioner HTTP route.

- Add autoStartDraft boolean to seasons table (migration 0108)
- Extract core start logic into app/services/draft-autostart.ts so both
  the API route and the timer can call it without AsyncLocalStorage
- Add checkAndAutoStartDrafts() to the timer tick; disables autoStartDraft
  and stops retrying if no draft order is set at fire time
- Guard against null draftDateTime in both the timer query (isNotNull)
  and server actions (autoStartDraft forced false when datetime is null)
- Add "Auto-start at scheduled time" checkbox to league creation wizard
  (step 3) and league settings, gated on date+time being set
- Show countdown + "Start Now" button in draft room when auto-start is
  scheduled; reuses the existing nowForPauseCheck 1-second ticker
- Show orange warning banner on league homepage to commissioners when
  auto-start is within 1 hour and no draft order has been configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-16 23:31:51 -07:00
parent faecfd8ecc
commit 0ba6a2a408
19 changed files with 6493 additions and 135 deletions

View file

@ -8,6 +8,16 @@ interface CommissionerDraftControlsProps {
onPause: () => void;
onResume: () => void;
buttonClassName?: string;
autoStartDraft?: boolean;
secondsUntilAutoStart?: number | null;
}
function formatCountdown(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function CommissionerDraftControls({
@ -18,8 +28,25 @@ export function CommissionerDraftControls({
onPause,
onResume,
buttonClassName,
autoStartDraft,
secondsUntilAutoStart,
}: CommissionerDraftControlsProps) {
if (seasonStatus === "pre_draft") {
if (autoStartDraft && secondsUntilAutoStart !== null && secondsUntilAutoStart !== undefined && secondsUntilAutoStart > 0) {
return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-mono tabular-nums">
Auto-starts in {formatCountdown(secondsUntilAutoStart)}
</span>
<Button variant="ghost" size="sm" className={buttonClassName} onClick={onStart}>
Start Now
</Button>
</div>
);
}
if (autoStartDraft && secondsUntilAutoStart === 0) {
return <span className="text-sm text-muted-foreground">Starting</span>;
}
return (
<Button className={buttonClassName} onClick={onStart}>
Start Draft

View file

@ -12,6 +12,7 @@ import { TimerModeSelector } from "~/components/league/TimerModeSelector";
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
import { StepperInput } from "~/components/league/StepperInput";
import { Checkbox } from "~/components/ui/checkbox";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function DraftSetupSection({
@ -26,6 +27,8 @@ export function DraftSetupSection({
onDraftDateChange,
draftTime,
onDraftTimeChange,
autoStartDraft,
onAutoStartDraftChange,
timerMode,
onTimerModeChange,
draftSpeed,
@ -51,6 +54,8 @@ export function DraftSetupSection({
onDraftDateChange: (v: Date | undefined) => void;
draftTime: string;
onDraftTimeChange: (v: string) => void;
autoStartDraft: boolean;
onAutoStartDraftChange: (v: boolean) => void;
timerMode: "chess_clock" | "standard";
onTimerModeChange: (v: "chess_clock" | "standard") => void;
draftSpeed: string;
@ -150,6 +155,20 @@ export function DraftSetupSection({
Set this before starting the draft room.
{localTz && <> Times are in your local timezone ({localTz}).</>}
</p>
{draftDate && draftTime && (
<div className="mt-4 flex items-center gap-3">
<Checkbox
id="autoStartDraft"
checked={autoStartDraft}
onCheckedChange={(v) => onAutoStartDraftChange(v === true)}
disabled={!canEditDraftRounds}
/>
<Label htmlFor="autoStartDraft" className="font-normal cursor-pointer">
Auto-start at scheduled time
</Label>
</div>
)}
<input type="hidden" name="autoStartDraft" value={draftDate && draftTime && autoStartDraft ? "on" : ""} />
</div>
<div className="space-y-8 rounded-lg border p-5 sm:p-6">

View file

@ -11,6 +11,7 @@ export type WizardSnapshot = {
draftRounds: number;
draftDate: string;
draftTime: string;
autoStartDraft: boolean;
timerMode: "chess_clock" | "standard";
draftSpeed: string;
overnightMode: "none" | "league" | "per_user";
@ -36,7 +37,9 @@ export function saveWizardState(snapshot: WizardSnapshot) {
export function loadWizardState(): WizardSnapshot | null {
try {
const raw = ss()?.getItem(WIZARD_SS_KEY);
return raw ? (JSON.parse(raw) as WizardSnapshot) : null;
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<WizardSnapshot>;
return { autoStartDraft: false, ...parsed } as WizardSnapshot;
} catch { return null; }
}
@ -55,6 +58,7 @@ export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData {
if (s.draftDate && s.draftTime) {
fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString());
}
if (s.autoStartDraft) fd.append("autoStartDraft", "on");
fd.append("draftTimerMode", s.timerMode);
fd.append("draftSpeed", s.draftSpeed);
fd.append("overnightPauseMode", s.overnightMode);

View file

@ -15,12 +15,12 @@ vi.mock("~/lib/auth.server", () => ({
vi.mock("~/models/commissioner", () => ({
isCommissioner: vi.fn(),
}));
vi.mock("~/models/draft-timer", () => ({
deleteSeasonTimers: vi.fn(),
initializeDraftTimers: vi.fn(),
vi.mock("~/models/user", () => ({
findUserById: vi.fn().mockResolvedValue(null),
getUserDisplayName: vi.fn().mockReturnValue(null),
}));
vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
vi.mock("~/services/draft-autostart", () => ({
startDraft: vi.fn().mockResolvedValue({ success: true }),
}));
// ── Fixtures ─────────────────────────────────────────────────────────────────
@ -28,11 +28,6 @@ vi.mock("~/models/audit-log", () => ({
const SEASON_ID = "season-1";
const COMMISSIONER_ID = "commissioner-user-1";
const mockDraftSlots = [
{ id: "slot-1", seasonId: SEASON_ID, teamId: "team-1", draftOrder: 1 },
{ id: "slot-2", seasonId: SEASON_ID, teamId: "team-2", draftOrder: 2 },
];
function makeSeason(overrides: Record<string, unknown> = {}) {
return {
id: SEASON_ID,
@ -56,7 +51,7 @@ function makeRequest() {
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("draft.start action — timer initialization", () => {
describe("draft.start action", () => {
let mockDb: any;
let mockSocketIO: any;
@ -76,81 +71,67 @@ describe("draft.start action — timer initialization", () => {
mockDb = {
query: {
seasons: { findFirst: vi.fn() },
draftSlots: { findMany: vi.fn() },
},
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
};
mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots);
const { database } = await import("~/database/context");
vi.mocked(database).mockReturnValue(mockDb);
const { startDraft } = await import("~/services/draft-autostart");
vi.mocked(startDraft).mockResolvedValue({ success: true });
});
describe("chess_clock mode", () => {
it("initializes each team's timer to draftInitialTime", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
);
it("returns 401 when not authenticated", async () => {
const { auth } = await import("~/lib/auth.server");
vi.mocked(auth.api.getSession).mockResolvedValue(null);
await action({ request: makeRequest(), params: {}, context: ctx });
const { initializeDraftTimers } = await import("~/models/draft-timer");
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
SEASON_ID,
expect.any(Array),
120 // draftInitialTime, not draftIncrementTime
);
});
it("uses the configured draftInitialTime (not the increment)", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 28800, draftIncrementTime: 3600 })
);
await action({ request: makeRequest(), params: {}, context: ctx });
const { initializeDraftTimers } = await import("~/models/draft-timer");
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
SEASON_ID,
expect.any(Array),
28800 // 8 hours initial bank
);
});
const res = await action({ request: makeRequest(), params: {}, context: ctx });
expect(res.status).toBe(401);
});
describe("standard mode", () => {
it("initializes each team's timer to draftIncrementTime (the per-pick time)", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
);
it("returns 404 when season not found", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(null);
await action({ request: makeRequest(), params: {}, context: ctx });
const res = await action({ request: makeRequest(), params: {}, context: ctx });
expect(res.status).toBe(404);
});
const { initializeDraftTimers } = await import("~/models/draft-timer");
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
SEASON_ID,
expect.any(Array),
30 // draftIncrementTime, not draftInitialTime
);
});
it("returns 403 when user is not a commissioner", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
const { isCommissioner } = await import("~/models/commissioner");
vi.mocked(isCommissioner).mockResolvedValue(false);
it("ignores draftInitialTime — uses only increment for the starting clock", async () => {
// Even though initialTime=600, standard mode should use increment=45
mockDb.query.seasons.findFirst.mockResolvedValue(
makeSeason({ draftTimerMode: "standard", draftInitialTime: 600, draftIncrementTime: 45 })
);
const res = await action({ request: makeRequest(), params: {}, context: ctx });
expect(res.status).toBe(403);
});
await action({ request: makeRequest(), params: {}, context: ctx });
it("calls startDraft and returns 200 on success", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
const { initializeDraftTimers } = await import("~/models/draft-timer");
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
SEASON_ID,
expect.any(Array),
45 // increment only — initial time is irrelevant in standard mode
);
});
const res = await action({ request: makeRequest(), params: {}, context: ctx });
const { startDraft } = await import("~/services/draft-autostart");
expect(vi.mocked(startDraft)).toHaveBeenCalledWith(
expect.objectContaining({ seasonId: SEASON_ID })
);
expect(res.status).toBe(200);
});
it("returns 400 when startDraft reports draft already started", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
const { startDraft } = await import("~/services/draft-autostart");
vi.mocked(startDraft).mockResolvedValue({ success: false, error: "Draft already started or completed" });
const res = await action({ request: makeRequest(), params: {}, context: ctx });
expect(res.status).toBe(400);
});
it("returns 400 when startDraft reports no draft slots", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
const { startDraft } = await import("~/services/draft-autostart");
vi.mocked(startDraft).mockResolvedValue({ success: false, error: "No draft slots found for this season" });
const res = await action({ request: makeRequest(), params: {}, context: ctx });
expect(res.status).toBe(400);
});
});

View file

@ -2,16 +2,16 @@ import { auth } from "~/lib/auth.server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { deleteSeasonTimers, initializeDraftTimers } from "~/models/draft-timer";
import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log";
import { findUserById, getUserDisplayName } from "~/models/user";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
import { startDraft } from "~/services/draft-autostart";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
const { request } = args;
const session = await auth.api.getSession({ headers: args.request.headers });
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null;
if (!userId) {
@ -27,7 +27,6 @@ export async function action(args: ActionFunctionArgs) {
const db = database();
// Get season details
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
@ -36,65 +35,27 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: "Season not found" }, { status: 404 });
}
// Check if user is commissioner
if (!(await isCommissioner(season.leagueId, userId))) {
return Response.json({ error: "Only commissioners can start the draft" }, { status: 403 });
}
// Check if draft already started
if (season.status === "draft" || season.status === "active" || season.status === "completed") {
return Response.json({ error: "Draft already started or completed" }, { status: 400 });
}
const user = await findUserById(userId);
const actorDisplayName = user ? (getUserDisplayName(user) ?? userId) : userId;
// Validate draft slots exist before modifying any state
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
});
if (draftSlots.length === 0) {
return Response.json({ error: "No draft slots found for this season" }, { status: 400 });
}
// Update season status to draft
await db
.update(schema.seasons)
.set({
status: "draft",
currentPickNumber: 1,
})
.where(eq(schema.seasons.id, seasonId));
// Standard mode: each pick starts with exactly the increment (no carry-over bank).
// Chess clock mode: each team starts with the full initial time bank.
const initialTime =
season.draftTimerMode === "standard"
? season.draftIncrementTime || 30
: season.draftInitialTime || 120;
// Reset timers for all teams
await deleteSeasonTimers(seasonId);
await initializeDraftTimers(
const result = await startDraft({
seasonId,
draftSlots.map((slot) => ({ id: slot.teamId })),
initialTime
);
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorUserId: userId,
action: "draft_started",
details: { pickNumber: 1 },
actorDisplayName,
db,
io: getSocketIO(),
});
// Emit socket event
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-started", {
seasonId,
currentPickNumber: 1,
});
} catch (error) {
logger.error("Socket.IO error:", error);
if (!result.success) {
const status =
result.error === "Draft already started or completed" ? 400
: result.error === "No draft slots found for this season" ? 400
: 500;
return Response.json({ error: result.error }, { status });
}
return Response.json({ success: true });

View file

@ -297,6 +297,12 @@ export default function DraftRoom() {
return () => clearInterval(id);
}, []);
const secondsUntilAutoStart = useMemo(() => {
if (season.status !== "pre_draft" || !season.autoStartDraft || !season.draftDateTime) return null;
const diff = Math.ceil((new Date(season.draftDateTime).getTime() - nowForPauseCheck.getTime()) / 1000);
return diff > 0 ? diff : 0;
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
const roomClosureCountdown = useMemo(() => {
if (!isDraftComplete) return null;
@ -1342,6 +1348,8 @@ export default function DraftRoom() {
onStart={handleStartDraft}
onPause={handlePauseDraft}
onResume={handleResumeDraft}
autoStartDraft={season.autoStartDraft}
secondsUntilAutoStart={secondsUntilAutoStart}
/>
</div>
)}
@ -1544,9 +1552,17 @@ export default function DraftRoom() {
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{isCommissioner && season.status === "pre_draft" && (
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
Start Draft
</Button>
<CommissionerDraftControls
seasonStatus={season.status}
isDraftComplete={isDraftComplete}
isPaused={isPaused}
onStart={handleStartDraft}
onPause={handlePauseDraft}
onResume={handleResumeDraft}
buttonClassName="w-full min-h-[48px]"
autoStartDraft={season.autoStartDraft}
secondsUntilAutoStart={secondsUntilAutoStart}
/>
)}
{userTeam && (

View file

@ -520,6 +520,14 @@ export async function action(args: Route.ActionArgs) {
seasonUpdates.draftTimerMode = draftTimerMode;
}
// Determine effective draftDateTime after this save so we can validate autoStartDraft
const effectiveDateTime =
"draftDateTime" in seasonUpdates ? seasonUpdates.draftDateTime : season.draftDateTime;
const autoStartDraft = formData.get("autoStartDraft") === "on" && effectiveDateTime !== null;
if (autoStartDraft !== season.autoStartDraft) {
seasonUpdates.autoStartDraft = autoStartDraft;
}
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
if (overnightPauseMode !== season.overnightPauseMode) {

View file

@ -160,6 +160,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const [draftTime, setDraftTime] = useState<string>(
season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""
);
const [autoStartDraft, setAutoStartDraft] = useState<boolean>(season?.autoStartDraft ?? false);
// Draft order state (separate form, separate dirty flag)
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
@ -249,6 +250,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
setDraftRounds(season?.draftRounds || 20);
setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined);
setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "");
setAutoStartDraft(season?.autoStartDraft ?? false);
setHasUnsavedSettingsChanges(false);
};
@ -372,6 +374,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }}
draftTime={draftTime}
onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }}
autoStartDraft={autoStartDraft}
onAutoStartDraftChange={(v) => { setAutoStartDraft(v); markDirty(); }}
timerMode={timerMode}
onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }}
draftSpeed={draftSpeed}

View file

@ -3,7 +3,7 @@ import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import type { Route } from "./+types/$leagueId";
import { Link2 } from "lucide-react";
import { Link2, TriangleAlert } from "lucide-react";
import { Button } from "~/components/ui/button";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
@ -173,6 +173,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div>
</div>
{isUserCommissioner &&
season?.status === "pre_draft" &&
season.autoStartDraft &&
!isDraftOrderSet &&
season.draftDateTime &&
new Date(season.draftDateTime).getTime() - Date.now() < 60 * 60 * 1000 && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-orange-500/50 bg-orange-500/10 px-4 py-3 text-orange-700 dark:text-orange-400">
<div className="flex items-center gap-2">
<TriangleAlert className="h-4 w-4 shrink-0" />
<span className="font-medium">Draft order not set.</span>
<span className="hidden text-sm sm:inline">
The draft is scheduled to auto-start soon but no draft order has been configured.
</span>
</div>
<Button asChild size="sm" variant="outline" className="shrink-0 border-orange-500/60 text-orange-700 hover:bg-orange-500/20 dark:text-orange-400">
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Draft Order
</Link>
</Button>
</div>
)}
{season?.status === "draft" && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-yellow-700 dark:text-yellow-400">
<div className="flex items-center gap-2">

View file

@ -20,6 +20,7 @@ const createMockSeason = (overrides: {
draftRounds: 20,
flexSpots: 0,
draftDateTime: null,
autoStartDraft: false,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: 'chess_clock' as const,

View file

@ -53,6 +53,7 @@ const mockSeason = {
draftRounds: 20,
flexSpots: 0,
draftDateTime: null,
autoStartDraft: false,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: 'chess_clock' as const,

View file

@ -145,6 +145,7 @@ export async function action(args: Route.ActionArgs) {
const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
const draftDateTime = formData.get("draftDateTime");
const autoStartDraft = formData.get("autoStartDraft") === "on" && !!(typeof draftDateTime === "string" && draftDateTime);
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
@ -222,6 +223,7 @@ export async function action(args: Route.ActionArgs) {
: null,
draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
autoStartDraft,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
@ -613,6 +615,8 @@ function Step3DraftSettings({
setDraftDate,
draftTime,
setDraftTime,
autoStartDraft,
setAutoStartDraft,
timerMode,
setTimerMode,
draftSpeed,
@ -637,6 +641,8 @@ function Step3DraftSettings({
setDraftDate: (v: string) => void;
draftTime: string;
setDraftTime: (v: string) => void;
autoStartDraft: boolean;
setAutoStartDraft: (v: boolean) => void;
timerMode: "chess_clock" | "standard";
setTimerMode: (v: "chess_clock" | "standard") => void;
draftSpeed: string;
@ -713,6 +719,19 @@ function Step3DraftSettings({
This can be changed before the draft starts.
{localTz && <> Times are in your local timezone ({localTz}).</>}
</p>
{draftDate && draftTime && (
<div className="flex items-center gap-3 pt-1">
<Checkbox
id="autoStartDraft"
checked={autoStartDraft}
onCheckedChange={(v) => setAutoStartDraft(v === true)}
/>
<Label htmlFor="autoStartDraft" className="font-normal cursor-pointer">
Auto-start at scheduled time
</Label>
</div>
)}
<input type="hidden" name="autoStartDraft" value={draftDate && draftTime && autoStartDraft ? "on" : ""} />
</div>
{/* Timer Mode */}
@ -1025,6 +1044,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
const [draftRounds, setDraftRounds] = useState(20);
const [draftDate, setDraftDate] = useState("");
const [draftTime, setDraftTime] = useState("");
const [autoStartDraft, setAutoStartDraft] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
const [draftSpeed, setDraftSpeed] = useState("standard");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
@ -1068,7 +1088,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
return buildFormDataFromSnapshot({
leagueName, teamCount, joinAsPlayer, selectedTemplate,
selectedSports: [...selectedSports],
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
overnightMode, overnightStart, overnightEnd, overnightTimezone,
scoringPreset, scoringRules, userTimezone,
});
@ -1114,7 +1134,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
saveWizardState({
leagueName, teamCount, joinAsPlayer, selectedTemplate,
selectedSports: [...selectedSports],
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
overnightMode, overnightStart, overnightEnd, overnightTimezone,
scoringPreset, scoringRules, userTimezone,
});
@ -1204,6 +1224,8 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
setDraftDate={setDraftDate}
draftTime={draftTime}
setDraftTime={setDraftTime}
autoStartDraft={autoStartDraft}
setAutoStartDraft={setAutoStartDraft}
timerMode={timerMode}
setTimerMode={(mode) => {
setTimerMode(mode);

View file

@ -0,0 +1,99 @@
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { logger } from "~/lib/logger";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import type { Server as SocketIOServer } from "socket.io";
type Db = PostgresJsDatabase<typeof schema>;
export interface StartDraftOptions {
seasonId: string;
actorUserId: string;
actorDisplayName: string;
db: Db;
io: SocketIOServer;
}
export interface StartDraftResult {
success: boolean;
error?: string;
}
/**
* Core draft-start logic shared by the commissioner HTTP route and the
* auto-start timer. Accepts `db` directly (not via AsyncLocalStorage) so it
* works both inside React Router request context and in the server timer loop.
*/
export async function startDraft(options: StartDraftOptions): Promise<StartDraftResult> {
const { seasonId, actorUserId, actorDisplayName, db, io } = options;
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return { success: false, error: "Season not found" };
}
// Idempotency guard — status may have changed between the outer check and here
if (season.status !== "pre_draft") {
return { success: false, error: "Draft already started or completed" };
}
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
});
if (draftSlots.length === 0) {
return { success: false, error: "No draft slots found for this season" };
}
await db
.update(schema.seasons)
.set({
status: "draft",
currentPickNumber: 1,
draftStartedAt: new Date(),
})
.where(eq(schema.seasons.id, seasonId));
// Standard mode: each pick gets exactly the increment time.
// Chess clock mode: each team starts with the full initial bank.
const initialTime =
season.draftTimerMode === "standard"
? season.draftIncrementTime || 30
: season.draftInitialTime || 120;
await db.delete(schema.draftTimers).where(eq(schema.draftTimers.seasonId, seasonId));
await db.insert(schema.draftTimers).values(
draftSlots.map((slot) => ({
seasonId,
teamId: slot.teamId,
timeRemaining: initialTime,
}))
);
await db.insert(schema.commissionerAuditLog).values({
seasonId,
leagueId: season.leagueId,
actorUserId,
actorDisplayName,
action: "draft_started",
affectedTeamIds: [],
details: { pickNumber: 1, autoStarted: actorUserId === "system" },
});
try {
io.to(`draft-${seasonId}`).emit("draft-started", {
seasonId,
currentPickNumber: 1,
});
} catch (err) {
// Socket emit failure is non-fatal; DB state is already committed
logger.error("[startDraft] Socket emit failed:", err);
}
return { success: true };
}

View file

@ -7,6 +7,7 @@ export const mockSeason = {
draftRounds: 20,
flexSpots: 0,
draftDateTime: new Date('2025-12-01T19:00:00'),
autoStartDraft: false,
draftInitialTime: 120,
draftIncrementTime: 15,
currentPickNumber: 1,

View file

@ -206,6 +206,7 @@ export const seasons = pgTable("seasons", {
draftRounds: integer("draft_rounds").notNull().default(20),
flexSpots: integer("flex_spots").notNull().default(0),
draftDateTime: timestamp("draft_date_time"),
autoStartDraft: boolean("auto_start_draft").notNull().default(false),
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),

View file

@ -0,0 +1 @@
ALTER TABLE "seasons" ADD COLUMN "auto_start_draft" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -757,6 +757,13 @@
"when": 1778868190284,
"tag": "0107_outgoing_mikhail_rasputin",
"breakpoints": true
},
{
"idx": 108,
"version": "7",
"when": 1778997704083,
"tag": "0108_faulty_purple_man",
"breakpoints": true
}
]
}

View file

@ -1,11 +1,12 @@
import * as schema from "~/database/schema";
import { eq, and, asc, sql } from "drizzle-orm";
import { eq, and, asc, sql, lte, isNotNull } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
import { logger } from "./logger";
import { db } from "./db";
import { startDraft } from "~/services/draft-autostart";
let timerInterval: NodeJS.Timeout | null = null;
let timerTickRunning = false;
@ -109,11 +110,50 @@ export function stopDraftTimerSystem(): void {
}
}
async function checkAndAutoStartDrafts(): Promise<void> {
const io = getSocketIO();
const seasonsToStart = await db.query.seasons.findMany({
where: and(
eq(schema.seasons.status, "pre_draft"),
eq(schema.seasons.autoStartDraft, true),
isNotNull(schema.seasons.draftDateTime),
lte(schema.seasons.draftDateTime, new Date())
),
});
for (const season of seasonsToStart) {
logger.log(`[Timer] Auto-starting draft for season ${season.id}`);
const result = await startDraft({
seasonId: season.id,
actorUserId: "system",
actorDisplayName: "System (auto-start)",
db,
io,
});
if (result.success) continue;
if (result.error === "Draft already started or completed") continue;
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
if (result.error === "No draft slots found for this season") {
// Disable auto-start to stop the retry loop. The commissioner will see
// the setting is now off and can re-enable it once the order is set.
await db
.update(schema.seasons)
.set({ autoStartDraft: false })
.where(eq(schema.seasons.id, season.id));
logger.log(`[Timer] Disabled autoStartDraft for season ${season.id} — no draft order set`);
}
}
}
/**
* Update all active draft timers
* Called every second by the timer interval
*/
async function updateDraftTimers(): Promise<void> {
await checkAndAutoStartDrafts();
const io = getSocketIO();
// Get all active drafts