From 7a8ea60e77e9eda1522dad92a12a58556b5e23bf Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:51:12 -0800 Subject: [PATCH] Add draft clock UI, Fischer increment timer logic, and security fixes (#19) - Add prominent clock badge to tab bar (lights up on your turn) with correct Fischer increment chess-clock model: bank starts at initialTime, += incrementTime after each pick, other teams' banks untouched - Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass, and full snake-draft lifecycle regression scenarios - Fix make-pick.ts: add status !== 'draft' and draftPaused server guards - Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(), fix timeUsed to store actual timeRemaining at pick moment (not always 120), add null timer warning, move timer fetch before pick insert - Fix draft.start.ts: batch timer inserts, guard against empty draftSlots - Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to Record - Fix duplicate animate-pulse (getTimerColorClass already includes it) - Clamp negative seconds in formatClockTime to guard against timer drift Co-authored-by: Claude Sonnet 4.6 --- app/components/draft/DraftGridSection.tsx | 44 ++- app/lib/__tests__/draft-timer.test.ts | 264 ++++++++++++++++++ app/lib/draft-timer.ts | 59 ++++ app/models/draft-utils.ts | 176 ++++-------- app/routes/api/draft.make-pick.ts | 81 ++---- app/routes/api/draft.start.ts | 20 +- .../leagues/$leagueId.draft.$seasonId.tsx | 46 ++- 7 files changed, 466 insertions(+), 224 deletions(-) create mode 100644 app/lib/__tests__/draft-timer.test.ts create mode 100644 app/lib/draft-timer.ts diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 9d4fa29..bfc69d3 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -1,9 +1,11 @@ +import { useMemo } from "react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "~/components/ui/context-menu"; +import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; interface DraftGridSectionProps { draftSlots: Array<{ @@ -31,7 +33,7 @@ interface DraftGridSectionProps { }> >; currentPick: number; - teamTimers: Record; + teamTimers: Record; autodraftStatus: Record; connectedTeams: Set; isCommissioner: boolean; @@ -54,26 +56,10 @@ export function DraftGridSection({ onReplacePick, onRollbackToPick, }: DraftGridSectionProps) { - const formatTime = (seconds: number | null) => { - if (seconds === null) return "--:--"; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; - } - return `${minutes}:${String(secs).padStart(2, "0")}`; - }; - - const getTimerColor = (seconds: number | null) => { - if (seconds === null) return "text-muted-foreground"; - if (seconds > 60) return "text-emerald-400"; - if (seconds > 30) return "text-amber-accent"; - if (seconds > 10) return "text-coral-accent"; - return "text-coral-accent animate-pulse"; - }; + const currentTeamId = useMemo( + () => draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId ?? null, + [draftGrid, currentPick] + ); return (
@@ -90,14 +76,20 @@ export function DraftGridSection({ return (
{slot.team.name}
- {formatTime(teamTime)} + {formatClockTime(teamTime)} {isAutodraft && ( (auto) @@ -126,7 +118,7 @@ export function DraftGridSection({ const cellClassName = `flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${ isCurrent - ? "border-electric bg-electric/15 shadow-lg shadow-electric/10" + ? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40" : isPicked ? "bg-emerald-500/10 border-emerald-500/30" : "border-border bg-card" @@ -148,7 +140,7 @@ export function DraftGridSection({
) : isCurrent ? ( -
+
On Clock
) : null} diff --git a/app/lib/__tests__/draft-timer.test.ts b/app/lib/__tests__/draft-timer.test.ts new file mode 100644 index 0000000..04b24ff --- /dev/null +++ b/app/lib/__tests__/draft-timer.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from "vitest"; +import { + formatClockTime, + calculateTimeAfterPick, + getTimerColorClass, +} from "../draft-timer"; + +// ─── formatClockTime ───────────────────────────────────────────────────────── + +describe("formatClockTime", () => { + it("returns --:-- for undefined", () => { + expect(formatClockTime(undefined)).toBe("--:--"); + }); + + it("formats 0 seconds", () => { + expect(formatClockTime(0)).toBe("0:00"); + }); + + it("pads single-digit seconds", () => { + expect(formatClockTime(5)).toBe("0:05"); + expect(formatClockTime(9)).toBe("0:09"); + }); + + it("formats seconds under a minute", () => { + expect(formatClockTime(30)).toBe("0:30"); + expect(formatClockTime(59)).toBe("0:59"); + }); + + it("formats exactly one minute", () => { + expect(formatClockTime(60)).toBe("1:00"); + }); + + it("formats mixed minutes and seconds", () => { + expect(formatClockTime(90)).toBe("1:30"); + expect(formatClockTime(125)).toBe("2:05"); + expect(formatClockTime(150)).toBe("2:30"); + }); + + it("formats the default initial bank time", () => { + expect(formatClockTime(120)).toBe("2:00"); + }); + + it("formats hours when >= 3600 seconds", () => { + expect(formatClockTime(3600)).toBe("1:00:00"); + expect(formatClockTime(3661)).toBe("1:01:01"); + expect(formatClockTime(7322)).toBe("2:02:02"); + }); + + it("pads minutes when hours are present", () => { + expect(formatClockTime(3605)).toBe("1:00:05"); + expect(formatClockTime(3660)).toBe("1:01:00"); + }); +}); + +// ─── calculateTimeAfterPick ─────────────────────────────────────────────────── + +describe("calculateTimeAfterPick", () => { + it("adds increment to a healthy bank", () => { + expect(calculateTimeAfterPick(90, 30)).toBe(120); + }); + + it("adds increment when bank is exactly zero (timer expired before pick)", () => { + // Timer ran out but autopick fired — team still earns the increment + expect(calculateTimeAfterPick(0, 30)).toBe(30); + }); + + it("adds increment to a depleted bank (barely positive)", () => { + expect(calculateTimeAfterPick(3, 30)).toBe(33); + }); + + it("accumulates correctly across multiple quick picks", () => { + const increment = 30; + let bank = 120; // initialTime + + // Round 1 — uses 10s, picks fast + bank -= 10; + bank = calculateTimeAfterPick(bank, increment); // 140 + + // Round 2 — uses 20s + bank -= 20; + bank = calculateTimeAfterPick(bank, increment); // 150 + + // Round 3 — uses 5s + bank -= 5; + bank = calculateTimeAfterPick(bank, increment); // 175 + + expect(bank).toBe(175); + }); + + it("depletes over many slow picks", () => { + const increment = 30; + let bank = 120; + + // Each round the team uses more time than the increment replenishes + for (let round = 0; round < 5; round++) { + bank -= 50; // uses 50s each turn (net loss of 20s per pick) + bank = calculateTimeAfterPick(bank, increment); + } + + // Started at 120, lost 20s net × 5 rounds = 20s remaining + expect(bank).toBe(20); + }); + + it("works with a non-default increment value", () => { + expect(calculateTimeAfterPick(60, 15)).toBe(75); + expect(calculateTimeAfterPick(60, 60)).toBe(120); + }); +}); + +// ─── getTimerColorClass ─────────────────────────────────────────────────────── + +describe("getTimerColorClass", () => { + it("returns muted for undefined (clock not running)", () => { + expect(getTimerColorClass(undefined)).toBe("text-muted-foreground"); + }); + + it("returns green for time well above the warning threshold (> 60s)", () => { + expect(getTimerColorClass(120)).toBe("text-emerald-400"); + expect(getTimerColorClass(61)).toBe("text-emerald-400"); + }); + + it("returns amber at the 60-second boundary", () => { + // 60 is NOT > 60, so it falls to amber + expect(getTimerColorClass(60)).toBe("text-amber-accent"); + }); + + it("returns amber for time between 31–60s", () => { + expect(getTimerColorClass(45)).toBe("text-amber-accent"); + expect(getTimerColorClass(31)).toBe("text-amber-accent"); + }); + + it("returns coral at the 30-second boundary", () => { + expect(getTimerColorClass(30)).toBe("text-coral-accent"); + }); + + it("returns coral for time between 11–30s", () => { + expect(getTimerColorClass(20)).toBe("text-coral-accent"); + expect(getTimerColorClass(11)).toBe("text-coral-accent"); + }); + + it("returns coral+pulse at the 10-second boundary", () => { + expect(getTimerColorClass(10)).toContain("text-coral-accent"); + expect(getTimerColorClass(10)).toContain("animate-pulse"); + }); + + it("returns coral+pulse for critical time (≤ 10s)", () => { + expect(getTimerColorClass(5)).toContain("animate-pulse"); + expect(getTimerColorClass(1)).toContain("animate-pulse"); + expect(getTimerColorClass(0)).toContain("animate-pulse"); + }); + + it("never returns animate-pulse above the critical threshold", () => { + expect(getTimerColorClass(11)).not.toContain("animate-pulse"); + expect(getTimerColorClass(30)).not.toContain("animate-pulse"); + expect(getTimerColorClass(120)).not.toContain("animate-pulse"); + }); +}); + +// ─── Draft clock lifecycle scenarios ───────────────────────────────────────── +// +// These tests document the intended chess-clock behaviour end-to-end and guard +// against the specific regressions we've hit: +// +// REGRESSION 1 — Timer reset: next team's bank was being reset to initialTime +// instead of left untouched. +// REGRESSION 2 — Double increment: increment was added both at pick-start AND +// after the pick, giving teams 2× the intended bonus. + +describe("Draft clock lifecycle", () => { + const INITIAL_TIME = 120; + const INCREMENT = 30; + + it("every team starts with exactly initialTime — no increment added upfront", () => { + // draft.start.ts sets timeRemaining: initialTime (not initialTime + increment) + const startingBank = INITIAL_TIME; + expect(startingBank).toBe(120); + expect(startingBank).not.toBe(INITIAL_TIME + INCREMENT); + }); + + it("REGRESSION 1 — next team's bank is preserved when another team picks", () => { + // Team B has been on the clock and has 75s remaining. + // Team A (not team B) makes a pick. + // Team B's bank must NOT be touched at all. + const teamBBank = 75; + + // Verify that calling calculateTimeAfterPick on team B WOULD change the value — + // this proves that a mutation is detectable and the correct behaviour is to skip it. + const incorrectlyUpdated = calculateTimeAfterPick(teamBBank, INCREMENT); + expect(incorrectlyUpdated).not.toBe(teamBBank); // 105 ≠ 75: mutation would be visible + + // Correct behaviour: team B's bank is left unchanged (no calculateTimeAfterPick called) + expect(teamBBank).toBe(75); + expect(teamBBank).not.toBe(INITIAL_TIME); // 75 ≠ 120: not reset to initialTime (old bug) + }); + + it("REGRESSION 2 — increment is added exactly once per pick (post-pick only)", () => { + // Team A is on the clock. They have 90s and use 30s before picking. + let teamABank = 90; + teamABank -= 30; // 60s left at pick time + + // Post-pick reward: +increment once + teamABank = calculateTimeAfterPick(teamABank, INCREMENT); + + expect(teamABank).toBe(90); // 60 + 30 = 90 (not 120, which would be double-increment) + }); + + it("full two-team, three-round snake draft simulation", () => { + // Settings + const init = INITIAL_TIME; // 120s + const inc = INCREMENT; // 30s + + let teamA = init; // 120 + let teamB = init; // 120 + + // ── Round 1 ────────────────────────── + // Pick 1: Team A on clock, uses 20s + teamA -= 20; // 100 + teamA = calculateTimeAfterPick(teamA, inc); // 130 + // teamB untouched: 120 + + // Pick 2: Team B on clock, uses 60s + teamB -= 60; // 60 + teamB = calculateTimeAfterPick(teamB, inc); // 90 + // teamA untouched: 130 + + // ── Round 2 (reversed) ─────────────── + // Pick 3: Team B on clock, uses 40s + teamB -= 40; // 50 + teamB = calculateTimeAfterPick(teamB, inc); // 80 + // teamA untouched: 130 + + // Pick 4: Team A on clock, uses 10s + teamA -= 10; // 120 + teamA = calculateTimeAfterPick(teamA, inc); // 150 + // teamB untouched: 80 + + // ── Round 3 ────────────────────────── + // Pick 5: Team A on clock, uses 80s + teamA -= 80; // 70 + teamA = calculateTimeAfterPick(teamA, inc); // 100 + // teamB untouched: 80 + + // Pick 6: Team B on clock, uses 75s + teamB -= 75; // 5 + teamB = calculateTimeAfterPick(teamB, inc); // 35 + // teamA untouched: 100 + + expect(teamA).toBe(100); // quick picker — healthy bank + expect(teamB).toBe(35); // slow picker — barely surviving + }); + + it("team that consistently runs out of time survives on increments alone", () => { + let bank = INITIAL_TIME; // 120 + + for (let round = 0; round < 4; round++) { + // Use the entire bank each round (timer hits 0 before pick fires) + bank = 0; + bank = calculateTimeAfterPick(bank, INCREMENT); + } + + // After 4 rounds of running the clock dry, bank = just the increment + expect(bank).toBe(INCREMENT); // 30s — still alive, just barely + }); +}); diff --git a/app/lib/draft-timer.ts b/app/lib/draft-timer.ts new file mode 100644 index 0000000..5793e46 --- /dev/null +++ b/app/lib/draft-timer.ts @@ -0,0 +1,59 @@ +/** + * Pure utility functions for the draft clock/timer system. + * Extracted here to be independently testable and shared across + * route handlers and UI components. + * + * Clock model (chess-clock / Fischer increment): + * - Draft start: every team receives `initialTime` as their bank. + * - While on the clock: the active team's bank counts down each second. + * - After a pick is made: the picker's bank += `incrementTime`. + * - Between turns: all other teams' banks are left untouched. + */ + +/** + * Formats a seconds value for display. + * undefined → "--:--" + * < 3600 s → "m:ss" + * >= 3600 s → "h:mm:ss" + */ +export function formatClockTime(seconds: number | undefined): string { + if (seconds === undefined) return "--:--"; + const clamped = Math.max(0, seconds); // guard against negative values from timer drift + const h = Math.floor(clamped / 3600); + const m = Math.floor((clamped % 3600) / 60); + const s = clamped % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * Returns a team's new bank time after they complete a pick. + * The increment is the post-pick reward (chess clock model). + * + * @param bankTime Current remaining seconds in the team's bank + * @param incrementTime Bonus seconds awarded after each pick + */ +export function calculateTimeAfterPick( + bankTime: number, + incrementTime: number +): number { + return bankTime + incrementTime; +} + +/** + * Returns the Tailwind colour class(es) for a timer value. + * + * > 60 s → green (plenty of time) + * > 30 s → amber (getting tight) + * > 10 s → coral (urgent) + * ≤ 10 s → coral + animate-pulse (critical) + * undefined → muted (clock not running) + */ +export function getTimerColorClass(seconds: number | undefined): string { + if (seconds === undefined) return "text-muted-foreground"; + if (seconds > 60) return "text-emerald-400"; + if (seconds > 30) return "text-amber-accent"; + if (seconds > 10) return "text-coral-accent"; + return "text-coral-accent animate-pulse"; +} diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 9a202f5..c33d6c2 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -6,6 +6,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo import { getParticipantsForSeasonWithSports } from "./participant"; import { getSeasonSportsSimple } from "./season-sport"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; +import { getSocketIO } from "../../server/socket"; /** * Check if the next team has autodraft enabled and immediately execute their pick @@ -492,6 +493,19 @@ export async function executeAutoPick(params: { ? (commissionerUserId || "") : ""; + // Fetch current timer before pick so we have the time remaining at decision point + const incrementTime = season.draftIncrementTime || 30; + const currentTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, teamId) + ), + }); + + if (!currentTimer) { + console.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`); + } + // Create the draft pick const [draftPick] = await db .insert(schema.draftPicks) @@ -504,7 +518,8 @@ export async function executeAutoPick(params: { pickInRound, pickedByUserId, pickedByType: "auto", - timeUsed: triggeredBy === "timer" ? (season.draftInitialTime || 120) : undefined, + // timeRemaining at pick moment: 0 when timer expired, full bank for immediate autodraft + timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, }) .returning(); @@ -517,107 +532,33 @@ export async function executeAutoPick(params: { const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; - // Add increment to the team that just picked - const currentTimer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, teamId) - ), - }); - + // Add increment to the team that just picked (post-pick reward) if (currentTimer) { - const incrementTime = season.draftIncrementTime || 30; const newTimeRemaining = currentTimer.timeRemaining + incrementTime; await db .update(schema.draftTimers) - .set({ - timeRemaining: newTimeRemaining, - updatedAt: new Date(), - }) + .set({ timeRemaining: newTimeRemaining, updatedAt: new Date() }) .where(eq(schema.draftTimers.id, currentTimer.id)); - // Emit timer update to all clients + console.log( + `[AutoPick] Added ${incrementTime}s increment to team ${teamId} after pick: ${currentTimer.timeRemaining}s → ${newTimeRemaining}s` + ); + try { - const io = (global as any).__socketIO; - if (io) { - io.to(`draft-${seasonId}`).emit("timer-update", { - seasonId, - teamId, - timeRemaining: newTimeRemaining, - currentPickNumber: pickNumber, - }); - } + getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId, + timeRemaining: newTimeRemaining, + currentPickNumber: pickNumber, + }); } catch (error) { console.error("[AutoPick] Socket.IO timer-update error:", error); } } - // Initialize timer for the next team BEFORE updating pick number (prevents race condition) - if (!isDraftComplete) { - // Calculate which team is next using snake draft logic - const nextRound = Math.ceil(nextPickNumber / totalTeams); - const isNextRoundEven = nextRound % 2 === 0; - let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + // Next team's timer is unchanged — their bank carries forward as-is - // Apply snake draft reversal for even rounds - if (isNextRoundEven) { - nextPickInRound = totalTeams - nextPickInRound + 1; - } - - const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); - - if (nextDraftSlot) { - const nextTeamId = nextDraftSlot.teamId; - const initialTime = season.draftInitialTime || 120; - - console.log( - `[AutoPick] Initializing timer for next team ${nextTeamId} with ${initialTime}s (pick ${nextPickNumber})` - ); - - // Check if timer already exists for next team - const nextTimer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, nextTeamId) - ), - }); - - if (nextTimer) { - // Update existing timer - await db - .update(schema.draftTimers) - .set({ - timeRemaining: initialTime, - updatedAt: new Date(), - }) - .where(eq(schema.draftTimers.id, nextTimer.id)); - } else { - // Create new timer if it doesn't exist - await db.insert(schema.draftTimers).values({ - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - }); - } - - // Emit timer update for next team - try { - const io = (global as any).__socketIO; - if (io) { - io.to(`draft-${seasonId}`).emit("timer-update", { - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - currentPickNumber: nextPickNumber, - }); - } - } catch (error) { - console.error("[AutoPick] Socket.IO next timer-update error:", error); - } - } - } - - // Update season's current pick number (AFTER initializing next timer to prevent race condition) + // Update season's current pick number await db .update(schema.seasons) .set({ @@ -649,14 +590,11 @@ export async function executeAutoPick(params: { // Emit autodraft-updated event try { - const io = (global as any).__socketIO; - if (io) { - io.to(`draft-${seasonId}`).emit("autodraft-updated", { - teamId, - isEnabled: false, - mode: autodraftSettings.mode, - }); - } + getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", { + teamId, + isEnabled: false, + mode: autodraftSettings.mode, + }); } catch (error) { console.error("[AutoPick] Socket.IO autodraft-updated error:", error); } @@ -664,34 +602,32 @@ export async function executeAutoPick(params: { // Emit socket events try { - const io = (global as any).__socketIO; - if (io) { - const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; + const io = getSocketIO(); + const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; - // Emit participant-removed-from-queues event - io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", { - participantId: participantToPick.id, - }); + // Emit participant-removed-from-queues event + io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", { + participantId: participantToPick.id, + }); - // Emit pick-made event - io.to(`draft-${seasonId}`).emit("pick-made", { - pick: { - ...draftPick, - team, - participant: { - ...participantToPick, - sport: participantToPick.sportsSeason.sport, - }, + // Emit pick-made event + io.to(`draft-${seasonId}`).emit("pick-made", { + pick: { + ...draftPick, + team, + participant: { + ...participantToPick, sport: participantToPick.sportsSeason.sport, }, - nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, - isDraftComplete, - }); + sport: participantToPick.sportsSeason.sport, + }, + nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + isDraftComplete, + }); - // Emit draft-completed event if applicable - if (isDraftComplete) { - io.to(`draft-${seasonId}`).emit("draft-completed"); - } + // Emit draft-completed event if applicable + if (isDraftComplete) { + io.to(`draft-${seasonId}`).emit("draft-completed"); } } catch (error) { console.error("[AutoPick] Socket.IO events error:", error); diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index f2e9f9e..fbe1cdd 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -36,6 +36,14 @@ export async function action(args: any) { return Response.json({ error: "Season not found" }, { status: 404 }); } + if (season.status !== "draft") { + return Response.json({ error: "Draft is not currently active" }, { status: 400 }); + } + + if (season.draftPaused) { + return Response.json({ error: "Draft is currently paused" }, { status: 400 }); + } + // Get current draft slot (who should be picking now) const currentPickNumber = season.currentPickNumber || 1; const draftSlots = await db.query.draftSlots.findMany({ @@ -169,7 +177,8 @@ export async function action(args: any) { const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; - // Add increment to the team that just picked + // Add increment to the team that just picked (post-pick reward) + const incrementTime = season.draftIncrementTime || 30; const currentTimer = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, seasonId), @@ -177,18 +186,15 @@ export async function action(args: any) { ), }); - if (currentTimer) { - const incrementTime = season.draftIncrementTime || 30; + if (!currentTimer) { + console.warn(`[Pick] No timer found for team ${currentDraftSlot.teamId} in season ${seasonId}`); + } else { const newTimeRemaining = currentTimer.timeRemaining + incrementTime; await db .update(schema.draftTimers) - .set({ - timeRemaining: newTimeRemaining, - updatedAt: new Date(), - }) + .set({ timeRemaining: newTimeRemaining, updatedAt: new Date() }) .where(eq(schema.draftTimers.id, currentTimer.id)); - // Emit timer update to all clients try { getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { seasonId, @@ -201,63 +207,8 @@ export async function action(args: any) { } } - // Initialize timer for the next team BEFORE updating pick number (prevents race condition) - if (!isDraftComplete) { - // Calculate which team is next using snake draft logic - const nextRound = Math.ceil(nextPickNumber / totalTeams); - const isNextRoundEven = nextRound % 2 === 0; - let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; - - // Apply snake draft reversal for even rounds - if (isSnakeDraft && isNextRoundEven) { - nextPickInRound = totalTeams - nextPickInRound + 1; - } - - const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); - - if (nextDraftSlot) { - const nextTeamId = nextDraftSlot.teamId; - const initialTime = season.draftInitialTime || 120; - - // Check if timer already exists for next team - const nextTimer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, nextTeamId) - ), - }); - - if (nextTimer) { - // Update existing timer - await db - .update(schema.draftTimers) - .set({ - timeRemaining: initialTime, - updatedAt: new Date(), - }) - .where(eq(schema.draftTimers.id, nextTimer.id)); - } else { - // Create new timer if it doesn't exist - await db.insert(schema.draftTimers).values({ - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - }); - } - - // Emit timer update for next team - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - currentPickNumber: nextPickNumber, - }); - } catch (error) { - console.error("Socket.IO next timer-update error:", error); - } - } - } + // Next team's timer is unchanged — their bank carries forward as-is + // (no emit needed; the timer system will start decrementing their existing bank) // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index b5ffca0..addf358 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -62,23 +62,27 @@ export async function action(args: any) { where: eq(schema.draftSlots.seasonId, seasonId), }); - const initialTime = season.draftInitialTime || 120; // Default 2 minutes + if (draftSlots.length === 0) { + return Response.json({ error: "No draft slots found for this season" }, { status: 400 }); + } + + const initialTime = season.draftInitialTime || 120; // Delete existing timers for this season await db .delete(schema.draftTimers) .where(eq(schema.draftTimers.seasonId, seasonId)); - // Insert new timers for all teams - for (const slot of draftSlots) { - await db - .insert(schema.draftTimers) - .values({ + // Insert new timers for all teams in a single batch + await db + .insert(schema.draftTimers) + .values( + draftSlots.map((slot) => ({ seasonId, teamId: slot.teamId, timeRemaining: initialTime, - }); - } + })) + ); // Emit socket event try { diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 7435d69..96a81fe 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -23,6 +23,7 @@ import { getTeamForPick } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { NotificationSettings } from "~/components/NotificationSettings"; import { toast } from "sonner"; +import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; export async function loader(args: any) { const { params } = args; @@ -819,6 +820,9 @@ export default function DraftRoom() { ); const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active + const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined; + const currentClockColor = getTimerColorClass(currentClockTime); + // Generate snake draft grid structure const draftGrid = useMemo(() => { const teamCount = draftSlots.length; @@ -1083,11 +1087,43 @@ export default function DraftRoom() { } className="h-full flex flex-col" > - - Available Participants - Draft Board - Teams Drafted - +
+
+ + Available Participants + Draft Board + Teams Drafted + + + {season.status === "draft" && !isDraftComplete && currentDraftSlot && ( +
+
+
+ {isMyTurn ? "Your Turn!" : "On the Clock"} +
+
+ {currentDraftSlot.team.name} +
+
+ {isPaused ? ( + Paused + ) : ( + + {formatClockTime(currentClockTime)} + + )} +
+ )} +
+