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;
|
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.
|
* 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 totalPicks = totalTeams * season.draftRounds;
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Add increment to the team that just picked (post-pick reward).
|
// Auto picks do not earn bank time.
|
||||||
// Atomic update so the timer loop cannot overwrite this via a concurrent decrement.
|
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
||||||
const [updatedTimer] = await db
|
// Chess clock mode: timer already reached 0; leave the bank frozen at 0.
|
||||||
.update(schema.draftTimers)
|
let emitTimeRemaining: number;
|
||||||
.set({
|
|
||||||
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
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(
|
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 {
|
} else {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
// Chess clock: no DB update — auto picks don't earn increment.
|
||||||
seasonId,
|
emitTimeRemaining = currentTimer?.timeRemaining ?? 0;
|
||||||
teamId,
|
console.log(
|
||||||
timeRemaining: updatedTimer.timeRemaining,
|
`[AutoPick] Chess clock pick for team ${teamId}, bank frozen at ${emitTimeRemaining}s (no increment)`
|
||||||
currentPickNumber: nextPickNumber,
|
);
|
||||||
});
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("[AutoPick] Socket.IO timer-update error:", error);
|
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
|
// Next team's timer is unchanged — their bank carries forward as-is
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ const mockSeason = {
|
||||||
draftRounds: 3,
|
draftRounds: 3,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 30,
|
draftIncrementTime: 30,
|
||||||
|
draftTimerMode: "chess_clock",
|
||||||
currentPickNumber: 1,
|
currentPickNumber: 1,
|
||||||
draftPaused: false,
|
draftPaused: false,
|
||||||
};
|
};
|
||||||
|
|
@ -347,86 +348,82 @@ describe("draft.force-manual-pick action", () => {
|
||||||
|
|
||||||
// ── Timer behavior ─────────────────────────────────────────────────────────
|
// ── Timer behavior ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// All three pick paths (user pick, force auto, force manual) must treat timers
|
// Force manual picks are commissioner actions — they do NOT earn the team
|
||||||
// identically:
|
// any extra time bank, regardless of timer mode.
|
||||||
// 1. The team that was picked FOR gets +draftIncrementTime added to their bank.
|
//
|
||||||
// 2. The next team's bank is left completely untouched.
|
// 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", () => {
|
describe("timer behavior", () => {
|
||||||
it("adds draftIncrementTime to the picking team's time bank", async () => {
|
describe("chess_clock mode", () => {
|
||||||
// Timer has 75s; increment is 30s → DB atomically returns new balance: 105s
|
it("does NOT add increment to the picking team's bank", async () => {
|
||||||
mockDb.returning
|
// Timer has 75s (from beforeEach mock); forced pick → bank stays at 75s
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||||
|
"timer-update",
|
||||||
|
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 75 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
it("emits timer-update with the frozen (unchanged) bank value", async () => {
|
||||||
"timer-update",
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits timer-update for the picking team with their incremented balance", async () => {
|
expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", {
|
||||||
mockDb.returning
|
seasonId: SEASON_ID,
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
teamId: TEAM_ID,
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
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", {
|
// findFirst called once (picking team's current value for the emit)
|
||||||
seasonId: SEASON_ID,
|
expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1);
|
||||||
teamId: TEAM_ID,
|
// update called once only for season.currentPickNumber — NOT for the timer
|
||||||
timeRemaining: 105,
|
expect(mockDb.update).toHaveBeenCalledTimes(1);
|
||||||
currentPickNumber: 2,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new timer for the picking team if they have no existing timer", async () => {
|
describe("standard mode", () => {
|
||||||
// update() returns [] when no timer row exists → triggers the insert fallback
|
beforeEach(() => {
|
||||||
mockDb.returning
|
mockDb.query.seasons.findFirst.mockResolvedValue({
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
...mockSeason,
|
||||||
.mockResolvedValueOnce([]);
|
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)
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
expect(mockDb.insert).toHaveBeenCalledTimes(2);
|
|
||||||
|
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 () => {
|
// ── REGRESSION: Next team's timer must never be touched ──────────────────
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("REGRESSION: does not emit a timer-update for the next team", async () => {
|
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 });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
|
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
|
||||||
|
|
@ -435,14 +432,5 @@ describe("draft.force-manual-pick action", () => {
|
||||||
);
|
);
|
||||||
expect(nextTeamTimerEmits).toHaveLength(0);
|
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 });
|
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
|
// Create the draft pick
|
||||||
const [draftPick] = await db
|
const [draftPick] = await db
|
||||||
.insert(schema.draftPicks)
|
.insert(schema.draftPicks)
|
||||||
|
|
@ -165,25 +173,30 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
const totalPicks = totalTeams * season.draftRounds;
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
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 incrementTime = season.draftIncrementTime || 30;
|
||||||
const [updatedTimer] = await db
|
let newTimeRemaining: number;
|
||||||
.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();
|
|
||||||
|
|
||||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
if (season.draftTimerMode === "standard") {
|
||||||
if (!updatedTimer) {
|
const [updatedTimer] = await db
|
||||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
.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
|
// Emit timer update to all clients
|
||||||
|
|
|
||||||
|
|
@ -202,27 +202,51 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
const totalPicks = totalTeams * season.draftRounds;
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Add increment to the team that just picked (post-pick reward).
|
// Update the picking team's timer after their pick.
|
||||||
// Use an atomic SQL update so the timer loop cannot overwrite this increment
|
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
||||||
// via a concurrent read-modify-write decrement.
|
// 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 incrementTime = season.draftIncrementTime || 30;
|
||||||
const [updatedTimer] = await db
|
let newTimeRemaining: number;
|
||||||
.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();
|
|
||||||
|
|
||||||
// Safety fallback: timer row should always exist after draft start
|
let updatedTimer: { timeRemaining: number } | undefined;
|
||||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
||||||
if (!updatedTimer) {
|
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({
|
await db.insert(schema.draftTimers).values({
|
||||||
seasonId,
|
seasonId,
|
||||||
teamId: currentDraftSlot.teamId,
|
teamId: currentDraftSlot.teamId,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,12 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
})
|
})
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
.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
|
// Reset timers for all teams
|
||||||
await deleteSeasonTimers(seasonId);
|
await deleteSeasonTimers(seasonId);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import * as schema from "~/database/schema";
|
||||||
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
||||||
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
||||||
import { deleteSeasonTimers } from "~/models/draft-timer";
|
import { deleteSeasonTimers } from "~/models/draft-timer";
|
||||||
|
import { parseDraftSpeed } from "~/lib/draft-timer";
|
||||||
import type { Route } from "./+types/$leagueId.settings";
|
import type { Route } from "./+types/$leagueId.settings";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -452,32 +453,13 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const draftDateTime = formData.get("draftDateTime");
|
const draftDateTime = formData.get("draftDateTime");
|
||||||
const draftRounds = formData.get("draftRounds");
|
const draftRounds = formData.get("draftRounds");
|
||||||
const draftSpeed = formData.get("draftSpeed");
|
const draftSpeed = formData.get("draftSpeed");
|
||||||
|
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
|
||||||
|
|
||||||
// Map draft speed to time values
|
// Map draft speed to time values
|
||||||
let draftInitialTime: number;
|
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
|
||||||
let draftIncrementTime: number;
|
draftSpeed as string | null,
|
||||||
|
draftTimerMode ?? "chess_clock"
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// League-level fields (name, isPublicDraftBoard) are already saved above.
|
// League-level fields (name, isPublicDraftBoard) are already saved above.
|
||||||
// Now handle season-specific updates.
|
// Now handle season-specific updates.
|
||||||
|
|
@ -512,6 +494,11 @@ export async function action(args: Route.ActionArgs) {
|
||||||
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
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)
|
// Handle scoring rules (only if in pre_draft status)
|
||||||
if (season.status === "pre_draft") {
|
if (season.status === "pre_draft") {
|
||||||
const scoringFields = [
|
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 { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
|
||||||
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
||||||
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
||||||
);
|
);
|
||||||
|
|
@ -1000,6 +988,25 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -1007,7 +1014,9 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
name="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"
|
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={
|
defaultValue={
|
||||||
season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
timerMode === "standard"
|
||||||
|
? (season?.draftIncrementTime?.toString() ?? "90")
|
||||||
|
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
||||||
? "fast"
|
? "fast"
|
||||||
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
||||||
? "standard"
|
? "standard"
|
||||||
|
|
@ -1017,16 +1026,37 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
? "very-slow"
|
? "very-slow"
|
||||||
: "standard"
|
: "standard"
|
||||||
}
|
}
|
||||||
|
key={timerMode}
|
||||||
disabled={!canEditDraftRounds}
|
disabled={!canEditDraftRounds}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="fast">Fast (1 min + 10 sec)</option>
|
{timerMode === "standard" ? (
|
||||||
<option value="standard">Standard (2 min + 15 sec)</option>
|
<>
|
||||||
<option value="slow">Slow (8 hours + 1 hour)</option>
|
<option value="15">15 seconds</option>
|
||||||
<option value="very-slow">Very Slow (12 hours + 1 hour)</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>
|
</select>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const createMockSeason = (overrides: {
|
||||||
draftDateTime: null,
|
draftDateTime: null,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 15,
|
draftIncrementTime: 15,
|
||||||
|
draftTimerMode: 'chess_clock' as const,
|
||||||
currentPickNumber: null,
|
currentPickNumber: null,
|
||||||
draftStartedAt: null,
|
draftStartedAt: null,
|
||||||
draftPaused: false,
|
draftPaused: false,
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const mockSeason = {
|
||||||
draftDateTime: null,
|
draftDateTime: null,
|
||||||
draftInitialTime: 120,
|
draftInitialTime: 120,
|
||||||
draftIncrementTime: 15,
|
draftIncrementTime: 15,
|
||||||
|
draftTimerMode: 'chess_clock' as const,
|
||||||
currentPickNumber: null,
|
currentPickNumber: null,
|
||||||
draftStartedAt: null,
|
draftStartedAt: null,
|
||||||
draftPaused: false,
|
draftPaused: false,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "~/components/ui/popover";
|
} from "~/components/ui/popover";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { parseDraftSpeed } from "~/lib/draft-timer";
|
||||||
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
export function meta(): Route.MetaDescriptors {
|
||||||
|
|
@ -83,32 +84,11 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const draftRounds = formData.get("draftRounds");
|
const draftRounds = formData.get("draftRounds");
|
||||||
const draftDateTime = formData.get("draftDateTime");
|
const draftDateTime = formData.get("draftDateTime");
|
||||||
const draftSpeed = formData.get("draftSpeed");
|
const draftSpeed = formData.get("draftSpeed");
|
||||||
|
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
|
||||||
|
|
||||||
// Map draft speed to time values
|
// Map draft speed to time values
|
||||||
let draftInitialTimeNum: number;
|
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
|
||||||
let draftIncrementTimeNum: number;
|
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
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,
|
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
|
||||||
draftInitialTime: draftInitialTimeNum,
|
draftInitialTime: draftInitialTimeNum,
|
||||||
draftIncrementTime: draftIncrementTimeNum,
|
draftIncrementTime: draftIncrementTimeNum,
|
||||||
|
draftTimerMode,
|
||||||
...scoringRules,
|
...scoringRules,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -224,6 +205,7 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
||||||
const [draftRounds, setDraftRounds] = useState<number>(20);
|
const [draftRounds, setDraftRounds] = useState<number>(20);
|
||||||
const [draftDate, setDraftDate] = useState<Date>();
|
const [draftDate, setDraftDate] = useState<Date>();
|
||||||
const [draftTime, setDraftTime] = useState<string>("");
|
const [draftTime, setDraftTime] = useState<string>("");
|
||||||
|
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
|
||||||
|
|
||||||
const handleSportToggle = (sportId: string) => {
|
const handleSportToggle = (sportId: string) => {
|
||||||
const newSelected = new Set(selectedSports);
|
const newSelected = new Set(selectedSports);
|
||||||
|
|
@ -383,22 +365,61 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
||||||
<select
|
<select
|
||||||
id="draftSpeed"
|
id="draftSpeed"
|
||||||
name="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"
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="fast">Fast (1 min + 10 sec)</option>
|
{timerMode === "standard" ? (
|
||||||
<option value="standard">Standard (2 min + 15 sec)</option>
|
<>
|
||||||
<option value="slow">Slow (8 hours + 1 hour)</option>
|
<option value="15">15 seconds</option>
|
||||||
<option value="very-slow">Very Slow (12 hours + 1 hour)</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>
|
</select>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ export const autodraftModeEnum = pgEnum("autodraft_mode", [
|
||||||
"while_on",
|
"while_on",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const draftTimerModeEnum = pgEnum("draft_timer_mode", [
|
||||||
|
"chess_clock",
|
||||||
|
"standard",
|
||||||
|
]);
|
||||||
|
|
||||||
export const probabilitySourceEnum = pgEnum("probability_source", [
|
export const probabilitySourceEnum = pgEnum("probability_source", [
|
||||||
"manual",
|
"manual",
|
||||||
"futures_odds",
|
"futures_odds",
|
||||||
|
|
@ -130,6 +135,7 @@ export const seasons = pgTable("seasons", {
|
||||||
draftDateTime: timestamp("draft_date_time"),
|
draftDateTime: timestamp("draft_date_time"),
|
||||||
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
|
draftInitialTime: integer("draft_initial_time").notNull().default(120), // seconds
|
||||||
draftIncrementTime: integer("draft_increment_time").notNull().default(30), // 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),
|
currentPickNumber: integer("current_pick_number").default(1),
|
||||||
draftStartedAt: timestamp("draft_started_at"),
|
draftStartedAt: timestamp("draft_started_at"),
|
||||||
draftPaused: boolean("draft_paused").notNull().default(false),
|
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,
|
"when": 1773897678653,
|
||||||
"tag": "0052_new_warbird",
|
"tag": "0052_new_warbird",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773903201189,
|
||||||
|
"tag": "0053_smooth_kingpin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue