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:
parent
41f3654956
commit
7a8ea60e77
7 changed files with 466 additions and 224 deletions
|
|
@ -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}
|
||||
|
|
|
|||
264
app/lib/__tests__/draft-timer.test.ts
Normal file
264
app/lib/__tests__/draft-timer.test.ts
Normal 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 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
|
||||
});
|
||||
});
|
||||
59
app/lib/draft-timer.ts
Normal file
59
app/lib/draft-timer.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue