Add standard draft clock mode (#67) (#189)

Implements a new "standard" timer mode alongside the existing chess clock
mode. In standard mode the per-pick timer resets to a fixed value after
every pick (no carry-over), and the speed selector shows plain time values
instead of named chess-clock presets.

Key changes:
- Add `draft_timer_mode` enum column to `seasons` table (migration 0053)
- `draft.start`: standard mode seeds timers at `draftIncrementTime` (the
  per-pick value) rather than `draftInitialTime`
- `draft.make-pick`: three-way branch — standard resets, chess clock
  owner earns increment, commissioner/admin pick leaves bank frozen
- `draft.force-manual-pick`: commissioner picks never earn bank time;
  chess clock path uses a pre-pick snapshot to avoid a race window with
  the 1-second timer loop
- `executeAutoPick` in draft-utils: auto picks never earn bank time;
  chess clock path skips the DB update (timer already at 0)
- League creation and settings pages: mode-aware speed selector (raw
  seconds for standard, named presets for chess clock); shared
  `parseDraftSpeed` utility extracted to `app/lib/draft-timer.ts`
- Tests added for draft.start timer init and make-pick timer mode
  behavior; force-manual-pick tests updated for new timer semantics

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-20 21:36:39 -07:00 committed by GitHub
parent ebe06b2522
commit 2949ca733a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 4403 additions and 198 deletions

View file

@ -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.
*

View file

@ -644,14 +644,15 @@ 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.
// 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 (season.draftTimerMode === "standard") {
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
updatedAt: new Date(),
})
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
@ -659,22 +660,28 @@ export async function executeAutoPick(params: {
)
)
.returning();
if (updatedTimer) {
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)`
);
} 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: updatedTimer.timeRemaining,
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

View file

@ -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 });
expect(mockSocketIO.emit).toHaveBeenCalledWith(
"timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
);
});
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 }]);
it("emits timer-update with the frozen (unchanged) bank value", async () => {
await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
seasonId: SEASON_ID,
teamId: TEAM_ID,
timeRemaining: 105,
timeRemaining: 75,
currentPickNumber: 2,
});
});
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([]);
it("reads the picking team's timer but does not write it", async () => {
await action({ request: defaultPickRequest(), params: {}, context: ctx });
// insert should be called for: (1) draft pick, (2) new timer (increment only: 30s)
expect(mockDb.insert).toHaveBeenCalledTimes(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("increment is additive, not a flat reset — fast pickers accumulate time", async () => {
// Team had 100s remaining; increment is 30s → DB atomically returns 130s
describe("standard mode", () => {
beforeEach(() => {
mockDb.query.seasons.findFirst.mockResolvedValue({
...mockSeason,
draftTimerMode: "standard",
});
});
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: 130 }]);
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
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 })
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
);
});
// ── 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("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 }]);
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);
// Two updates: timer reset + season.currentPickNumber
expect(mockDb.update).toHaveBeenCalledTimes(2);
});
});
// ── 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);
});
});
});

View file

@ -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<string, unknown> = {}) {
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);
});
});
});

View file

@ -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<string, unknown> = {}) {
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
);
});
});
});

View file

@ -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,14 +173,16 @@ 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;
let newTimeRemaining: number;
if (season.draftTimerMode === "standard") {
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
updatedAt: new Date(),
})
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
@ -180,11 +190,14 @@ export async function action(args: ActionFunctionArgs) {
)
)
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
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
try {

View file

@ -202,11 +202,31 @@ 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
let newTimeRemaining: number;
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}`,
@ -219,10 +239,14 @@ 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;
}
// Safety fallback: timer row should always exist after draft start
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
if (!updatedTimer) {
// 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,

View file

@ -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);

View file

@ -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<Set<string>>(
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
);
@ -1000,6 +988,25 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label>
<select
id="draftTimerMode"
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
disabled={!canEditDraftRounds}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label>
<select
@ -1007,7 +1014,9 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={
season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
timerMode === "standard"
? (season?.draftIncrementTime?.toString() ?? "90")
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
? "fast"
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
? "standard"
@ -1017,16 +1026,37 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
? "very-slow"
: "standard"
}
key={timerMode}
disabled={!canEditDraftRounds}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
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"}
</p>
</div>

View file

@ -22,6 +22,7 @@ const createMockSeason = (overrides: {
draftDateTime: null,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: 'chess_clock' as const,
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,

View file

@ -54,6 +54,7 @@ const mockSeason = {
draftDateTime: null,
draftInitialTime: 120,
draftIncrementTime: 15,
draftTimerMode: 'chess_clock' as const,
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,

View file

@ -39,6 +39,7 @@ import {
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
export function meta(): Route.MetaDescriptors {
@ -83,32 +84,11 @@ export async function action(args: Route.ActionArgs) {
const draftRounds = formData.get("draftRounds");
const draftDateTime = formData.get("draftDateTime");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
// Map draft speed to time values
let draftInitialTimeNum: number;
let draftIncrementTimeNum: number;
switch (draftSpeed) {
case "fast":
draftInitialTimeNum = 60;
draftIncrementTimeNum = 10;
break;
case "standard":
draftInitialTimeNum = 120;
draftIncrementTimeNum = 15;
break;
case "slow":
draftInitialTimeNum = 28800; // 8 hours
draftIncrementTimeNum = 3600; // 1 hour
break;
case "very-slow":
draftInitialTimeNum = 43200; // 12 hours
draftIncrementTimeNum = 3600; // 1 hour
break;
default:
draftInitialTimeNum = 120;
draftIncrementTimeNum = 15;
}
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
// Validation
if (typeof name !== "string" || !name.trim()) {
@ -173,6 +153,7 @@ export async function action(args: Route.ActionArgs) {
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
...scoringRules,
});
@ -224,6 +205,7 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
const [draftRounds, setDraftRounds] = useState<number>(20);
const [draftDate, setDraftDate] = useState<Date>();
const [draftTime, setDraftTime] = useState<string>("");
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
@ -383,22 +365,61 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label>
<select
id="draftTimerMode"
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label>
<select
id="draftSpeed"
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue="standard"
defaultValue={timerMode === "standard" ? "90" : "standard"}
key={timerMode}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
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"}
</p>
</div>

View file

@ -64,6 +64,11 @@ export const autodraftModeEnum = pgEnum("autodraft_mode", [
"while_on",
]);
export const draftTimerModeEnum = pgEnum("draft_timer_mode", [
"chess_clock",
"standard",
]);
export const probabilitySourceEnum = pgEnum("probability_source", [
"manual",
"futures_odds",
@ -130,6 +135,7 @@ export const seasons = pgTable("seasons", {
draftDateTime: timestamp("draft_date_time"),
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // seconds
draftTimerMode: draftTimerModeEnum("draft_timer_mode").notNull().default("chess_clock"),
currentPickNumber: integer("current_pick_number").default(1),
draftStartedAt: timestamp("draft_started_at"),
draftPaused: boolean("draft_paused").notNull().default(false),

View file

@ -0,0 +1,2 @@
CREATE TYPE "public"."draft_timer_mode" AS ENUM('chess_clock', 'standard');--> statement-breakpoint
ALTER TABLE "seasons" ADD COLUMN "draft_timer_mode" "draft_timer_mode" DEFAULT 'chess_clock' NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -372,6 +372,13 @@
"when": 1773897678653,
"tag": "0052_new_warbird",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1773903201189,
"tag": "0053_smooth_kingpin",
"breakpoints": true
}
]
}