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:
parent
ebe06b2522
commit
2949ca733a
16 changed files with 4403 additions and 198 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
324
app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
Normal file
324
app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
152
app/routes/api/__tests__/draft.start.test.ts
Normal file
152
app/routes/api/__tests__/draft.start.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const createMockSeason = (overrides: {
|
|||
draftDateTime: null,
|
||||
draftInitialTime: 120,
|
||||
draftIncrementTime: 15,
|
||||
draftTimerMode: 'chess_clock' as const,
|
||||
currentPickNumber: null,
|
||||
draftStartedAt: null,
|
||||
draftPaused: false,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const mockSeason = {
|
|||
draftDateTime: null,
|
||||
draftInitialTime: 120,
|
||||
draftIncrementTime: 15,
|
||||
draftTimerMode: 'chess_clock' as const,
|
||||
currentPickNumber: null,
|
||||
draftStartedAt: null,
|
||||
draftPaused: false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
2
drizzle/0053_smooth_kingpin.sql
Normal file
2
drizzle/0053_smooth_kingpin.sql
Normal 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;
|
||||
3596
drizzle/meta/0053_snapshot.json
Normal file
3596
drizzle/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -372,6 +372,13 @@
|
|||
"when": 1773897678653,
|
||||
"tag": "0052_new_warbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1773903201189,
|
||||
"tag": "0053_smooth_kingpin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue