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:
parent
faecfd8ecc
commit
d468385d90
19 changed files with 6493 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("uses the configured draftInitialTime (not the increment)", async () => {
|
||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 28800, draftIncrementTime: 3600 })
|
||||
);
|
||||
it("returns 404 when season not found", async () => {
|
||||
mockDb.query.seasons.findFirst.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),
|
||||
28800 // 8 hours initial bank
|
||||
);
|
||||
});
|
||||
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
const { initializeDraftTimers } = await import("~/models/draft-timer");
|
||||
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
||||
SEASON_ID,
|
||||
expect.any(Array),
|
||||
30 // draftIncrementTime, not draftInitialTime
|
||||
);
|
||||
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
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 })
|
||||
);
|
||||
it("calls startDraft and returns 200 on success", async () => {
|
||||
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
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 { 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const createMockSeason = (overrides: {
|
|||
draftRounds: 20,
|
||||
flexSpots: 0,
|
||||
draftDateTime: null,
|
||||
autoStartDraft: false,
|
||||
draftInitialTime: 120,
|
||||
draftIncrementTime: 15,
|
||||
draftTimerMode: 'chess_clock' as const,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const mockSeason = {
|
|||
draftRounds: 20,
|
||||
flexSpots: 0,
|
||||
draftDateTime: null,
|
||||
autoStartDraft: false,
|
||||
draftInitialTime: 120,
|
||||
draftIncrementTime: 15,
|
||||
draftTimerMode: 'chess_clock' as const,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
99
app/services/draft-autostart.ts
Normal file
99
app/services/draft-autostart.ts
Normal 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 };
|
||||
}
|
||||
1
app/test/fixtures/season.ts
vendored
1
app/test/fixtures/season.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
1
drizzle/0108_faulty_purple_man.sql
Normal file
1
drizzle/0108_faulty_purple_man.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "seasons" ADD COLUMN "auto_start_draft" boolean DEFAULT false NOT NULL;
|
||||
6143
drizzle/meta/0108_snapshot.json
Normal file
6143
drizzle/meta/0108_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue