brackt/app/routes/leagues/$leagueId.draft.$seasonId.tsx
Chris Parsons 2848231235
Canonical tournament layer: schema + backfill (1/2) (#365)
* refactor(schema): rename per-window tables to season_* prefix

Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: rename participant.ts model file to season-participant.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): update model layer to use renamed schema exports

Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant

Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts

Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route layer to use renamed schema exports

- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file

Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts

Error count reduced from 499 to 453 (46 errors fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route files for schema rename

Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(services): update simulators and services for renamed schema

Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId

Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts

Typecheck errors reduced from 365 to 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: rename per-window tables to season_* prefix

* fix(tests): update mock query keys after participants table rename

Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update remaining mock paths and keys after schema rename

* fix(tests): final two mock stragglers after schema rename

- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add post-phase1a baseline capture (temp, for diff verification)

* chore: capture pre-migration baselines

* chore: remove post-phase1a capture helper after verification

* schema: add canonical tournament & participant tables

Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(models): add canonical tournament, participant, result, surface-elo models

Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).

Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
  createCanonicalParticipant, etc.) to avoid collision with existing
  season-participant.ts exports
- All four models include comprehensive unit tests following the
  audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints

Part of Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: create canonical tables, add nullable FKs

* scripts: add extractTournamentIdentity helper for backfill

Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.

* scripts: add backfill orchestrator for canonical layer

Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.

Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
  (recorded in report.errors) rather than overwriting

* scripts: add backfill CLI with dry-run default

Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).

* fix(backfill-cli): wrap runBackfill in DatabaseContext.run

The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backfill-cli): exit 0 on success so pg pool doesn't block

The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 20:13:18 -07:00

1679 lines
60 KiB
TypeScript

import { useLoaderData, Link, useRevalidator, redirect, useNavigate } from "react-router";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { logger } from "~/lib/logger";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { auth } from "~/lib/auth.server";
import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState";
import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents";
import { RollbackConfirmDialog } from "~/components/draft/RollbackConfirmDialog";
import { TimeBankAdjustmentDialog } from "~/components/draft/TimeBankAdjustmentDialog";
import { CommissionerAutodraftDialog } from "~/components/draft/CommissionerAutodraftDialog";
import { CommissionerDraftControls } from "~/components/draft/CommissionerDraftControls";
import { getTeamQueue } from "~/models/draft-queue";
import { getTeamWatchlist } from "~/models/watchlist";
import { findSeasonWithTeamsAndLeague } from "~/models/season";
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import { getDraftPicksForSeason } from "~/models/draft-pick";
import { getDraftParticipants } from "~/models/season-participant";
import { getSeasonTimers } from "~/models/draft-timer";
import { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
import { hasCommissionerRecord } from "~/models/commissioner";
import { findUsersByIds, getUserDisplayName } from "~/models/user";
import { isInOvernightWindow } from "~/lib/overnight-pause";
import logomarkUrl from "../../../public/logomark.svg?url";
import { DraftSidebar } from "~/components/DraftSidebar";
import { TeamRosterView } from "~/components/draft/TeamRosterView";
import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
import { QueueSection } from "~/components/draft/QueueSection";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order";
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { NotificationSettings } from "~/components/NotificationSettings";
import { AutodraftBadgeWithPopover, AutodraftSettings } from "~/components/AutodraftSettings";
import { toast } from "sonner";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
import type { Route } from "./+types/$leagueId.draft.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Draft — ${data?.season?.league?.name ?? "League"} - Brackt` }];
}
const MOBILE_TABS_BASE = [
{ id: "available" as const, label: "Participants", Icon: Users },
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
{ id: "controls" as const, label: "Controls", Icon: Settings },
];
const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered };
export async function loader(args: Route.LoaderArgs) {
const { params } = args;
const { seasonId, leagueId } = params;
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!seasonId) {
throw new Response("Season ID is required", { status: 400 });
}
const season = await findSeasonWithTeamsAndLeague(seasonId);
if (!season) {
throw new Response("Season not found", { status: 404 });
}
if (season.draftCompletedAt) {
const elapsed = Date.now() - season.draftCompletedAt.getTime();
if (elapsed >= 5 * 60 * 1000) {
return redirect(`/leagues/${leagueId}/draft-board/${seasonId}`);
}
}
const userTeam = userId
? season.teams.find((team) => team.ownerId === userId)
: undefined;
const isCommissioner = userId
? await hasCommissionerRecord(leagueId, userId)
: false;
if (!season.league.isPublicDraftBoard) {
if (!userId) {
throw new Response("You must be logged in to view the draft room", {
status: 401,
});
}
if (!userTeam && !isCommissioner) {
throw new Response("You do not have access to this draft room", {
status: 403,
});
}
}
const [draftSlots, draftPicks, availableParticipants, timers, autodraftSettings] =
await Promise.all([
findDraftSlotsBySeasonId(seasonId),
getDraftPicksForSeason(seasonId),
getDraftParticipants(seasonId),
getSeasonTimers(seasonId),
getSeasonAutodraftSettings(seasonId),
]);
const userQueue = userTeam
? await getTeamQueue(userTeam.id).catch((err) => {
logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
return [];
})
: [];
const userWatchlist = userTeam
? await getTeamWatchlist(userTeam.id, seasonId).catch((err) => {
logger.error("[DraftLoader] Failed to load watchlist, defaulting to empty:", err);
return [];
})
: [];
const userAutodraftSettings = userTeam
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
: null;
// Single users query builds both the owner display-name map and the timezone
// map for overnight pause moon icons — avoids two separate findUsersByIds calls.
const uniqueOwnerIds = [...new Set(
draftSlots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
)];
const userRows = uniqueOwnerIds.length ? await findUsersByIds(uniqueOwnerIds) : [];
const userById = new Map(userRows.map((u) => [u.id, u]));
const ownerMap: Record<string, string> = {};
for (const slot of draftSlots) {
if (slot.team.ownerId) {
const user = userById.get(slot.team.ownerId);
const name = user ? getUserDisplayName(user) : null;
if (name) ownerMap[slot.team.id] = name;
}
}
const teamTimezoneMap: Record<string, string | null> =
season.overnightPauseMode === "per_user"
? Object.fromEntries(
draftSlots.map((slot) => [
slot.team.id,
slot.team.ownerId ? (userById.get(slot.team.ownerId)?.timezone || null) : null,
])
)
: {};
return {
season,
draftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
userWatchlist: userWatchlist.map((w) => w.participantId),
timers,
autodraftSettings,
userAutodraftSettings,
isCommissioner: !!isCommissioner,
currentUserId: userId,
ownerMap,
teamTimezoneMap,
};
}
export default function DraftRoom() {
const {
season,
draftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
userWatchlist,
timers,
autodraftSettings,
userAutodraftSettings,
isCommissioner,
currentUserId,
ownerMap,
teamTimezoneMap,
} = useLoaderData<typeof loader>();
const navigate = useNavigate();
const { revalidate, state: revalidatorState } = useRevalidator();
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
const {
permissionState: notificationsPermission,
enabled: notificationsEnabled,
setEnabled: setNotificationsEnabled,
mode: notificationsMode,
setMode: setNotificationsMode,
sendNotification,
} = useDraftNotifications(season.id, currentUserId ?? "");
// Use refs so the socket effect doesn't re-register all handlers when the user
// changes notification settings (which would change the sendNotification reference).
const sendNotificationRef = useRef(sendNotification);
useEffect(() => {
sendNotificationRef.current = sendNotification;
}, [sendNotification]);
const notificationsModeRef = useRef(notificationsMode);
useEffect(() => {
notificationsModeRef.current = notificationsMode;
}, [notificationsMode]);
const {
picks, setPicks,
currentPick, setCurrentPick,
searchQuery, setSearchQuery,
hideDrafted, setHideDrafted,
hideIneligible, setHideIneligible,
hideCompletedSports, setHideCompletedSports,
sportFilters, setSportFilters,
queue, setQueue,
isPaused, setIsPaused,
isDraftComplete, setIsDraftComplete,
sidebarCollapsed, setSidebarCollapsed,
activeTab, setActiveTab,
mobileTab, setMobileTab,
teamsSubTab, setTeamsSubTab,
boardSubTab, setBoardSubTab,
isMobile,
autodraftStatus, setAutodraftStatus,
connectedTeams, setConnectedTeams,
watchedParticipantIds, setWatchedParticipantIds,
showOnlyWatched, setShowOnlyWatched,
teamTimers, setTeamTimers,
forcePickDialogOpen, setForcePickDialogOpen,
selectedPickSlot, setSelectedPickSlot,
replacePickDialogOpen, setReplacePickDialogOpen,
replacePickSlot, setReplacePickSlot,
rollbackConfirmOpen, setRollbackConfirmOpen,
rollbackPickNumber, setRollbackPickNumber,
isRollingBack, setIsRollingBack,
timeBankDialogOpen, setTimeBankDialogOpen,
timeBankTeamId, setTimeBankTeamId,
commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen,
commissionerAutodraftTeamId, setCommissionerAutodraftTeamId,
timeBankAmount, setTimeBankAmount,
timeBankUnit, setTimeBankUnit,
timeBankDirection, setTimeBankDirection,
isAdjustingTimeBank, setIsAdjustingTimeBank,
isOvernightPause, setIsOvernightPause,
overnightResumesAt, setOvernightResumesAt,
roomClosed, setRoomClosed,
isSyncing, setIsSyncing,
} = useDraftRoomState({
draftPicks,
season,
userTeam,
isCommissioner,
autodraftSettings,
timers,
draftSlots,
userQueue,
initialWatchedParticipantIds: userWatchlist,
});
// Advances every second so isInOvernightWindow sees the real current time.
const [nowForPauseCheck, setNowForPauseCheck] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNowForPauseCheck(new Date()), 1_000);
return () => clearInterval(id);
}, []);
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
const roomClosureCountdown = useMemo(() => {
if (!isDraftComplete) return null;
const completedAt = season.draftCompletedAt;
if (!completedAt) return 300;
const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000;
const remaining = Math.max(0, 300 - elapsed);
return Math.ceil(remaining);
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
useEffect(() => {
if (!isDraftComplete) return;
if (roomClosed) {
navigate(draftBoardUrl);
return;
}
if (roomClosureCountdown !== null && roomClosureCountdown <= 0) {
navigate(draftBoardUrl);
}
}, [isDraftComplete, roomClosed, roomClosureCountdown, navigate, draftBoardUrl]);
// Compute which teams are currently in their overnight pause window.
const pausedTeamIds = useMemo(() => {
const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season;
if (mode === "none" || !start || !end) return new Set<string>();
const result = new Set<string>();
for (const slot of draftSlots) {
const tz = mode === "league"
? (fallbackTz || null)
: (teamTimezoneMap[slot.team.id] || fallbackTz || null);
if (tz && isInOvernightWindow(tz, start, end, nowForPauseCheck)) result.add(slot.team.id);
}
return result;
}, [season, draftSlots, teamTimezoneMap, nowForPauseCheck]);
const {
authDegraded,
authFetch,
userAutodraft,
setUserAutodraft,
userAutodraftRef,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
} = useDraftAuthRecovery({
reconnectCount,
revalidate,
revalidatorState,
currentUserId,
userAutodraftSettings,
draftPicks,
season,
userQueue,
timers,
autodraftSettings,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setQueue,
setTeamTimers,
setAutodraftStatus,
setIsSyncing,
});
useEffect(() => {
if (reconnectCount <= 0) return;
setIsSyncing(true);
const id = setTimeout(() => setIsSyncing(false), 5000);
return () => clearTimeout(id);
}, [reconnectCount, setIsSyncing]);
// Shared transforms for eligibility calculations
const transformedPicks = useMemo(
() =>
picks.map((p) => ({
teamId: p.team.id,
participant: {
id: p.participant.id,
sport: { id: p.sport.id, name: p.sport.name },
},
})),
[picks]
);
const transformedParticipants = useMemo(
() =>
availableParticipants.map((p) => ({
id: p.id,
sport: { id: p.sport.id, name: p.sport.name },
})),
[availableParticipants]
);
const seasonSportsData = useMemo(() => {
const sportsMap = new Map<string, { id: string; name: string }>();
availableParticipants.forEach((p) => {
if (!sportsMap.has(p.sport.id)) {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
}
});
return Array.from(sportsMap.values()).toSorted((a, b) =>
a.name.localeCompare(b.name)
);
}, [availableParticipants]);
const participantRanks = useMemo(() => {
const ranks = new Map<string, { overallRank: number; sportRank: number }>();
const sportCounters = new Map<string, number>();
availableParticipants.forEach((p, i) => {
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
sportCounters.set(p.sport.id, sportIdx);
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
});
return ranks;
}, [availableParticipants]);
// Calculate draft eligibility for current user's team
const eligibility = useMemo(() => {
if (!userTeam) return null;
const userTeamPicks = transformedPicks.filter(
(p) => p.teamId === userTeam.id
);
return calculateDraftEligibility(
userTeam.id,
userTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [userTeam, transformedPicks, transformedParticipants, seasonSportsData, season]);
// Calculate eligibility for force manual pick dialog (selected team)
const forcePickEligibility = useMemo(() => {
if (!selectedPickSlot) return null;
const selectedTeamPicks = transformedPicks.filter(
(p) => p.teamId === selectedPickSlot.teamId
);
return calculateDraftEligibility(
selectedPickSlot.teamId,
selectedTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
// Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free
const replacePickEligibility = useMemo(() => {
if (!replacePickSlot) return null;
const allPicksExcluding = transformedPicks.filter(
(p) => p.participant.id !== replacePickSlot.oldParticipantId
);
const selectedTeamPicks = allPicksExcluding.filter(
(p) => p.teamId === replacePickSlot.teamId
);
return calculateDraftEligibility(
replacePickSlot.teamId,
selectedTeamPicks,
allPicksExcluding,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
useDraftSocketEvents({
on,
off,
socketVersion,
userTeam,
draftSlots,
leagueName: season.league.name,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
userAutodraftRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
setIsSyncing,
});
// Persist sidebar collapsed state
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
}
}, [sidebarCollapsed]);
// Queue handlers
const handleAddToQueue = useCallback(async (participantId: string) => {
if (!userTeam) return;
// Check if participant is already in queue (client-side)
const alreadyInQueue = queue.some((item) => item.participantId === participantId);
if (alreadyInQueue) {
toast.error("This participant is already in your queue");
return;
}
// Optimistic update - create a temporary queue item
const now = new Date();
const tempQueueItem: QueueItem = {
id: `temp-${Date.now()}`,
participantId,
queuePosition: queue.length + 1,
seasonId: season.id,
teamId: userTeam.id,
createdAt: now,
updatedAt: now,
};
// Immediately update the UI
setQueue((prev) => [...prev, tempQueueItem]);
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
pendingQueueMutationsRef.current++;
try {
const response = await authFetch("/api/queue/add", {
method: "POST",
body: formData,
});
if (!response) { setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); return; }
if (response.ok) {
const data = await response.json();
// Replace temp item with real item from server
setQueue((prev) =>
prev.map((item) =>
item.id === tempQueueItem.id ? data.queueItem : item
)
);
} else {
// Revert the optimistic update on error
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
const error = await response.json();
toast.error(error.error || "Failed to add to queue");
}
} catch {
// Revert the optimistic update on network error
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
toast.error("Network error - failed to add to queue");
} finally {
pendingQueueMutationsRef.current--;
}
// queue is read directly (not via functional updater) for the duplicate check and
// optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection
// already re-renders when queue changes (it receives queue as a prop).
}, [userTeam, queue, season.id, authFetch, setQueue, pendingQueueMutationsRef]);
const handleRemoveFromQueue = useCallback(async (queueId: string) => {
if (!userTeam) return;
// Optimistically remove the item immediately
let previousQueue: QueueItem[] = [];
setQueue((prev) => {
previousQueue = prev;
return prev.filter((item) => item.id !== queueId);
});
const formData = new FormData();
formData.append("queueId", queueId);
formData.append("teamId", userTeam.id);
pendingQueueMutationsRef.current++;
try {
const response = await authFetch("/api/queue/remove", {
method: "POST",
body: formData,
});
if (!response) { setQueue(previousQueue); return; }
if (response.ok) {
const data = await response.json();
setQueue(data.queue);
} else {
setQueue(previousQueue);
const error = await response.json();
toast.error(error.error || "Failed to remove from queue");
}
} catch {
setQueue(previousQueue);
toast.error("Network error - failed to remove from queue");
} finally {
pendingQueueMutationsRef.current--;
}
}, [userTeam, authFetch, setQueue, pendingQueueMutationsRef]);
const handleReorderQueue = useCallback(async (participantIds: string[]) => {
if (!userTeam) return;
// Optimistically reorder the queue immediately
let previousQueue: QueueItem[] = [];
setQueue((prev) => {
previousQueue = prev;
return participantIds
.map((pid) => prev.find((item) => item.participantId === pid))
.filter((item): item is QueueItem => item !== undefined);
});
const formData = new FormData();
formData.append("teamId", userTeam.id);
formData.append("seasonId", season.id);
formData.append("participantIds", JSON.stringify(participantIds));
pendingQueueMutationsRef.current++;
try {
const response = await authFetch("/api/queue/reorder", {
method: "POST",
body: formData,
});
if (!response) { setQueue(previousQueue); return; }
if (response.ok) {
const data = await response.json();
setQueue(data.queue);
} else {
const error = await response.json();
setQueue(previousQueue);
toast.error(error.error || "Failed to reorder queue");
}
} catch {
setQueue(previousQueue);
toast.error("Network error - failed to reorder queue");
} finally {
pendingQueueMutationsRef.current--;
}
}, [userTeam, season.id, authFetch, setQueue, pendingQueueMutationsRef]);
const handleToggleWatchlist = useCallback(async (participantId: string) => {
if (!userTeam) return;
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) {
next.delete(participantId);
} else {
next.add(participantId);
}
return next;
});
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
try {
const response = await authFetch("/api/watchlist/toggle", {
method: "POST",
body: formData,
});
if (!response) {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
return;
}
if (!response.ok) {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
const error = await response.json();
toast.error(error.error || "Failed to update watchlist");
}
} catch {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
toast.error("Network error - failed to update watchlist");
}
}, [userTeam, season.id, authFetch, setWatchedParticipantIds]);
const handleStartDraft = async () => {
try {
const formData = new FormData();
formData.append("seasonId", season.id);
const response = await authFetch("/api/draft/start", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
revalidate();
} else {
const error = await response.json();
toast.error(error.error || "Failed to start draft");
}
} catch {
toast.error("Network error - failed to start draft");
}
};
const handlePauseDraft = async () => {
const formData = new FormData();
formData.append("seasonId", season.id);
try {
const response = await authFetch("/api/draft/pause", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
setIsPaused(true);
} else {
const error = await response.json();
toast.error(error.error || "Failed to pause draft");
}
} catch {
toast.error("Network error - failed to pause draft");
}
};
const handleResumeDraft = async () => {
const formData = new FormData();
formData.append("seasonId", season.id);
try {
const response = await authFetch("/api/draft/resume", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
setIsPaused(false);
} else {
const error = await response.json();
toast.error(error.error || "Failed to resume draft");
}
} catch {
toast.error("Network error - failed to resume draft");
}
};
const handleMakePick = useCallback(async (participantId: string) => {
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("participantId", participantId);
const response = await authFetch("/api/draft/make-pick", {
method: "POST",
body: formData,
});
if (!response) return;
if (!response.ok) {
const error = await response.json();
toast.error(error.error || "Failed to make pick");
}
} catch {
toast.error("Network error - failed to make pick");
}
}, [season.id, authFetch]);
const handleForceAutopick = useCallback(async (pickNumber: number, teamId: string) => {
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", teamId);
formData.append("pickNumber", pickNumber.toString());
const response = await authFetch("/api/draft/force-autopick", {
method: "POST",
body: formData,
});
if (!response) return;
if (!response.ok) {
const error = await response.json();
toast.error(error.error || "Failed to force autopick");
}
} catch {
toast.error("Network error - failed to force autopick");
}
}, [season.id, authFetch]);
const handleForceManualPick = async (participantId: string) => {
if (!selectedPickSlot) return;
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", selectedPickSlot.teamId);
formData.append("participantId", participantId);
formData.append("pickNumber", selectedPickSlot.pickNumber.toString());
const response = await authFetch("/api/draft/force-manual-pick", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
setForcePickDialogOpen(false);
setSelectedPickSlot(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to force manual pick");
}
} catch {
toast.error("Network error - failed to force manual pick");
}
};
const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => {
const pick = picks.find((p) => p.pickNumber === pickNumber);
if (!pick) {
toast.error("Pick not found — try refreshing the page");
return;
}
setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id });
setReplacePickDialogOpen(true);
// picks is read directly to find the pick being replaced.
}, [picks, setReplacePickSlot, setReplacePickDialogOpen]);
const handleReplacePick = async (participantId: string) => {
if (!replacePickSlot) return;
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", replacePickSlot.teamId);
formData.append("participantId", participantId);
formData.append("pickNumber", replacePickSlot.pickNumber.toString());
const response = await authFetch("/api/draft/replace-pick", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
setReplacePickDialogOpen(false);
setReplacePickSlot(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to replace pick");
}
} catch {
toast.error("Network error - failed to replace pick");
}
};
const handleRollbackToPick = useCallback((pickNumber: number) => {
setRollbackPickNumber(pickNumber);
setRollbackConfirmOpen(true);
}, [setRollbackPickNumber, setRollbackConfirmOpen]);
const handleConfirmRollback = async () => {
if (!rollbackPickNumber || isRollingBack) return;
setIsRollingBack(true);
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("pickNumber", rollbackPickNumber.toString());
const response = await authFetch("/api/draft/rollback", {
method: "POST",
body: formData,
});
setIsRollingBack(false);
if (!response) return;
if (response.ok) {
setRollbackConfirmOpen(false);
setRollbackPickNumber(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to roll back draft");
}
} catch {
setIsRollingBack(false);
toast.error("Network error - failed to roll back draft");
}
};
const handleAdjustTimeBankOpen = useCallback((teamId: string) => {
setTimeBankTeamId(teamId);
setTimeBankAmount("1");
setTimeBankUnit("minutes");
setTimeBankDirection("add");
setTimeBankDialogOpen(true);
}, [setTimeBankTeamId, setTimeBankAmount, setTimeBankUnit, setTimeBankDirection, setTimeBankDialogOpen]);
const handleSetAutodraftOpen = useCallback((teamId: string) => {
setCommissionerAutodraftTeamId(teamId);
setCommissionerAutodraftDialogOpen(true);
}, [setCommissionerAutodraftTeamId, setCommissionerAutodraftDialogOpen]);
const handleCommissionerAutodraftUpdate = useCallback(() => {
setCommissionerAutodraftDialogOpen(false);
setCommissionerAutodraftTeamId(null);
}, [setCommissionerAutodraftDialogOpen, setCommissionerAutodraftTeamId]);
const handleConfirmAdjustTimeBank = async () => {
if (!timeBankTeamId || isAdjustingTimeBank) return;
const amount = parseFloat(timeBankAmount);
if (isNaN(amount) || amount <= 0) {
toast.error("Please enter a valid positive amount");
return;
}
const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 };
const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]);
if (totalSeconds === 0) {
toast.error("Adjustment rounds to 0 seconds — please enter a larger value");
return;
}
const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds;
setIsAdjustingTimeBank(true);
try {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", timeBankTeamId);
formData.append("adjustment", adjustment.toString());
const response = await authFetch("/api/draft/adjust-time-bank", {
method: "POST",
body: formData,
});
if (!response) return;
if (response.ok) {
const data = await response.json();
setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining }));
const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name;
toast.success(`Time bank adjusted for ${teamName}`);
setTimeBankDialogOpen(false);
setTimeBankTeamId(null);
} else {
const error = await response.json();
toast.error(error.error || "Failed to adjust time bank");
}
} catch {
toast.error("Network error — failed to adjust time bank");
} finally {
setIsAdjustingTimeBank(false);
}
};
const handleForceManualPickOpen = useCallback((pickNumber: number, teamId: string) => {
setSelectedPickSlot({ pickNumber, teamId });
setForcePickDialogOpen(true);
}, [setSelectedPickSlot, setForcePickDialogOpen]);
// Calculate current round
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
// Determine whose turn it is
const currentDraftSlot = useMemo(
() => getTeamForPick(currentPick, draftSlots),
[currentPick, draftSlots]
);
const isMyTurn = !!(
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
);
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
const currentClockColor = getTimerColorClass(currentClockTime);
// Generate snake draft grid structure
const draftGrid = useMemo(() => {
const teamCount = draftSlots.length;
const rounds = season.draftRounds;
const pickMap = new Map(picks.map((p) => [p.pickNumber, p]));
const grid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: (typeof draftPicks)[number];
}>
> = [];
for (let round = 1; round <= rounds; round++) {
const roundPicks = [];
const isOddRound = round % 2 === 1;
for (let i = 0; i < teamCount; i++) {
const pickInRound = i + 1;
const teamIndex = isOddRound ? i : teamCount - 1 - i;
const pickNumber = (round - 1) * teamCount + pickInRound;
const teamId = draftSlots[teamIndex]?.team.id;
const pick = pickMap.get(pickNumber);
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
}
grid.push(roundPicks);
}
return grid;
}, [picks, draftSlots, season.draftRounds]);
const projectedPicks = useMemo(() => {
if (!userTeam || season.status !== "draft" || isDraftComplete) return [];
return getProjectedPicks({
draftSlots,
userTeamId: userTeam.id,
currentPick,
totalRounds: season.draftRounds,
existingPicks: picks.map((p) => ({
pickNumber: p.pickNumber,
teamId: p.team.id,
})),
});
}, [userTeam, season.status, isDraftComplete, draftSlots, currentPick, season.draftRounds, picks]);
// Get drafted participant IDs for filtering
const draftedParticipantIds = useMemo(
() => new Set(picks.map((p) => p.participant.id)),
[picks]
);
// Sport IDs where the current user's team has already made at least one pick
const userDraftedSportIds = useMemo(() => {
if (!userTeam) return new Set<string>();
return new Set(
picks
.filter((p) => p.team.id === userTeam.id)
.map((p) => p.sport.id)
);
}, [picks, userTeam]);
// Sport names where the current user's team has already made at least one pick
const userDraftedSportNames = useMemo(() => {
if (!userTeam) return new Set<string>();
return new Set(
picks
.filter((p) => p.team.id === userTeam.id)
.map((p) => p.sport.name)
);
}, [picks, userTeam]);
// Get unique sports for filter dropdown
const uniqueSports = useMemo(
() =>
Array.from(
new Set(availableParticipants.map((p) => p.sport.name))
).sort(),
[availableParticipants]
);
// Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = useMemo(() => {
const sportFilterSet = new Set(sportFilters);
return availableParticipants.filter((participant) => {
// Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
return false;
}
// Eligibility filter - hide ineligible participants by default (if user has a team)
if (hideIneligible && eligibility) {
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
if (!isEligible) {
return false;
}
}
// Hide completed sports filter - hide participants from sports already drafted by user's team
if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) {
return false;
}
// Search filter
if (
searchQuery &&
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Sport filter
if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) {
return false;
}
// Watched only filter
if (showOnlyWatched && !watchedParticipantIds.has(participant.id)) {
return false;
}
return true;
});
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
setHideCompletedSports(hide);
if (hide) {
setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s)));
}
}, [userDraftedSportNames, setHideCompletedSports, setSportFilters]);
const miniDraftGrid = useMemo(() => ({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap,
teamTimers,
autodraftStatus,
connectedTeams,
seasonStatus: season.status,
draftPaused: isPaused,
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isOvernightPause, overnightResumesAt, pausedTeamIds, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
const availableParticipantsSectionProps = useMemo(() => ({
participants: filteredParticipants,
participantRanks,
miniDraftGrid,
searchQuery,
sportFilters,
hideDrafted,
hideIneligible,
uniqueSports,
draftedParticipantIds,
queue,
eligibility,
canPick,
hasTeam: !!userTeam,
onSearchChange: setSearchQuery,
onSportFiltersChange: setSportFilters,
onHideDraftedChange: setHideDrafted,
onHideIneligibleChange: setHideIneligible,
hideCompletedSports,
userDraftedSportNames,
onHideCompletedSportsChange: handleHideCompletedSportsChange,
onMakePick: handleMakePick,
onAddToQueue: handleAddToQueue,
onRemoveFromQueue: handleRemoveFromQueue,
projectedPicks,
watchedParticipantIds,
onToggleWatchlist: handleToggleWatchlist,
showOnlyWatched,
onShowOnlyWatchedChange: setShowOnlyWatched,
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched]);
const draftGridSectionProps = {
draftSlots,
draftGrid,
currentPick,
teamTimers,
autodraftStatus,
connectedTeams,
isCommissioner,
ownerMap,
seasonStatus: season.status,
draftPaused: isPaused,
overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds },
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
};
const summaryViewProps = {
draftSlots,
picks,
sports: seasonSportsData,
ownerMap,
totalRounds: season.draftRounds,
};
const rosterViewProps = {
draftSlots,
picks,
sports: seasonSportsData,
ownerMap,
totalRounds: season.draftRounds,
};
const handleAutodraftUpdate = useCallback(
(isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => {
setUserAutodraft({ isEnabled, mode, queueOnly });
},
[setUserAutodraft]
);
const queueSectionProps = userTeam
? {
queue,
availableParticipants,
canPick,
onRemoveFromQueue: handleRemoveFromQueue,
onReorder: handleReorderQueue,
onMakePick: handleMakePick,
}
: null;
const mobileTabs = userTeam
? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)]
: MOBILE_TABS_BASE;
const mobileColCount = mobileTabs.length;
return (
<div className="h-dvh bg-background flex flex-col overflow-hidden">
{/* Draft Completion Banner */}
{isDraftComplete && (
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0 flex items-center justify-center gap-2 flex-wrap">
<span>Draft Complete! The season is now active.</span>
<a
href={draftBoardUrl}
className="inline-flex items-center gap-1 bg-emerald-500/30 hover:bg-emerald-500/40 text-emerald-300 px-3 py-1 rounded-md text-sm transition-colors"
>
View Draft Board
</a>
{roomClosureCountdown !== null && roomClosureCountdown > 0 && (
<span className="text-sm text-emerald-500">
Room closes in {Math.floor(roomClosureCountdown / 60)}:{String(roomClosureCountdown % 60).padStart(2, "0")}
</span>
)}
</div>
)}
{/* Standalone Header - No Main Nav */}
<div className="border-b bg-card flex-shrink-0">
<div className="w-full px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
{userTeam && sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(false)}
className="lg:hidden"
aria-label="Open sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</Button>
)}
<Link to="/">
<img src={logomarkUrl} alt="Brackt" className="h-8" />
</Link>
<h1 className="text-lg md:text-xl font-bold">
Draft Room
</h1>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Pick Notifications</span>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
switchOnly
/>
</div>
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
{isCommissioner && (
<div className="hidden md:flex">
<CommissionerDraftControls
seasonStatus={season.status}
isDraftComplete={isDraftComplete}
isPaused={isPaused}
onStart={handleStartDraft}
onPause={handlePauseDraft}
onResume={handleResumeDraft}
/>
</div>
)}
<Button variant="outline" asChild className="hidden md:flex">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Mobile On Clock bar */}
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
isMyTurn
? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric"
: "bg-muted/30"
}`}>
<div>
<div className={`text-xs font-bold uppercase tracking-wider ${
isMyTurn
? isOvernightPause ? "text-blue-400" : "text-electric"
: "text-muted-foreground"
}`}>
{isMyTurn
? isOvernightPause ? "Overnight Pause" : "Your Turn!"
: "On the Clock"}
</div>
<div className="text-sm font-bold">
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
</div>
{currentDraftSlotOwnerName && (
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
)}
</div>
{isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span>
) : isOvernightPause ? (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
🌙 {formatClockTime(currentClockTime)}
</span>
) : (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)}
</span>
)}
</div>
)}
{/* Desktop Tab Navigation Row */}
<div className={`hidden md:grid md:grid-cols-3 flex-shrink-0 items-center px-4 py-2 border-b transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete
? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: "bg-card"
}`}>
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
}
>
<TabsList>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex justify-center">
{isMyTurn && season.status === "draft" && !isDraftComplete && (
isOvernightPause ? (
<span className="text-sm text-blue-200 text-center leading-snug">
🌙 Overnight pause active timer frozen
{overnightResumesAt && (
<> until {overnightResumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>
)}. You can still pick.
</span>
) : (
<span className="text-base font-bold tracking-wide text-electric">
It&apos;s your turn!
</span>
)
)}
</div>
<div className="flex justify-end">
{userTeam && (
<AutodraftBadgeWithPopover
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
showOvernightNote={season.overnightPauseMode !== "none"}
/>
)}
</div>
</div>
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
<div className="flex-1 overflow-hidden">
{isMobile ? (
<div className="flex h-full overflow-hidden flex-col">
{mobileTab === "available" && (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0">
<RecentPicksFeed picks={picks} />
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AvailableParticipantsSection {...availableParticipantsSectionProps} miniDraftGrid={undefined} />
</div>
</div>
)}
{mobileTab === "queue" && (
<div className="h-full overflow-y-auto">
{queueSectionProps ? (
<QueueSection {...queueSectionProps} />
) : (
<p className="text-muted-foreground text-sm text-center py-8 px-4">
Join a team to manage your queue
</p>
)}
</div>
)}
{mobileTab === "board" && (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
<button
onClick={() => setBoardSubTab("board")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Board
</button>
<button
onClick={() => setBoardSubTab("pick-list")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "pick-list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Pick List
</button>
</div>
<div className="flex-1 overflow-hidden">
{boardSubTab !== "pick-list" ? (
<DraftGridSection {...draftGridSectionProps} />
) : (
<div className="h-full overflow-y-auto p-4">
<SidebarRecentPicks picks={picks} />
</div>
)}
</div>
</div>
)}
{mobileTab === "teams" && (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
<button
onClick={() => setTeamsSubTab("rosters")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
teamsSubTab === "rosters"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Roster
</button>
<button
onClick={() => setTeamsSubTab("summary")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
teamsSubTab === "summary"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Summary
</button>
</div>
<div className="flex-1 overflow-hidden">
{teamsSubTab === "rosters" ? (
<TeamRosterView {...rosterViewProps} />
) : (
<DraftSummaryView {...summaryViewProps} />
)}
</div>
</div>
)}
{mobileTab === "controls" && (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{isCommissioner && season.status === "pre_draft" && (
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
Start Draft
</Button>
)}
{userTeam && (
<AutodraftSettings
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
showOvernightNote={season.overnightPauseMode !== "none"}
/>
)}
</div>
<div className="flex-shrink-0 p-4 border-t flex flex-col gap-3">
{isCommissioner && season.status === "draft" && !isDraftComplete && (
isPaused ? (
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
Resume Draft
</Button>
) : (
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
Pause Draft
</Button>
)
)}
<Button variant="outline" asChild className="w-full min-h-[48px]">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex h-full overflow-hidden">
{userTeam && queueSectionProps && (
<DraftSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={setSidebarCollapsed}
queueSection={<QueueSection {...queueSectionProps} />}
recentPicksSection={<SidebarRecentPicks picks={picks} />}
/>
)}
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
<DraftGridSection {...draftGridSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
<TeamRosterView {...rosterViewProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
<DraftSummaryView {...summaryViewProps} />
</div>
</div>
</div>
)}
</div>
{/* Mobile Bottom Nav */}
<nav className={`md:hidden flex-shrink-0 border-t bg-card grid`} style={{ gridTemplateColumns: `repeat(${mobileColCount}, 1fr)` }}>
{mobileTabs.map((tab) => {
const showTurnIndicator =
(tab.id === "available" || tab.id === "queue") &&
isMyTurn &&
season.status === "draft" &&
!isDraftComplete;
return (
<button
key={tab.id}
onClick={() => setMobileTab(tab.id as typeof mobileTab)}
className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${
mobileTab === tab.id ? "text-electric" : "text-muted-foreground"
}`}
>
<div className="relative">
<tab.Icon className="h-5 w-5" />
{showTurnIndicator && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" />
)}
</div>
<span className="text-[10px] font-medium">{tab.label}</span>
</button>
);
})}
</nav>
<ParticipantSelectionDialog
open={forcePickDialogOpen && !!selectedPickSlot}
onOpenChange={(open) => {
setForcePickDialogOpen(open);
if (!open) setSelectedPickSlot(null);
}}
title="Force Manual Pick"
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
participants={availableParticipants}
draftedParticipantIds={draftedParticipantIds}
eligibility={forcePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleForceManualPick}
/>
<ParticipantSelectionDialog
open={replacePickDialogOpen && !!replacePickSlot}
onOpenChange={(open) => {
setReplacePickDialogOpen(open);
if (!open) setReplacePickSlot(null);
}}
title="Replace Pick"
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
participants={availableParticipants}
draftedParticipantIds={draftedParticipantIds}
allowParticipantId={replacePickSlot?.oldParticipantId}
currentParticipantId={replacePickSlot?.oldParticipantId}
eligibility={replacePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleReplacePick}
/>
{/* Rollback Confirmation Dialog */}
<RollbackConfirmDialog
open={rollbackConfirmOpen}
onOpenChange={(open) => { setRollbackConfirmOpen(open); if (!open) setRollbackPickNumber(null); }}
pickNumber={rollbackPickNumber}
picksCount={rollbackPickNumber !== null ? picks.filter((p) => p.pickNumber >= rollbackPickNumber).length : 0}
isRollingBack={isRollingBack}
onConfirm={handleConfirmRollback}
/>
{/* Time Bank Adjustment Dialog */}
<TimeBankAdjustmentDialog
open={timeBankDialogOpen}
onOpenChange={(open) => { setTimeBankDialogOpen(open); if (!open) setTimeBankTeamId(null); }}
teamName={draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}
amount={timeBankAmount}
onAmountChange={setTimeBankAmount}
unit={timeBankUnit}
onUnitChange={setTimeBankUnit}
direction={timeBankDirection}
onDirectionChange={setTimeBankDirection}
isAdjusting={isAdjustingTimeBank}
onConfirm={handleConfirmAdjustTimeBank}
/>
{/* Commissioner Autodraft Override Dialog */}
<CommissionerAutodraftDialog
open={commissionerAutodraftDialogOpen}
onOpenChange={(open) => { setCommissionerAutodraftDialogOpen(open); if (!open) setCommissionerAutodraftTeamId(null); }}
teamId={commissionerAutodraftTeamId}
teamName={draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name}
seasonId={season.id}
isEnabled={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.isEnabled ?? false}
mode={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.mode ?? "next_pick"}
queueOnly={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.queueOnly ?? false}
onUpdate={handleCommissionerAutodraftUpdate}
/>
{/* Connection Overlay - blocks interaction until socket connects */}
<ConnectionOverlay
isConnected={isConnected}
isReconnecting={isReconnecting}
connectionError={connectionError}
isSyncing={isSyncing}
/>
{/* Auth Recovery Overlay - blocks interaction when session expires */}
<AuthRecoveryOverlay isAuthDegraded={authDegraded} />
</div>
);
}