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({