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<string, number | undefined>
- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-21 16:51:12 -08:00 committed by GitHub
parent 41f3654956
commit 7a8ea60e77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 466 additions and 224 deletions

View file

@ -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<string, number>;
teamTimers: Record<string, number | undefined>;
autodraftStatus: Record<string, boolean>;
connectedTeams: Set<string>;
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 (
<div className="h-full overflow-auto p-4">
@ -90,14 +76,20 @@ export function DraftGridSection({
return (
<div key={slot.id} className="flex-1 min-w-32 text-center">
<div
className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}
className={`font-semibold text-sm truncate px-2 ${
slot.team.id === currentTeamId
? "text-electric font-bold"
: !isConnected
? "italic text-muted-foreground"
: ""
}`}
>
{slot.team.name}
</div>
<div
className={`text-xs font-mono ${getTimerColor(teamTime)}`}
className={`text-xs font-mono ${getTimerColorClass(teamTime)}`}
>
{formatTime(teamTime)}
{formatClockTime(teamTime)}
{isAutodraft && (
<span className="ml-1 text-muted-foreground">
(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({
</div>
</div>
) : isCurrent ? (
<div className="text-xs font-semibold text-electric">
<div className="text-sm font-bold text-electric animate-pulse">
On Clock
</div>
) : null}

View file

@ -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 3160s", () => {
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 1130s", () => {
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
});
});

59
app/lib/draft-timer.ts Normal file
View file

@ -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";
}

View file

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

View file

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

View file

@ -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 {

View file

@ -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"
>
<TabsList className="mx-4 mt-4">
<TabsTrigger value="participants">Available Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
</TabsList>
<div className={`mt-4 transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete
? "bg-electric/25 border-y-2 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: ""
}`}>
<div className="flex items-center gap-3 px-4 py-2">
<TabsList>
<TabsTrigger value="participants">Available Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
</TabsList>
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div className={`ml-auto flex items-center gap-2.5 rounded-lg px-3 py-1.5 flex-shrink-0 transition-all ${
isMyTurn
? "bg-electric/30 border-2 border-electric shadow-md shadow-electric/30"
: "border border-border/50"
}`}>
<div className="leading-tight">
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
{isMyTurn ? "Your Turn!" : "On the Clock"}
</div>
<div className={`text-sm font-bold ${isMyTurn ? "text-foreground" : "text-muted-foreground"}`}>
{currentDraftSlot.team.name}
</div>
</div>
{isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span>
) : (
<span className={`text-2xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)}
</span>
)}
</div>
)}
</div>
</div>
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
<div className="h-full">