diff --git a/app/models/__tests__/executeAutoPick.timer.test.ts b/app/models/__tests__/executeAutoPick.timer.test.ts
new file mode 100644
index 0000000..ade15eb
--- /dev/null
+++ b/app/models/__tests__/executeAutoPick.timer.test.ts
@@ -0,0 +1,352 @@
+/**
+ * Tests for executeAutoPick timer behavior across chess_clock and standard modes.
+ *
+ * chess_clock: auto-picks earn the increment (bank += increment) so a team that
+ * times out doesn't get permanently frozen at 0 on all future turns.
+ * standard: auto-picks reset the bank to exactly draftIncrementTime.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { executeAutoPick } from "../draft-utils";
+
+// ── Module-level mocks ────────────────────────────────────────────────────────
+
+vi.mock("~/server/socket", () => ({
+ getSocketIO: vi.fn(),
+}));
+
+vi.mock("~/models/draft-pick", () => ({
+ getDraftPicksWithSports: vi.fn(),
+ getTeamDraftPicksWithSports: vi.fn(),
+ isParticipantDrafted: vi.fn(),
+}));
+
+vi.mock("~/models/participant", () => ({
+ getParticipantsForSeasonWithSports: vi.fn(),
+}));
+
+vi.mock("~/models/season-sport", () => ({
+ getSeasonSportsSimple: vi.fn(),
+}));
+
+vi.mock("~/models/draft-queue", () => ({
+ getTeamQueue: vi.fn(),
+ getAllQueuesForSeason: vi.fn(),
+}));
+
+vi.mock("~/lib/draft-eligibility", () => ({
+ calculateDraftEligibility: vi.fn(),
+}));
+
+// database context not needed — we pass db directly to executeAutoPick
+vi.mock("~/database/context");
+
+import { getDraftPicksWithSports, getTeamDraftPicksWithSports, isParticipantDrafted } from "~/models/draft-pick";
+import { getParticipantsForSeasonWithSports } from "~/models/participant";
+import { getSeasonSportsSimple } from "~/models/season-sport";
+import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
+import { calculateDraftEligibility } from "~/lib/draft-eligibility";
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const SEASON_ID = "season-1";
+const TEAM_ID = "team-1";
+const NEXT_TEAM_ID = "team-2";
+const PARTICIPANT_ID = "p-1";
+const SPORT_ID = "sport-1";
+
+const mockParticipantForQueue = {
+ id: PARTICIPANT_ID,
+ name: "Test Player",
+ sportsSeason: { sport: { id: SPORT_ID, name: "NFL" } },
+};
+
+const mockParticipantFull = {
+ id: PARTICIPANT_ID,
+ name: "Test Player",
+ sportsSeason: {
+ id: "ss-1",
+ sport: { id: SPORT_ID, name: "NFL" },
+ },
+};
+
+const mockDraftPick = {
+ id: "pick-1",
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ participantId: PARTICIPANT_ID,
+ pickNumber: 1,
+ round: 1,
+ pickInRound: 1,
+ pickedByUserId: "",
+ pickedByType: "auto",
+};
+
+const mockDraftSlots = [
+ {
+ id: "slot-1",
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ draftOrder: 1,
+ team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" },
+ },
+ {
+ id: "slot-2",
+ seasonId: SEASON_ID,
+ teamId: NEXT_TEAM_ID,
+ draftOrder: 2,
+ team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
+ },
+];
+
+function makeSeason(overrides: Record = {}) {
+ return {
+ id: SEASON_ID,
+ leagueId: "league-1",
+ status: "draft",
+ draftRounds: 3,
+ draftInitialTime: 120,
+ draftIncrementTime: 15,
+ draftTimerMode: "chess_clock",
+ currentPickNumber: 1,
+ draftPaused: false,
+ ...overrides,
+ };
+}
+
+/**
+ * Build a mock DB for executeAutoPick.
+ *
+ * The `returning` mock is left unconfigured so individual tests can set up
+ * the sequence of values they expect (pick insert, then timer update).
+ */
+function makeMockDb(seasonOverrides: Record = {}) {
+ const mockDb: any = {
+ query: {
+ // Race-condition guard — no existing pick at this slot
+ draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
+ seasons: { findFirst: vi.fn().mockResolvedValue(makeSeason(seasonOverrides)) },
+ draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
+ participants: {
+ // findMany: used by autoPickForTeam queue path
+ findMany: vi.fn().mockResolvedValue([mockParticipantForQueue]),
+ // findFirst: used by executeAutoPick to fetch full participant details
+ findFirst: vi.fn().mockResolvedValue(mockParticipantFull),
+ },
+ draftTimers: {
+ // Default: timer is at 0 (expired), which is the normal auto-pick trigger state
+ findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 0 }),
+ },
+ // checkAndTriggerNextAutodraft reads this; returning null prevents any recursion
+ autodraftSettings: { findFirst: vi.fn().mockResolvedValue(null) },
+ },
+ insert: vi.fn().mockReturnThis(),
+ values: vi.fn().mockReturnThis(),
+ returning: vi.fn(), // configured per-test
+ update: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ delete: vi.fn().mockReturnThis(),
+ };
+ return mockDb;
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe("executeAutoPick — timer mode behavior", () => {
+ let mockSocketIO: any;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
+ const { getSocketIO } = await import("~/server/socket");
+ vi.mocked(getSocketIO).mockReturnValue(mockSocketIO);
+
+ // autoPickForTeam external dependencies — return minimal happy-path values
+ vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
+ vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
+ vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
+ vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
+ vi.mocked(calculateDraftEligibility).mockReturnValue({
+ eligibleSportIds: new Set([SPORT_ID]),
+ ineligibleReasons: {},
+ } as ReturnType);
+
+ // Queue has one item so autoPickForTeam selects it without hitting the EV path
+ vi.mocked(getTeamQueue).mockResolvedValue([
+ { id: "q-1", participantId: PARTICIPANT_ID, queuePosition: 1 } as any,
+ ]);
+ vi.mocked(isParticipantDrafted).mockResolvedValue(false);
+
+ // pruneIneligibleQueueItems calls getAllQueuesForSeason — return empty Map to skip pruning
+ vi.mocked(getAllQueuesForSeason).mockResolvedValue(new Map() as any);
+ });
+
+ // ── chess_clock mode ────────────────────────────────────────────────────────
+
+ describe("chess_clock mode", () => {
+ it("emits timer-update with bank + increment after a timer-triggered pick", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
+ // Timer expired at 0; 0 + 15 = 15 after the increment
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick]) // insert draft pick
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 })
+ );
+ });
+
+ it("writes the timer increment to the DB", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
+ });
+
+ it("applies the configured increment, not a hard-coded default", async () => {
+ // Use a non-default increment of 30 to confirm the value is read from the season
+ const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 30 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("returns success after the pick", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
+
+ const result = await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(result.success).toBe(true);
+ });
+ });
+
+ // ── standard mode ───────────────────────────────────────────────────────────
+
+ describe("standard mode", () => {
+ it("emits timer-update with exactly draftIncrementTime after a timer-triggered pick", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("writes the timer reset to the DB", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockDb.update).toHaveBeenCalledTimes(2);
+ });
+
+ it("uses custom draftIncrementTime when configured", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 90 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
+
+ await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
+ );
+ });
+
+ it("returns success after the pick", async () => {
+ const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ const result = await executeAutoPick({
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ pickNumber: 1,
+ triggeredBy: "timer",
+ autodraftSettings: null,
+ db: mockDb,
+ });
+
+ expect(result.success).toBe(true);
+ });
+ });
+});
diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts
index 7cd4a7d..c93611e 100644
--- a/app/models/draft-utils.ts
+++ b/app/models/draft-utils.ts
@@ -645,9 +645,10 @@ export async function executeAutoPick(params: {
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
- // Auto picks do not earn bank time.
+ // Update the team's timer after the auto-pick.
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
- // Chess clock mode: timer already reached 0; leave the bank frozen at 0.
+ // Chess clock mode: add the increment so the team starts their next turn with some time
+ // (without this, a single timeout would permanently freeze their bank at 0).
let emitTimeRemaining: number;
if (season.draftTimerMode === "standard") {
@@ -666,10 +667,26 @@ export async function executeAutoPick(params: {
`[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
);
} else {
- // Chess clock: no DB update — auto picks don't earn increment.
- emitTimeRemaining = currentTimer?.timeRemaining ?? 0;
+ // Chess clock: add the increment (atomic add, same as a manual pick).
+ const [updatedTimer] = await db
+ .update(schema.draftTimers)
+ .set({
+ timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(schema.draftTimers.seasonId, seasonId),
+ eq(schema.draftTimers.teamId, teamId)
+ )
+ )
+ .returning();
+ emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
+ if (!updatedTimer) {
+ await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining });
+ }
logger.log(
- `[AutoPick] Chess clock pick for team ${teamId}, bank frozen at ${emitTimeRemaining}s (no increment)`
+ `[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)`
);
}
diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts
index e26aada..502e876 100644
--- a/app/routes/api/__tests__/draft.force-manual-pick.test.ts
+++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts
@@ -348,43 +348,38 @@ describe("draft.force-manual-pick action", () => {
// ── Timer behavior ─────────────────────────────────────────────────────────
//
- // Force manual picks are commissioner actions — they do NOT earn the team
- // any extra time bank, regardless of timer mode.
+ // Force manual picks earn the increment the same as any other pick.
//
- // chess_clock: bank is frozen (no DB update); current value read for socket emit.
- // standard: bank resets to draftIncrementTime (the per-pick time) for the next turn.
+ // chess_clock: bank += draftIncrementTime (atomic add).
+ // standard: bank resets to draftIncrementTime for the next turn.
// Next team's bank is always left completely untouched.
describe("timer behavior", () => {
describe("chess_clock mode", () => {
- it("does NOT add increment to the picking team's bank", async () => {
- // Timer has 75s (from beforeEach mock); forced pick → bank stays at 75s
+ it("adds the increment to the picking team's bank", async () => {
+ // Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
+
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
- expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
);
});
- it("emits timer-update with the frozen (unchanged) bank value", async () => {
+ it("writes the incremented timer to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
+
await action({ request: defaultPickRequest(), params: {}, context: ctx });
- expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
- seasonId: SEASON_ID,
- teamId: TEAM_ID,
- timeRemaining: 75,
- currentPickNumber: 2,
- });
- });
-
- it("reads the picking team's timer but does not write it", async () => {
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
-
- // findFirst called once (picking team's current value for the emit)
- expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1);
- // update called once only for season.currentPickNumber — NOT for the timer
- expect(mockDb.update).toHaveBeenCalledTimes(1);
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
});
});
@@ -409,15 +404,16 @@ describe("draft.force-manual-pick action", () => {
);
});
- it("writes the DB timer (reset) and updates the season pick number", async () => {
+ it("writes the timer reset to the DB", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: defaultPickRequest(), params: {}, context: ctx });
- // Two updates: timer reset + season.currentPickNumber
- expect(mockDb.update).toHaveBeenCalledTimes(2);
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
});
});
diff --git a/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
new file mode 100644
index 0000000..ba3aabd
--- /dev/null
+++ b/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
@@ -0,0 +1,355 @@
+/**
+ * Tests for draft.force-manual-pick timer behavior across chess_clock and standard modes.
+ *
+ * chess_clock: force picks earn the increment just like any other pick (bank += increment)
+ * standard: force picks always reset the bank to exactly draftIncrementTime
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { RouterContextProvider } from "react-router";
+import { action } from "~/routes/api/draft.force-manual-pick";
+
+const ctx = {} as unknown as RouterContextProvider;
+
+vi.mock("~/database/context");
+vi.mock("~/server/socket", () => ({
+ getSocketIO: vi.fn(),
+}));
+vi.mock("@clerk/react-router/server", () => ({
+ getAuth: vi.fn(),
+}));
+vi.mock("~/models/draft-pick", () => ({
+ getDraftPicksWithSports: vi.fn(),
+ getTeamDraftPicksWithSports: vi.fn(),
+}));
+vi.mock("~/models/participant", () => ({
+ getParticipantsForSeasonWithSports: vi.fn(),
+}));
+vi.mock("~/models/season-sport", () => ({
+ getSeasonSportsSimple: vi.fn(),
+}));
+vi.mock("~/lib/draft-eligibility", () => ({
+ calculateDraftEligibility: vi.fn(),
+}));
+vi.mock("~/models/draft-utils", () => ({
+ checkAndTriggerNextAutodraft: vi.fn(),
+ calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
+}));
+vi.mock("~/models/user", () => ({
+ isUserAdminByClerkId: vi.fn(),
+}));
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const SEASON_ID = "season-1";
+const TEAM_ID = "team-1";
+const NEXT_TEAM_ID = "team-2";
+const PARTICIPANT_ID = "participant-1";
+const COMMISSIONER_ID = "commissioner-user-1";
+const ADMIN_ID = "admin-user-1";
+const SPORT_ID = "sport-nfl";
+
+const mockParticipant = {
+ id: PARTICIPANT_ID,
+ name: "Patrick Mahomes",
+ sportsSeason: {
+ id: "sports-season-1",
+ sport: { id: SPORT_ID, name: "NFL" },
+ },
+};
+
+const mockDraftPick = {
+ id: "pick-1",
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ participantId: PARTICIPANT_ID,
+ pickNumber: 1,
+ round: 1,
+ pickInRound: 1,
+ pickedByUserId: COMMISSIONER_ID,
+ pickedByType: "commissioner",
+};
+
+const mockDraftSlots = [
+ {
+ id: "slot-1",
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ draftOrder: 1,
+ team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" },
+ },
+ {
+ id: "slot-2",
+ seasonId: SEASON_ID,
+ teamId: NEXT_TEAM_ID,
+ draftOrder: 2,
+ team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
+ },
+];
+
+function makeSeason(overrides: Record = {}) {
+ return {
+ id: SEASON_ID,
+ leagueId: "league-1",
+ status: "draft",
+ draftRounds: 3,
+ draftInitialTime: 120,
+ draftIncrementTime: 30,
+ draftTimerMode: "chess_clock",
+ currentPickNumber: 1,
+ draftPaused: false,
+ ...overrides,
+ };
+}
+
+function makeRequest() {
+ const formData = new FormData();
+ formData.append("seasonId", SEASON_ID);
+ formData.append("teamId", TEAM_ID);
+ formData.append("participantId", PARTICIPANT_ID);
+ formData.append("pickNumber", "1");
+ return new Request("http://localhost/api/draft/force-manual-pick", {
+ method: "POST",
+ body: formData,
+ });
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe("draft.force-manual-pick action — timer mode behavior", () => {
+ let mockDb: any;
+ let mockSocketIO: any;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ // Default: authenticated as commissioner
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
+
+ const { isUserAdminByClerkId } = await import("~/models/user");
+ vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
+
+ mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
+ const socketModule = await import("~/server/socket");
+ vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
+
+ const { getDraftPicksWithSports, getTeamDraftPicksWithSports } = await import("~/models/draft-pick");
+ vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
+ vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
+
+ const { getParticipantsForSeasonWithSports } = await import("~/models/participant");
+ vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
+
+ const { getSeasonSportsSimple } = await import("~/models/season-sport");
+ vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
+
+ const { calculateDraftEligibility } = await import("~/lib/draft-eligibility");
+ vi.mocked(calculateDraftEligibility).mockReturnValue({
+ eligibleSportIds: new Set([SPORT_ID]),
+ ineligibleReasons: {},
+ } as any);
+
+ const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils");
+ vi.mocked(checkAndTriggerNextAutodraft).mockResolvedValue(undefined);
+
+ mockDb = {
+ query: {
+ seasons: { findFirst: vi.fn() },
+ commissioners: {
+ findFirst: vi.fn().mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }),
+ },
+ draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
+ participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
+ draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
+ draftTimers: {
+ findFirst: vi.fn().mockResolvedValue({ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 75 }),
+ },
+ },
+ insert: vi.fn().mockReturnThis(),
+ values: vi.fn().mockReturnThis(),
+ returning: vi.fn().mockResolvedValue([mockDraftPick]),
+ update: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ delete: vi.fn().mockReturnThis(),
+ };
+
+ const { database } = await import("~/database/context");
+ vi.mocked(database).mockReturnValue(mockDb);
+ });
+
+ // ── chess_clock mode ────────────────────────────────────────────────────────
+
+ describe("chess_clock mode", () => {
+ beforeEach(() => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
+ );
+ });
+
+ describe("commissioner force pick", () => {
+ it("emits timer-update with bank + increment", async () => {
+ // Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
+ );
+ });
+
+ it("writes the timer update to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ // Two DB updates: timer row + season.currentPickNumber
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
+ });
+
+ it("accumulates a larger bank when more time was remaining", async () => {
+ mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
+ // 100 + 30 = 130
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
+ );
+ });
+ });
+
+ describe("admin force pick", () => {
+ beforeEach(async () => {
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
+
+ const { isUserAdminByClerkId } = await import("~/models/user");
+ vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
+
+ // Admin is not a commissioner
+ mockDb.query.commissioners.findFirst.mockResolvedValue(null);
+ });
+
+ it("emits timer-update with bank + increment", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
+ );
+ });
+
+ it("writes the timer update to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
+ });
+ });
+ });
+
+ // ── standard mode ───────────────────────────────────────────────────────────
+
+ describe("standard mode", () => {
+ beforeEach(() => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 })
+ );
+ });
+
+ describe("commissioner force pick", () => {
+ it("resets bank to exactly draftIncrementTime", async () => {
+ // Pre-pick bank doesn't matter — standard always resets
+ mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("does not accumulate time even when bank was large", async () => {
+ // Team had 25s left; standard mode resets to increment, never adds to prior balance
+ mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+ });
+
+ describe("admin force pick", () => {
+ beforeEach(async () => {
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
+
+ const { isUserAdminByClerkId } = await import("~/models/user");
+ vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
+
+ mockDb.query.commissioners.findFirst.mockResolvedValue(null);
+ });
+
+ it("resets bank to exactly draftIncrementTime", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("uses custom draftIncrementTime when configured", async () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
+ );
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
+ );
+ });
+ });
+ });
+});
diff --git a/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts b/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
index c8c0844..1d7ddde 100644
--- a/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
+++ b/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
@@ -1,8 +1,8 @@
/**
* Tests for draft.make-pick timer behavior across chess_clock and standard modes.
*
- * chess_clock: after a pick, the team's remaining bank += increment (carry-over)
- * standard: after a pick, the team's bank resets to exactly the increment
+ * chess_clock: after ANY pick (owner, commissioner, admin), bank += increment
+ * standard: after ANY pick, bank resets to exactly draftIncrementTime
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { RouterContextProvider } from "react-router";
@@ -39,13 +39,15 @@ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(),
}));
-// ── Fixtures ─────────────────────────────────────────────────────────────────
+// ── Fixtures ──────────────────────────────────────────────────────────────────
const SEASON_ID = "season-1";
const TEAM_ID = "team-1";
const NEXT_TEAM_ID = "team-2";
const PARTICIPANT_ID = "participant-1";
const OWNER_ID = "owner-user-1";
+const COMMISSIONER_ID = "commissioner-user-1";
+const ADMIN_ID = "admin-user-1";
const SPORT_ID = "sport-nfl";
const mockParticipant = {
@@ -69,6 +71,7 @@ const mockDraftPick = {
pickedByType: "owner",
};
+// Two-team draft; team-1 picks first.
const mockDraftSlots = [
{
id: "slot-1",
@@ -111,7 +114,7 @@ function makeRequest() {
});
}
-// ── Tests ─────────────────────────────────────────────────────────────────────
+// ── Tests ──────────────────────────────────────────────────────────────────────
describe("draft.make-pick action — timer mode behavior", () => {
let mockDb: any;
@@ -156,7 +159,7 @@ describe("draft.make-pick action — timer mode behavior", () => {
draftPicks: { findFirst: vi.fn().mockResolvedValue(null) },
participants: { findFirst: vi.fn().mockResolvedValue(mockParticipant) },
draftSlots: { findMany: vi.fn().mockResolvedValue(mockDraftSlots) },
- draftTimers: { findFirst: vi.fn().mockResolvedValue({ timeRemaining: 75 }) },
+ draftTimers: { findFirst: vi.fn().mockResolvedValue({ id: "timer-1", timeRemaining: 75 }) },
draftQueue: { findMany: vi.fn().mockResolvedValue([]) },
},
insert: vi.fn().mockReturnThis(),
@@ -172,16 +175,23 @@ describe("draft.make-pick action — timer mode behavior", () => {
vi.mocked(database).mockReturnValue(mockDb);
});
+ // ── chess_clock mode ────────────────────────────────────────────────────────
+
describe("chess_clock mode", () => {
- describe("owner pick — earns the increment", () => {
- it("emits timer-update with the incremented balance (bank + increment)", async () => {
- mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
- );
- // Simulate DB returning previous 75s + 30s increment = 105s
+ beforeEach(() => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
+ );
+ });
+
+ describe("owner pick", () => {
+ // Auth default is OWNER_ID (team owner), no extra setup needed.
+
+ it("emits timer-update with bank + increment", async () => {
+ // DB returns 75 + 30 = 105 after the atomic add
mockDb.returning
- .mockResolvedValueOnce([mockDraftPick]) // insert draft pick
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); // update timer
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
@@ -191,14 +201,12 @@ describe("draft.make-pick action — timer mode behavior", () => {
);
});
- it("picks with more time remaining accumulate a larger bank", async () => {
- mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
- );
- // Team had 100s; 100 + 30 = 130s
+ it("accumulates a larger bank when more time was remaining", async () => {
+ mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
+ // 100 + 30 = 130
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
@@ -207,78 +215,120 @@ describe("draft.make-pick action — timer mode behavior", () => {
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
);
});
+
+ it("writes the timer update to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
+ });
});
- describe("commissioner pick — no increment earned", () => {
- const COMMISSIONER_ID = "commissioner-user-1";
-
+ describe("commissioner pick", () => {
beforeEach(async () => {
- // Auth as commissioner (not the team owner)
const { getAuth } = await import("@clerk/react-router/server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
- // Commissioner record exists
- mockDb.query.commissioners.findFirst.mockResolvedValue({
- id: "c-1",
- userId: COMMISSIONER_ID,
- });
- // Team is owned by someone else
+
+ mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
+ // Commissioner does not own the team on the clock
mockDb.query.draftSlots.findMany.mockResolvedValue([
{
- id: "slot-1",
- seasonId: SEASON_ID,
- teamId: TEAM_ID,
- draftOrder: 1,
- team: { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "different-owner" },
- },
- {
- id: "slot-2",
- seasonId: SEASON_ID,
- teamId: NEXT_TEAM_ID,
- draftOrder: 2,
- team: { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" },
+ ...mockDraftSlots[0],
+ team: { ...mockDraftSlots[0].team, ownerId: "someone-else" },
},
+ mockDraftSlots[1],
]);
});
- it("emits timer-update with the frozen (unchanged) bank — no increment added", async () => {
- mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 })
- );
- // timerSnapshot has 75s remaining; commissioner picks → should still be 75s
- mockDb.query.draftTimers.findFirst.mockResolvedValue({ timeRemaining: 75 });
- mockDb.returning.mockResolvedValueOnce([mockDraftPick]);
+ it("emits timer-update with bank + increment", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
- expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
);
});
- it("does not write the timer to the DB (no increment update)", async () => {
- mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "chess_clock", draftIncrementTime: 30 })
- );
- mockDb.returning.mockResolvedValueOnce([mockDraftPick]);
+ it("writes the timer update to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
- // Only one update: season.currentPickNumber — the timer row is NOT written
- expect(mockDb.update).toHaveBeenCalledTimes(1);
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
+ });
+ });
+
+ describe("admin pick", () => {
+ beforeEach(async () => {
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
+
+ const { isUserAdminByClerkId } = await import("~/models/user");
+ vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
+
+ // Admin does not own the team on the clock
+ mockDb.query.draftSlots.findMany.mockResolvedValue([
+ {
+ ...mockDraftSlots[0],
+ team: { ...mockDraftSlots[0].team, ownerId: "someone-else" },
+ },
+ mockDraftSlots[1],
+ ]);
+ });
+
+ it("emits timer-update with bank + increment", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
+ );
+ });
+
+ it("writes the timer update to the DB", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockDb.set).toHaveBeenCalledWith(
+ expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
+ );
});
});
});
+ // ── standard mode ───────────────────────────────────────────────────────────
+
describe("standard mode", () => {
- it("emits timer-update with exactly draftIncrementTime regardless of remaining bank", async () => {
+ beforeEach(() => {
mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 30, draftIncrementTime: 30 })
);
- // Simulate DB resetting to exactly 30s (the increment), ignoring prior bank
+ });
+
+ it("owner pick — resets bank to exactly draftIncrementTime", async () => {
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
@@ -288,37 +338,78 @@ describe("draft.make-pick action — timer mode behavior", () => {
);
});
- it("fast pickers do NOT accumulate time — always resets to increment", async () => {
- mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
- );
- // Team picked quickly with 90s remaining — in standard mode they still reset to 30s
+ it("owner pick — fast picker does NOT accumulate time (bank never exceeds increment)", async () => {
+ // Team had 25s left (picked quickly); standard mode ignores prior balance
+ mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
await action({ request: makeRequest(), params: {}, context: ctx });
- // Must be 30 (increment reset), not 120 (initialTime) or 120 (90+30)
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
);
});
- it("still returns 200 on a successful pick", async () => {
+ it("commissioner pick — resets bank to exactly draftIncrementTime", async () => {
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
+ mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
+ mockDb.query.draftSlots.findMany.mockResolvedValue([
+ { ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
+ mockDraftSlots[1],
+ ]);
+
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("admin pick — resets bank to exactly draftIncrementTime", async () => {
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
+ const { isUserAdminByClerkId } = await import("~/models/user");
+ vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
+ mockDb.query.draftSlots.findMany.mockResolvedValue([
+ { ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
+ mockDraftSlots[1],
+ ]);
+
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("uses custom draftIncrementTime when configured", async () => {
mockDb.query.seasons.findFirst.mockResolvedValue(
- makeSeason({ draftTimerMode: "standard", draftIncrementTime: 45 })
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 90, draftIncrementTime: 90 })
);
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 45 }]);
+ .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
- const response = await action({ request: makeRequest(), params: {}, context: ctx });
+ await action({ request: makeRequest(), params: {}, context: ctx });
- expect(response.status).toBe(200);
- const data = await response.json();
- expect(data.success).toBe(true);
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
+ );
});
});
});
diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts
index 2767be1..174bd62 100644
--- a/app/routes/api/draft.force-manual-pick.ts
+++ b/app/routes/api/draft.force-manual-pick.ts
@@ -146,14 +146,6 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: reason }, { status: 400 });
}
- // Snapshot the team's time bank before any writes (used by chess clock branch below).
- const timerSnapshot = await db.query.draftTimers.findFirst({
- where: and(
- eq(schema.draftTimers.seasonId, seasonId),
- eq(schema.draftTimers.teamId, teamId)
- ),
- });
-
// Create the draft pick
const [draftPick] = await db
.insert(schema.draftPicks)
@@ -174,30 +166,26 @@ export async function action(args: ActionFunctionArgs) {
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
- // Commissioner force picks do not earn bank time.
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
- // Chess clock mode: leave the bank frozen — no DB update, use the pre-pick snapshot.
+ // Chess clock: add the increment to the bank (same as any other pick type).
const incrementTime = season.draftIncrementTime || 30;
- let newTimeRemaining: number;
+ const timerSql = season.draftTimerMode === "standard"
+ ? sql`${incrementTime}`
+ : sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`;
- if (season.draftTimerMode === "standard") {
- const [updatedTimer] = await db
- .update(schema.draftTimers)
- .set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
- .where(
- and(
- eq(schema.draftTimers.seasonId, seasonId),
- eq(schema.draftTimers.teamId, teamId)
- )
+ const [updatedTimer] = await db
+ .update(schema.draftTimers)
+ .set({ timeRemaining: timerSql, updatedAt: new Date() })
+ .where(
+ and(
+ eq(schema.draftTimers.seasonId, seasonId),
+ eq(schema.draftTimers.teamId, teamId)
)
- .returning();
- newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
- if (!updatedTimer) {
- await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
- }
- } else {
- // Chess clock: bank is frozen — reuse the pre-pick snapshot, no DB write needed.
- newTimeRemaining = timerSnapshot?.timeRemaining ?? 0;
+ )
+ .returning();
+ const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
+ if (!updatedTimer) {
+ await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
}
// Emit timer update to all clients
diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts
index 62fb94a..247a9e9 100644
--- a/app/routes/api/draft.make-pick.ts
+++ b/app/routes/api/draft.make-pick.ts
@@ -205,8 +205,7 @@ export async function action(args: ActionFunctionArgs) {
// Update the picking team's timer after their pick.
// Standard mode: always reset to the per-pick time, regardless of who picked.
- // Chess clock, owner pick: add the increment (reward for picking promptly).
- // Chess clock, commissioner/admin pick: no change — forced picks don't earn bank time.
+ // Chess clock: always add the increment (any pick type — owner, commissioner, or admin).
const incrementTime = season.draftIncrementTime || 30;
let newTimeRemaining: number;
@@ -225,8 +224,8 @@ export async function action(args: ActionFunctionArgs) {
)
.returning();
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
- } else if (isTeamOwner) {
- // Chess clock, owner pick: earn the increment (atomic add).
+ } else {
+ // Chess clock: earn the increment regardless of who made the pick (atomic add).
[updatedTimer] = await db
.update(schema.draftTimers)
.set({
@@ -241,13 +240,10 @@ export async function action(args: ActionFunctionArgs) {
)
.returning();
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
- } else {
- // Chess clock, commissioner/admin pick: bank is frozen — no DB update needed.
- newTimeRemaining = timerSnapshot?.timeRemaining ?? 0;
}
- // If the timer row didn't exist yet, seed it (standard and chess clock owner branches only).
- if (!updatedTimer && (season.draftTimerMode === "standard" || isTeamOwner)) {
+ // If the timer row didn't exist yet, seed it.
+ if (!updatedTimer) {
await db.insert(schema.draftTimers).values({
seasonId,
teamId: currentDraftSlot.teamId,
diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx
index 8962724..8c5a183 100644
--- a/app/routes/leagues/$leagueId.tsx
+++ b/app/routes/leagues/$leagueId.tsx
@@ -429,6 +429,14 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{season.draftRounds}
+
+
+ Draft Timer Mode
+
+
+ {season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
+
+
Sports Selected
diff --git a/server/timer.ts b/server/timer.ts
index 4db7df1..09d0c98 100644
--- a/server/timer.ts
+++ b/server/timer.ts
@@ -105,7 +105,10 @@ async function updateDraftTimers(): Promise {
logger.warn(
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
);
- const initialTime = season.draftInitialTime || 120;
+ // Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
+ const initialTime = season.draftTimerMode === "standard"
+ ? (season.draftIncrementTime || 30)
+ : (season.draftInitialTime || 120);
await db
.insert(schema.draftTimers)
.values({