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;
|
onPause: () => void;
|
||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
buttonClassName?: string;
|
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({
|
export function CommissionerDraftControls({
|
||||||
|
|
@ -18,8 +28,25 @@ export function CommissionerDraftControls({
|
||||||
onPause,
|
onPause,
|
||||||
onResume,
|
onResume,
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
|
autoStartDraft,
|
||||||
|
secondsUntilAutoStart,
|
||||||
}: CommissionerDraftControlsProps) {
|
}: CommissionerDraftControlsProps) {
|
||||||
if (seasonStatus === "pre_draft") {
|
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 (
|
return (
|
||||||
<Button className={buttonClassName} onClick={onStart}>
|
<Button className={buttonClassName} onClick={onStart}>
|
||||||
Start Draft
|
Start Draft
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { TimerModeSelector } from "~/components/league/TimerModeSelector";
|
||||||
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
|
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
|
||||||
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
|
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
|
||||||
import { StepperInput } from "~/components/league/StepperInput";
|
import { StepperInput } from "~/components/league/StepperInput";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
export function DraftSetupSection({
|
export function DraftSetupSection({
|
||||||
|
|
@ -26,6 +27,8 @@ export function DraftSetupSection({
|
||||||
onDraftDateChange,
|
onDraftDateChange,
|
||||||
draftTime,
|
draftTime,
|
||||||
onDraftTimeChange,
|
onDraftTimeChange,
|
||||||
|
autoStartDraft,
|
||||||
|
onAutoStartDraftChange,
|
||||||
timerMode,
|
timerMode,
|
||||||
onTimerModeChange,
|
onTimerModeChange,
|
||||||
draftSpeed,
|
draftSpeed,
|
||||||
|
|
@ -51,6 +54,8 @@ export function DraftSetupSection({
|
||||||
onDraftDateChange: (v: Date | undefined) => void;
|
onDraftDateChange: (v: Date | undefined) => void;
|
||||||
draftTime: string;
|
draftTime: string;
|
||||||
onDraftTimeChange: (v: string) => void;
|
onDraftTimeChange: (v: string) => void;
|
||||||
|
autoStartDraft: boolean;
|
||||||
|
onAutoStartDraftChange: (v: boolean) => void;
|
||||||
timerMode: "chess_clock" | "standard";
|
timerMode: "chess_clock" | "standard";
|
||||||
onTimerModeChange: (v: "chess_clock" | "standard") => void;
|
onTimerModeChange: (v: "chess_clock" | "standard") => void;
|
||||||
draftSpeed: string;
|
draftSpeed: string;
|
||||||
|
|
@ -150,6 +155,20 @@ export function DraftSetupSection({
|
||||||
Set this before starting the draft room.
|
Set this before starting the draft room.
|
||||||
{localTz && <> Times are in your local timezone ({localTz}).</>}
|
{localTz && <> Times are in your local timezone ({localTz}).</>}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-8 rounded-lg border p-5 sm:p-6">
|
<div className="space-y-8 rounded-lg border p-5 sm:p-6">
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type WizardSnapshot = {
|
||||||
draftRounds: number;
|
draftRounds: number;
|
||||||
draftDate: string;
|
draftDate: string;
|
||||||
draftTime: string;
|
draftTime: string;
|
||||||
|
autoStartDraft: boolean;
|
||||||
timerMode: "chess_clock" | "standard";
|
timerMode: "chess_clock" | "standard";
|
||||||
draftSpeed: string;
|
draftSpeed: string;
|
||||||
overnightMode: "none" | "league" | "per_user";
|
overnightMode: "none" | "league" | "per_user";
|
||||||
|
|
@ -36,7 +37,9 @@ export function saveWizardState(snapshot: WizardSnapshot) {
|
||||||
export function loadWizardState(): WizardSnapshot | null {
|
export function loadWizardState(): WizardSnapshot | null {
|
||||||
try {
|
try {
|
||||||
const raw = ss()?.getItem(WIZARD_SS_KEY);
|
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; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +58,7 @@ export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData {
|
||||||
if (s.draftDate && s.draftTime) {
|
if (s.draftDate && s.draftTime) {
|
||||||
fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString());
|
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("draftTimerMode", s.timerMode);
|
||||||
fd.append("draftSpeed", s.draftSpeed);
|
fd.append("draftSpeed", s.draftSpeed);
|
||||||
fd.append("overnightPauseMode", s.overnightMode);
|
fd.append("overnightPauseMode", s.overnightMode);
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ vi.mock("~/lib/auth.server", () => ({
|
||||||
vi.mock("~/models/commissioner", () => ({
|
vi.mock("~/models/commissioner", () => ({
|
||||||
isCommissioner: vi.fn(),
|
isCommissioner: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/draft-timer", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
deleteSeasonTimers: vi.fn(),
|
findUserById: vi.fn().mockResolvedValue(null),
|
||||||
initializeDraftTimers: vi.fn(),
|
getUserDisplayName: vi.fn().mockReturnValue(null),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/audit-log", () => ({
|
vi.mock("~/services/draft-autostart", () => ({
|
||||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
startDraft: vi.fn().mockResolvedValue({ success: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -28,11 +28,6 @@ vi.mock("~/models/audit-log", () => ({
|
||||||
const SEASON_ID = "season-1";
|
const SEASON_ID = "season-1";
|
||||||
const COMMISSIONER_ID = "commissioner-user-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> = {}) {
|
function makeSeason(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: SEASON_ID,
|
id: SEASON_ID,
|
||||||
|
|
@ -56,7 +51,7 @@ function makeRequest() {
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("draft.start action — timer initialization", () => {
|
describe("draft.start action", () => {
|
||||||
let mockDb: any;
|
let mockDb: any;
|
||||||
let mockSocketIO: any;
|
let mockSocketIO: any;
|
||||||
|
|
||||||
|
|
@ -76,81 +71,67 @@ describe("draft.start action — timer initialization", () => {
|
||||||
mockDb = {
|
mockDb = {
|
||||||
query: {
|
query: {
|
||||||
seasons: { findFirst: vi.fn() },
|
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");
|
const { database } = await import("~/database/context");
|
||||||
vi.mocked(database).mockReturnValue(mockDb);
|
vi.mocked(database).mockReturnValue(mockDb);
|
||||||
|
|
||||||
|
const { startDraft } = await import("~/services/draft-autostart");
|
||||||
|
vi.mocked(startDraft).mockResolvedValue({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("chess_clock mode", () => {
|
it("returns 401 when not authenticated", async () => {
|
||||||
it("initializes each team's timer to draftInitialTime", async () => {
|
const { auth } = await import("~/lib/auth.server");
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
vi.mocked(auth.api.getSession).mockResolvedValue(null);
|
||||||
makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
|
|
||||||
);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("standard mode", () => {
|
it("returns 404 when season not found", async () => {
|
||||||
it("initializes each team's timer to draftIncrementTime (the per-pick time)", async () => {
|
mockDb.query.seasons.findFirst.mockResolvedValue(null);
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
|
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
it("returns 403 when user is not a commissioner", async () => {
|
||||||
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
mockDb.query.seasons.findFirst.mockResolvedValue(makeSeason());
|
||||||
SEASON_ID,
|
const { isCommissioner } = await import("~/models/commissioner");
|
||||||
expect.any(Array),
|
vi.mocked(isCommissioner).mockResolvedValue(false);
|
||||||
30 // draftIncrementTime, not draftInitialTime
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores draftInitialTime — uses only increment for the starting clock", async () => {
|
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
// Even though initialTime=600, standard mode should use increment=45
|
expect(res.status).toBe(403);
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
});
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 600, draftIncrementTime: 45 })
|
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
const res = await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
|
|
||||||
SEASON_ID,
|
const { startDraft } = await import("~/services/draft-autostart");
|
||||||
expect.any(Array),
|
expect(vi.mocked(startDraft)).toHaveBeenCalledWith(
|
||||||
45 // increment only — initial time is irrelevant in standard mode
|
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 { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { deleteSeasonTimers, initializeDraftTimers } from "~/models/draft-timer";
|
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
import { logger } from "~/lib/logger";
|
import { startDraft } from "~/services/draft-autostart";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
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;
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|
@ -27,7 +27,6 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Get season details
|
|
||||||
const season = await db.query.seasons.findFirst({
|
const season = await db.query.seasons.findFirst({
|
||||||
where: eq(schema.seasons.id, seasonId),
|
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 });
|
return Response.json({ error: "Season not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is commissioner
|
|
||||||
if (!(await isCommissioner(season.leagueId, userId))) {
|
if (!(await isCommissioner(season.leagueId, userId))) {
|
||||||
return Response.json({ error: "Only commissioners can start the draft" }, { status: 403 });
|
return Response.json({ error: "Only commissioners can start the draft" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if draft already started
|
const user = await findUserById(userId);
|
||||||
if (season.status === "draft" || season.status === "active" || season.status === "completed") {
|
const actorDisplayName = user ? (getUserDisplayName(user) ?? userId) : userId;
|
||||||
return Response.json({ error: "Draft already started or completed" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate draft slots exist before modifying any state
|
const result = await startDraft({
|
||||||
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(
|
|
||||||
seasonId,
|
seasonId,
|
||||||
draftSlots.map((slot) => ({ id: slot.teamId })),
|
|
||||||
initialTime
|
|
||||||
);
|
|
||||||
|
|
||||||
await logCommissionerAction({
|
|
||||||
seasonId,
|
|
||||||
leagueId: season.leagueId,
|
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_started",
|
actorDisplayName,
|
||||||
details: { pickNumber: 1 },
|
db,
|
||||||
|
io: getSocketIO(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit socket event
|
if (!result.success) {
|
||||||
try {
|
const status =
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-started", {
|
result.error === "Draft already started or completed" ? 400
|
||||||
seasonId,
|
: result.error === "No draft slots found for this season" ? 400
|
||||||
currentPickNumber: 1,
|
: 500;
|
||||||
});
|
return Response.json({ error: result.error }, { status });
|
||||||
} catch (error) {
|
|
||||||
logger.error("Socket.IO error:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,12 @@ export default function DraftRoom() {
|
||||||
return () => clearInterval(id);
|
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 draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
|
||||||
const roomClosureCountdown = useMemo(() => {
|
const roomClosureCountdown = useMemo(() => {
|
||||||
if (!isDraftComplete) return null;
|
if (!isDraftComplete) return null;
|
||||||
|
|
@ -1342,6 +1348,8 @@ export default function DraftRoom() {
|
||||||
onStart={handleStartDraft}
|
onStart={handleStartDraft}
|
||||||
onPause={handlePauseDraft}
|
onPause={handlePauseDraft}
|
||||||
onResume={handleResumeDraft}
|
onResume={handleResumeDraft}
|
||||||
|
autoStartDraft={season.autoStartDraft}
|
||||||
|
secondsUntilAutoStart={secondsUntilAutoStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1544,9 +1552,17 @@ export default function DraftRoom() {
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{isCommissioner && season.status === "pre_draft" && (
|
{isCommissioner && season.status === "pre_draft" && (
|
||||||
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
|
<CommissionerDraftControls
|
||||||
Start Draft
|
seasonStatus={season.status}
|
||||||
</Button>
|
isDraftComplete={isDraftComplete}
|
||||||
|
isPaused={isPaused}
|
||||||
|
onStart={handleStartDraft}
|
||||||
|
onPause={handlePauseDraft}
|
||||||
|
onResume={handleResumeDraft}
|
||||||
|
buttonClassName="w-full min-h-[48px]"
|
||||||
|
autoStartDraft={season.autoStartDraft}
|
||||||
|
secondsUntilAutoStart={secondsUntilAutoStart}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userTeam && (
|
{userTeam && (
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,14 @@ export async function action(args: Route.ActionArgs) {
|
||||||
seasonUpdates.draftTimerMode = draftTimerMode;
|
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;
|
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
|
||||||
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
|
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
|
||||||
if (overnightPauseMode !== season.overnightPauseMode) {
|
if (overnightPauseMode !== season.overnightPauseMode) {
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
const [draftTime, setDraftTime] = useState<string>(
|
const [draftTime, setDraftTime] = useState<string>(
|
||||||
season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""
|
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)
|
// Draft order state (separate form, separate dirty flag)
|
||||||
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
||||||
|
|
@ -249,6 +250,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
setDraftRounds(season?.draftRounds || 20);
|
setDraftRounds(season?.draftRounds || 20);
|
||||||
setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined);
|
setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined);
|
||||||
setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "");
|
setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "");
|
||||||
|
setAutoStartDraft(season?.autoStartDraft ?? false);
|
||||||
setHasUnsavedSettingsChanges(false);
|
setHasUnsavedSettingsChanges(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -372,6 +374,8 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }}
|
onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }}
|
||||||
draftTime={draftTime}
|
draftTime={draftTime}
|
||||||
onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }}
|
onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }}
|
||||||
|
autoStartDraft={autoStartDraft}
|
||||||
|
onAutoStartDraftChange={(v) => { setAutoStartDraft(v); markDirty(); }}
|
||||||
timerMode={timerMode}
|
timerMode={timerMode}
|
||||||
onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }}
|
onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }}
|
||||||
draftSpeed={draftSpeed}
|
draftSpeed={draftSpeed}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Link, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
import { Link2 } from "lucide-react";
|
import { Link2, TriangleAlert } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
|
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
|
||||||
|
|
@ -173,6 +173,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</div>
|
||||||
</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" && (
|
{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="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">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const createMockSeason = (overrides: {
|
||||||
draftRounds: 20,
|
draftRounds: 20,
|
||||||
flexSpots: 0,
|
flexSpots: 0,
|
||||||
draftDateTime: null,
|
draftDateTime: null,
|
||||||
|
autoStartDraft: false,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 15,
|
draftIncrementTime: 15,
|
||||||
draftTimerMode: 'chess_clock' as const,
|
draftTimerMode: 'chess_clock' as const,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const mockSeason = {
|
||||||
draftRounds: 20,
|
draftRounds: 20,
|
||||||
flexSpots: 0,
|
flexSpots: 0,
|
||||||
draftDateTime: null,
|
draftDateTime: null,
|
||||||
|
autoStartDraft: false,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 15,
|
draftIncrementTime: 15,
|
||||||
draftTimerMode: 'chess_clock' as const,
|
draftTimerMode: 'chess_clock' as const,
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const templateId = formData.get("templateId");
|
const templateId = formData.get("templateId");
|
||||||
const draftRounds = formData.get("draftRounds");
|
const draftRounds = formData.get("draftRounds");
|
||||||
const draftDateTime = formData.get("draftDateTime");
|
const draftDateTime = formData.get("draftDateTime");
|
||||||
|
const autoStartDraft = formData.get("autoStartDraft") === "on" && !!(typeof draftDateTime === "string" && draftDateTime);
|
||||||
const draftSpeed = formData.get("draftSpeed");
|
const draftSpeed = formData.get("draftSpeed");
|
||||||
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
||||||
|
|
||||||
|
|
@ -222,6 +223,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
: null,
|
: null,
|
||||||
draftRounds: draftRoundsNum,
|
draftRounds: draftRoundsNum,
|
||||||
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
||||||
|
autoStartDraft,
|
||||||
draftInitialTime: draftInitialTimeNum,
|
draftInitialTime: draftInitialTimeNum,
|
||||||
draftIncrementTime: draftIncrementTimeNum,
|
draftIncrementTime: draftIncrementTimeNum,
|
||||||
draftTimerMode,
|
draftTimerMode,
|
||||||
|
|
@ -613,6 +615,8 @@ function Step3DraftSettings({
|
||||||
setDraftDate,
|
setDraftDate,
|
||||||
draftTime,
|
draftTime,
|
||||||
setDraftTime,
|
setDraftTime,
|
||||||
|
autoStartDraft,
|
||||||
|
setAutoStartDraft,
|
||||||
timerMode,
|
timerMode,
|
||||||
setTimerMode,
|
setTimerMode,
|
||||||
draftSpeed,
|
draftSpeed,
|
||||||
|
|
@ -637,6 +641,8 @@ function Step3DraftSettings({
|
||||||
setDraftDate: (v: string) => void;
|
setDraftDate: (v: string) => void;
|
||||||
draftTime: string;
|
draftTime: string;
|
||||||
setDraftTime: (v: string) => void;
|
setDraftTime: (v: string) => void;
|
||||||
|
autoStartDraft: boolean;
|
||||||
|
setAutoStartDraft: (v: boolean) => void;
|
||||||
timerMode: "chess_clock" | "standard";
|
timerMode: "chess_clock" | "standard";
|
||||||
setTimerMode: (v: "chess_clock" | "standard") => void;
|
setTimerMode: (v: "chess_clock" | "standard") => void;
|
||||||
draftSpeed: string;
|
draftSpeed: string;
|
||||||
|
|
@ -713,6 +719,19 @@ function Step3DraftSettings({
|
||||||
This can be changed before the draft starts.
|
This can be changed before the draft starts.
|
||||||
{localTz && <> Times are in your local timezone ({localTz}).</>}
|
{localTz && <> Times are in your local timezone ({localTz}).</>}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Timer Mode */}
|
{/* Timer Mode */}
|
||||||
|
|
@ -1025,6 +1044,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
|
||||||
const [draftRounds, setDraftRounds] = useState(20);
|
const [draftRounds, setDraftRounds] = useState(20);
|
||||||
const [draftDate, setDraftDate] = useState("");
|
const [draftDate, setDraftDate] = useState("");
|
||||||
const [draftTime, setDraftTime] = useState("");
|
const [draftTime, setDraftTime] = useState("");
|
||||||
|
const [autoStartDraft, setAutoStartDraft] = useState(false);
|
||||||
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
|
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
|
||||||
const [draftSpeed, setDraftSpeed] = useState("standard");
|
const [draftSpeed, setDraftSpeed] = useState("standard");
|
||||||
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
|
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none");
|
||||||
|
|
@ -1068,7 +1088,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
|
||||||
return buildFormDataFromSnapshot({
|
return buildFormDataFromSnapshot({
|
||||||
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
||||||
selectedSports: [...selectedSports],
|
selectedSports: [...selectedSports],
|
||||||
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
|
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
|
||||||
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
||||||
scoringPreset, scoringRules, userTimezone,
|
scoringPreset, scoringRules, userTimezone,
|
||||||
});
|
});
|
||||||
|
|
@ -1114,7 +1134,7 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
|
||||||
saveWizardState({
|
saveWizardState({
|
||||||
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
leagueName, teamCount, joinAsPlayer, selectedTemplate,
|
||||||
selectedSports: [...selectedSports],
|
selectedSports: [...selectedSports],
|
||||||
draftRounds, draftDate, draftTime, timerMode, draftSpeed,
|
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
|
||||||
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
overnightMode, overnightStart, overnightEnd, overnightTimezone,
|
||||||
scoringPreset, scoringRules, userTimezone,
|
scoringPreset, scoringRules, userTimezone,
|
||||||
});
|
});
|
||||||
|
|
@ -1204,6 +1224,8 @@ export default function NewLeague({ loaderData }: Route.ComponentProps) {
|
||||||
setDraftDate={setDraftDate}
|
setDraftDate={setDraftDate}
|
||||||
draftTime={draftTime}
|
draftTime={draftTime}
|
||||||
setDraftTime={setDraftTime}
|
setDraftTime={setDraftTime}
|
||||||
|
autoStartDraft={autoStartDraft}
|
||||||
|
setAutoStartDraft={setAutoStartDraft}
|
||||||
timerMode={timerMode}
|
timerMode={timerMode}
|
||||||
setTimerMode={(mode) => {
|
setTimerMode={(mode) => {
|
||||||
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,
|
draftRounds: 20,
|
||||||
flexSpots: 0,
|
flexSpots: 0,
|
||||||
draftDateTime: new Date('2025-12-01T19:00:00'),
|
draftDateTime: new Date('2025-12-01T19:00:00'),
|
||||||
|
autoStartDraft: false,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 15,
|
draftIncrementTime: 15,
|
||||||
currentPickNumber: 1,
|
currentPickNumber: 1,
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,7 @@ export const seasons = pgTable("seasons", {
|
||||||
draftRounds: integer("draft_rounds").notNull().default(20),
|
draftRounds: integer("draft_rounds").notNull().default(20),
|
||||||
flexSpots: integer("flex_spots").notNull().default(0),
|
flexSpots: integer("flex_spots").notNull().default(0),
|
||||||
draftDateTime: timestamp("draft_date_time"),
|
draftDateTime: timestamp("draft_date_time"),
|
||||||
|
autoStartDraft: boolean("auto_start_draft").notNull().default(false),
|
||||||
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
|
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
|
||||||
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
|
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
|
||||||
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),
|
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,
|
"when": 1778868190284,
|
||||||
"tag": "0107_outgoing_mikhail_rasputin",
|
"tag": "0107_outgoing_mikhail_rasputin",
|
||||||
"breakpoints": true
|
"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 * 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 type { InferSelectModel } from "drizzle-orm";
|
||||||
import { getSocketIO } from "./socket";
|
import { getSocketIO } from "./socket";
|
||||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||||
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
|
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
import { startDraft } from "~/services/draft-autostart";
|
||||||
|
|
||||||
let timerInterval: NodeJS.Timeout | null = null;
|
let timerInterval: NodeJS.Timeout | null = null;
|
||||||
let timerTickRunning = false;
|
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
|
* Update all active draft timers
|
||||||
* Called every second by the timer interval
|
* Called every second by the timer interval
|
||||||
*/
|
*/
|
||||||
async function updateDraftTimers(): Promise<void> {
|
async function updateDraftTimers(): Promise<void> {
|
||||||
|
await checkAndAutoStartDrafts();
|
||||||
|
|
||||||
const io = getSocketIO();
|
const io = getSocketIO();
|
||||||
|
|
||||||
// Get all active drafts
|
// Get all active drafts
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue