diff --git a/app/lib/draft-timer.ts b/app/lib/draft-timer.ts
index 5793e46..832675f 100644
--- a/app/lib/draft-timer.ts
+++ b/app/lib/draft-timer.ts
@@ -41,6 +41,34 @@ export function calculateTimeAfterPick(
return bankTime + incrementTime;
}
+/**
+ * Converts a draftSpeed form value + timerMode into DB time fields.
+ *
+ * Standard mode: speed is raw seconds (the per-pick time); both fields equal it.
+ * Chess clock mode: speed is a named preset that maps to (initial bank, increment).
+ */
+export function parseDraftSpeed(
+ draftSpeed: string | null,
+ draftTimerMode: "chess_clock" | "standard"
+): { draftInitialTime: number; draftIncrementTime: number } {
+ if (draftTimerMode === "standard") {
+ const seconds = parseInt(draftSpeed ?? "", 10);
+ const time = isNaN(seconds) ? 90 : seconds;
+ return { draftInitialTime: time, draftIncrementTime: time };
+ }
+
+ switch (draftSpeed) {
+ case "fast":
+ return { draftInitialTime: 60, draftIncrementTime: 10 };
+ case "slow":
+ return { draftInitialTime: 28800, draftIncrementTime: 3600 };
+ case "very-slow":
+ return { draftInitialTime: 43200, draftIncrementTime: 3600 };
+ default:
+ return { draftInitialTime: 120, draftIncrementTime: 15 };
+ }
+}
+
/**
* Returns the Tailwind colour class(es) for a timer value.
*
diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts
index 22cde54..7f7eedb 100644
--- a/app/models/draft-utils.ts
+++ b/app/models/draft-utils.ts
@@ -644,36 +644,43 @@ export async function executeAutoPick(params: {
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
- // Add increment to the team that just picked (post-pick reward).
- // Atomic update so the timer loop cannot overwrite this via a concurrent decrement.
- 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();
+ // Auto picks do not earn bank time.
+ // 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.
+ let emitTimeRemaining: number;
- if (updatedTimer) {
+ 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)
+ )
+ )
+ .returning();
+ emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
console.log(
- `[AutoPick] Added ${incrementTime}s increment to team ${teamId} after pick → ${updatedTimer.timeRemaining}s`
+ `[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
);
- try {
- getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
- seasonId,
- teamId,
- timeRemaining: updatedTimer.timeRemaining,
- currentPickNumber: nextPickNumber,
- });
- } catch (error) {
- console.error("[AutoPick] Socket.IO timer-update error:", error);
- }
+ } else {
+ // Chess clock: no DB update — auto picks don't earn increment.
+ emitTimeRemaining = currentTimer?.timeRemaining ?? 0;
+ console.log(
+ `[AutoPick] Chess clock pick for team ${teamId}, bank frozen at ${emitTimeRemaining}s (no increment)`
+ );
+ }
+
+ try {
+ getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
+ seasonId,
+ teamId,
+ timeRemaining: emitTimeRemaining,
+ currentPickNumber: nextPickNumber,
+ });
+ } catch (error) {
+ console.error("[AutoPick] Socket.IO timer-update error:", error);
}
// Next team's timer is unchanged — their bank carries forward as-is
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 4164fa5..e26aada 100644
--- a/app/routes/api/__tests__/draft.force-manual-pick.test.ts
+++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts
@@ -48,6 +48,7 @@ const mockSeason = {
draftRounds: 3,
draftInitialTime: 120,
draftIncrementTime: 30,
+ draftTimerMode: "chess_clock",
currentPickNumber: 1,
draftPaused: false,
};
@@ -347,86 +348,82 @@ describe("draft.force-manual-pick action", () => {
// ── Timer behavior ─────────────────────────────────────────────────────────
//
- // All three pick paths (user pick, force auto, force manual) must treat timers
- // identically:
- // 1. The team that was picked FOR gets +draftIncrementTime added to their bank.
- // 2. The next team's bank is left completely untouched.
+ // Force manual picks are commissioner actions — they do NOT earn the team
+ // any extra time bank, regardless of timer mode.
+ //
+ // 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.
+ // Next team's bank is always left completely untouched.
describe("timer behavior", () => {
- it("adds draftIncrementTime to the picking team's time bank", async () => {
- // Timer has 75s; increment is 30s → DB atomically returns new balance: 105s
- mockDb.returning
- .mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
+ 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
+ await action({ request: defaultPickRequest(), params: {}, context: ctx });
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
+ );
+ });
- expect(mockSocketIO.emit).toHaveBeenCalledWith(
- "timer-update",
- expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
- );
- });
+ it("emits timer-update with the frozen (unchanged) bank value", async () => {
+ await action({ request: defaultPickRequest(), params: {}, context: ctx });
- it("emits timer-update for the picking team with their incremented balance", async () => {
- mockDb.returning
- .mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
+ expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
+ seasonId: SEASON_ID,
+ teamId: TEAM_ID,
+ timeRemaining: 75,
+ currentPickNumber: 2,
+ });
+ });
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
+ it("reads the picking team's timer but does not write it", async () => {
+ await action({ request: defaultPickRequest(), params: {}, context: ctx });
- expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
- seasonId: SEASON_ID,
- teamId: TEAM_ID,
- timeRemaining: 105,
- currentPickNumber: 2,
+ // 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);
});
});
- it("creates a new timer for the picking team if they have no existing timer", async () => {
- // update() returns [] when no timer row exists → triggers the insert fallback
- mockDb.returning
- .mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([]);
+ describe("standard mode", () => {
+ beforeEach(() => {
+ mockDb.query.seasons.findFirst.mockResolvedValue({
+ ...mockSeason,
+ draftTimerMode: "standard",
+ });
+ });
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
+ it("resets the picking team's timer to draftIncrementTime", async () => {
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
- // insert should be called for: (1) draft pick, (2) new timer (increment only: 30s)
- expect(mockDb.insert).toHaveBeenCalledTimes(2);
+ await action({ request: defaultPickRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
+ );
+ });
+
+ it("writes the DB timer (reset) and updates the season pick number", 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);
+ });
});
- it("increment is additive, not a flat reset — fast pickers accumulate time", async () => {
- // Team had 100s remaining; increment is 30s → DB atomically returns 130s
- mockDb.returning
- .mockResolvedValueOnce([mockDraftPick])
- .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
-
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
-
- // Verify 130s (100 + 30), not 120s (which would be a flat reset to initialTime)
- expect(mockSocketIO.emit).toHaveBeenCalledWith(
- "timer-update",
- expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
- );
- });
-
- // ── REGRESSION TESTS ────────────────────────────────────────────────────
- //
- // Bug: force-manual-pick was resetting the next team's timer to
- // draftInitialTime (120s) instead of carrying their bank forward.
- // The correct behaviour (matching make-pick and force-autopick) is to
- // leave the next team's timer completely untouched.
-
- it("REGRESSION: does not look up the next team's timer", async () => {
- // The atomic SQL increment (timeRemaining + N) requires no prior read —
- // draftTimers.findFirst is never called.
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
-
- expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(0);
- });
+ // ── REGRESSION: Next team's timer must never be touched ──────────────────
it("REGRESSION: does not emit a timer-update for the next team", async () => {
- // Before the fix, a timer-update was emitted for the next team with
- // timeRemaining: draftInitialTime (120s), overwriting their actual bank.
await action({ request: defaultPickRequest(), params: {}, context: ctx });
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
@@ -435,14 +432,5 @@ describe("draft.force-manual-pick action", () => {
);
expect(nextTeamTimerEmits).toHaveLength(0);
});
-
- it("REGRESSION: does not overwrite the next team's existing time bank", async () => {
- // Verify that db.update is called exactly twice (timer increment + season
- // pick number), not three times (which would include resetting the next
- // team's timer).
- await action({ request: defaultPickRequest(), params: {}, context: ctx });
-
- expect(mockDb.update).toHaveBeenCalledTimes(2);
- });
});
});
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
new file mode 100644
index 0000000..c8c0844
--- /dev/null
+++ b/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
@@ -0,0 +1,324 @@
+/**
+ * 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
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { RouterContextProvider } from "react-router";
+import { action } from "~/routes/api/draft.make-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 }),
+ pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]),
+}));
+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 OWNER_ID = "owner-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: OWNER_ID,
+ pickedByType: "owner",
+};
+
+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_ID },
+ },
+ {
+ 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("participantId", PARTICIPANT_ID);
+ return new Request("http://localhost/api/draft/make-pick", {
+ method: "POST",
+ body: formData,
+ });
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("draft.make-pick action — timer mode behavior", () => {
+ let mockDb: any;
+ let mockSocketIO: any;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: OWNER_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(null) },
+ 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 }) },
+ draftQueue: { findMany: vi.fn().mockResolvedValue([]) },
+ },
+ 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);
+ });
+
+ 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
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick]) // insert draft pick
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]); // update timer
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
+ );
+ });
+
+ 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
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, 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("commissioner pick — no increment earned", () => {
+ const COMMISSIONER_ID = "commissioner-user-1";
+
+ 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.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" },
+ },
+ ]);
+ });
+
+ 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]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(mockSocketIO.emit).toHaveBeenCalledWith(
+ "timer-update",
+ expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
+ );
+ });
+
+ 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]);
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ // Only one update: season.currentPickNumber — the timer row is NOT written
+ expect(mockDb.update).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe("standard mode", () => {
+ it("emits timer-update with exactly draftIncrementTime regardless of remaining bank", async () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
+ );
+ // Simulate DB resetting to exactly 30s (the increment), ignoring prior bank
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, 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("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
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, 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 () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftIncrementTime: 45 })
+ );
+ mockDb.returning
+ .mockResolvedValueOnce([mockDraftPick])
+ .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 45 }]);
+
+ const response = await action({ request: makeRequest(), params: {}, context: ctx });
+
+ expect(response.status).toBe(200);
+ const data = await response.json();
+ expect(data.success).toBe(true);
+ });
+ });
+});
diff --git a/app/routes/api/__tests__/draft.start.test.ts b/app/routes/api/__tests__/draft.start.test.ts
new file mode 100644
index 0000000..da9a17b
--- /dev/null
+++ b/app/routes/api/__tests__/draft.start.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { RouterContextProvider } from "react-router";
+import { action } from "~/routes/api/draft.start";
+
+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/commissioner", () => ({
+ isCommissioner: vi.fn(),
+}));
+vi.mock("~/models/draft-timer", () => ({
+ deleteSeasonTimers: vi.fn(),
+ initializeDraftTimers: vi.fn(),
+}));
+
+// ── Fixtures ─────────────────────────────────────────────────────────────────
+
+const SEASON_ID = "season-1";
+const COMMISSIONER_ID = "commissioner-user-1";
+
+const mockDraftSlots = [
+ { id: "slot-1", seasonId: SEASON_ID, teamId: "team-1", draftOrder: 1 },
+ { id: "slot-2", seasonId: SEASON_ID, teamId: "team-2", draftOrder: 2 },
+];
+
+function makeSeason(overrides: Record = {}) {
+ return {
+ id: SEASON_ID,
+ leagueId: "league-1",
+ status: "pre_draft",
+ draftInitialTime: 120,
+ draftIncrementTime: 30,
+ draftTimerMode: "chess_clock",
+ ...overrides,
+ };
+}
+
+function makeRequest() {
+ const formData = new FormData();
+ formData.append("seasonId", SEASON_ID);
+ return new Request("http://localhost/api/draft/start", {
+ method: "POST",
+ body: formData,
+ });
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("draft.start action — timer initialization", () => {
+ let mockDb: any;
+ let mockSocketIO: any;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { getAuth } = await import("@clerk/react-router/server");
+ vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
+
+ const { isCommissioner } = await import("~/models/commissioner");
+ vi.mocked(isCommissioner).mockResolvedValue(true);
+
+ mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
+ const socketModule = await import("~/server/socket");
+ vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
+
+ mockDb = {
+ query: {
+ seasons: { findFirst: vi.fn() },
+ draftSlots: { findMany: vi.fn() },
+ },
+ update: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ };
+
+ mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots);
+
+ const { database } = await import("~/database/context");
+ vi.mocked(database).mockReturnValue(mockDb);
+ });
+
+ describe("chess_clock mode", () => {
+ it("initializes each team's timer to draftInitialTime", async () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 120, draftIncrementTime: 30 })
+ );
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ const { initializeDraftTimers } = await import("~/models/draft-timer");
+ expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
+ SEASON_ID,
+ expect.any(Array),
+ 120 // draftInitialTime, not draftIncrementTime
+ );
+ });
+
+ it("uses the configured draftInitialTime (not the increment)", async () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "chess_clock", draftInitialTime: 28800, draftIncrementTime: 3600 })
+ );
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ const { initializeDraftTimers } = await import("~/models/draft-timer");
+ expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
+ SEASON_ID,
+ expect.any(Array),
+ 28800 // 8 hours initial bank
+ );
+ });
+ });
+
+ describe("standard mode", () => {
+ it("initializes each team's timer to draftIncrementTime (the per-pick time)", async () => {
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 120, draftIncrementTime: 30 })
+ );
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ const { initializeDraftTimers } = await import("~/models/draft-timer");
+ expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
+ SEASON_ID,
+ expect.any(Array),
+ 30 // draftIncrementTime, not draftInitialTime
+ );
+ });
+
+ it("ignores draftInitialTime — uses only increment for the starting clock", async () => {
+ // Even though initialTime=600, standard mode should use increment=45
+ mockDb.query.seasons.findFirst.mockResolvedValue(
+ makeSeason({ draftTimerMode: "standard", draftInitialTime: 600, draftIncrementTime: 45 })
+ );
+
+ await action({ request: makeRequest(), params: {}, context: ctx });
+
+ const { initializeDraftTimers } = await import("~/models/draft-timer");
+ expect(vi.mocked(initializeDraftTimers)).toHaveBeenCalledWith(
+ SEASON_ID,
+ expect.any(Array),
+ 45 // increment only — initial time is irrelevant in standard mode
+ );
+ });
+ });
+});
diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts
index 1a84683..f920509 100644
--- a/app/routes/api/draft.force-manual-pick.ts
+++ b/app/routes/api/draft.force-manual-pick.ts
@@ -145,6 +145,14 @@ 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)
@@ -165,25 +173,30 @@ export async function action(args: ActionFunctionArgs) {
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
- // Add increment to the team that just picked (atomic to avoid race with timer loop)
+ // 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.
const incrementTime = season.draftIncrementTime || 30;
- 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();
+ let newTimeRemaining: number;
- const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
- if (!updatedTimer) {
- await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
+ 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)
+ )
+ )
+ .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;
}
// 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 ff6cd5a..23aa717 100644
--- a/app/routes/api/draft.make-pick.ts
+++ b/app/routes/api/draft.make-pick.ts
@@ -202,27 +202,51 @@ export async function action(args: ActionFunctionArgs) {
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
- // Add increment to the team that just picked (post-pick reward).
- // Use an atomic SQL update so the timer loop cannot overwrite this increment
- // via a concurrent read-modify-write decrement.
+ // 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.
const incrementTime = season.draftIncrementTime || 30;
- 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, currentDraftSlot.teamId)
- )
- )
- .returning();
+ let newTimeRemaining: number;
- // Safety fallback: timer row should always exist after draft start
- const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
- if (!updatedTimer) {
+ let updatedTimer: { timeRemaining: number } | undefined;
+
+ if (season.draftTimerMode === "standard") {
+ // Atomic reset so the timer loop cannot race with this write.
+ [updatedTimer] = await db
+ .update(schema.draftTimers)
+ .set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
+ .where(
+ and(
+ eq(schema.draftTimers.seasonId, seasonId),
+ eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
+ )
+ )
+ .returning();
+ newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
+ } else if (isTeamOwner) {
+ // Chess clock, owner pick: earn the increment (atomic add).
+ [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, currentDraftSlot.teamId)
+ )
+ )
+ .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)) {
await db.insert(schema.draftTimers).values({
seasonId,
teamId: currentDraftSlot.teamId,
diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts
index 754dbbf..1fb7c07 100644
--- a/app/routes/api/draft.start.ts
+++ b/app/routes/api/draft.start.ts
@@ -61,7 +61,12 @@ export async function action(args: ActionFunctionArgs) {
})
.where(eq(schema.seasons.id, seasonId));
- const initialTime = season.draftInitialTime || 120;
+ // Standard mode: each pick starts with exactly the increment (no carry-over bank).
+ // Chess clock mode: each team starts with the full initial time bank.
+ const initialTime =
+ season.draftTimerMode === "standard"
+ ? season.draftIncrementTime || 30
+ : season.draftInitialTime || 120;
// Reset timers for all teams
await deleteSeasonTimers(seasonId);
diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx
index 451c596..0d89fc9 100644
--- a/app/routes/leagues/$leagueId.settings.tsx
+++ b/app/routes/leagues/$leagueId.settings.tsx
@@ -24,6 +24,7 @@ import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer";
+import { parseDraftSpeed } from "~/lib/draft-timer";
import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
@@ -452,32 +453,13 @@ export async function action(args: Route.ActionArgs) {
const draftDateTime = formData.get("draftDateTime");
const draftRounds = formData.get("draftRounds");
const draftSpeed = formData.get("draftSpeed");
+ const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
// Map draft speed to time values
- let draftInitialTime: number;
- let draftIncrementTime: number;
-
- switch (draftSpeed) {
- case "fast":
- draftInitialTime = 60;
- draftIncrementTime = 10;
- break;
- case "standard":
- draftInitialTime = 120;
- draftIncrementTime = 15;
- break;
- case "slow":
- draftInitialTime = 28800; // 8 hours
- draftIncrementTime = 3600; // 1 hour
- break;
- case "very-slow":
- draftInitialTime = 43200; // 12 hours
- draftIncrementTime = 3600; // 1 hour
- break;
- default:
- draftInitialTime = 120;
- draftIncrementTime = 15;
- }
+ const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
+ draftSpeed as string | null,
+ draftTimerMode ?? "chess_clock"
+ );
// League-level fields (name, isPublicDraftBoard) are already saved above.
// Now handle season-specific updates.
@@ -512,6 +494,11 @@ export async function action(args: Route.ActionArgs) {
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
+ // Timer mode (only if submitted; disabled during active draft)
+ if (draftTimerMode !== null) {
+ seasonUpdates.draftTimerMode = draftTimerMode;
+ }
+
// Handle scoring rules (only if in pre_draft status)
if (season.status === "pre_draft") {
const scoringFields = [
@@ -663,6 +650,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [selectedSports, setSelectedSports] = useState>(
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
);
@@ -1000,6 +988,25 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
+
+
+
+
+ Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
+
+
+
- Initial time + increment per pick
+ {timerMode === "standard"
+ ? "Time per pick — resets to this value after each selection"
+ : "Initial time bank + increment added after each pick"}
+ Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
+
+
+
- Initial time + increment per pick
+ {timerMode === "standard"
+ ? "Time per pick — resets to this value after each selection"
+ : "Initial time bank + increment added after each pick"}