brackt/app/components/league/DraftInfoCard.tsx
Chris Parsons 973e56142f
Add pre-draft queue builder so users can rank players before draft order is set (#435)
* Add pre-draft queue builder so users can rank players before draft order is set

Creates a new /leagues/:leagueId/draft-queue/:seasonId page that lets team
owners browse participants by VORP and build their autopick queue during the
pre_draft phase. The queue uses the existing draftQueue table so it carries
seamlessly into the live draft room.

Adds a "Build Your Queue" button to DraftInfoCard that shows only when draft
order has not been set yet (replaced by "Enter Draft Room" once it is).

https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG

* Improve pre-draft rankings UX: mobile tabs, add/remove toggle, rename

On mobile, show tabs ("All Players" / "My Rankings") instead of a stacked
layout that buries the queue below a long participant list. On the All Players
list, the button now toggles between Add and Remove so users never need to
switch tabs just to drop someone. Desktop keeps the side-by-side panel layout.

Also renames the feature throughout from "queue builder" to "pre-draft rankings"
and the DraftInfoCard button to "Set Pre-Draft Rankings".

https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG

* Fix all code review issues in pre-draft rankings feature

- Replace useFetcher with direct fetch() for add/remove/reorder so rapid
  clicks no longer cancel in-flight requests
- Add toast.error() on all failure paths (matching live draft room pattern)
  and revert optimistic state when operations fail
- Add useRef to give handleReorder a stable reference without a localQueue
  closure dependency, preventing unnecessary QueueSection re-renders
- Convert allPlayersPanel and rankingsPanel to useMemo
- Remove leagueId from loader return; read from useParams() instead
- Extract queueBuilderHref to a variable in the league home component
- Add emptyMessage prop to QueueSection with a correct message for the
  pre-draft context ("Add players from All Players...")
- Add draft-queue-access.test.ts covering all loader access control paths:
  401/403 errors, status-based redirects, and redirect URL correctness

https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG

* Fix lint: use !== null check instead of != null

oxlint enforces eqeqeq; replace != null with !== null && !== undefined.

https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 17:26:36 -07:00

307 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { format, formatDistanceToNow } from "date-fns";
import { ChevronRight, ChessPawn, Hourglass, LayoutGrid, Moon } from "lucide-react";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DraftInfoCardProps {
draftRounds: number;
draftTimerMode: "standard" | "chess_clock";
/** Per-pick time for standard; initial bank for chess clock (seconds) */
draftInitialTime: number;
/** Bonus seconds added after each pick (chess clock only; ignored in standard) */
draftIncrementTime: number;
sportsCount: number;
isDraftOrderSet: boolean;
/** true when season status is pre_draft or draft */
isDraftOrPreDraft: boolean;
draftDateTime?: string | Date | null;
draftBoardHref: string;
draftRoomHref: string;
/** Link to the pre-draft queue builder; shown only when draft order is not yet set and user has a team */
queueBuilderHref?: string;
/** 1-based draft position for the current user; omit or undefined to hide the panel */
userDraftPosition?: number;
/** IANA timezone used to display the draft date and time. */
draftTimezone?: string | null;
overnightPauseMode?: "none" | "league" | "per_user";
overnightPauseStart?: string | null;
overnightPauseEnd?: string | null;
overnightPauseTimezone?: string | null;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function InfoPanel({ label, value, sub }: { label: string; value: React.ReactNode; sub?: React.ReactNode }) {
return (
<div className="flex min-w-0 flex-col rounded-lg bg-white/[0.04] px-3 py-3 gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground leading-none">
{label}
</span>
<span className="min-w-0 text-xl font-bold leading-none">{value}</span>
{sub && <span className="text-xs text-muted-foreground leading-none">{sub}</span>}
</div>
);
}
// ─── Card variant (pre_draft / draft) ─────────────────────────────────────────
function DraftInfoCardVariant({
draftRounds,
draftTimerMode,
draftInitialTime,
draftIncrementTime,
sportsCount,
isDraftOrderSet,
draftDateTime,
draftTimezone,
draftRoomHref,
queueBuilderHref,
userDraftPosition,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
}: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount);
const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer";
const draftDate = draftDateTime ? new Date(draftDateTime) : null;
const timerValue = draftTimerMode === "chess_clock"
? `${formatHumanTime(draftInitialTime, { attributive: true })} time bank`
: formatHumanTime(draftInitialTime);
const timerSub = draftTimerMode === "chess_clock"
? `+${formatHumanTime(draftIncrementTime, { attributive: true })} increment`
: "per pick";
const roundsSub = [
`${sportsCount} sport${sportsCount !== 1 ? "s" : ""}`,
flexSpots > 0 ? `${flexSpots} flex` : null,
].filter(Boolean).join(" + ");
const hasPosition = userDraftPosition !== null && userDraftPosition !== undefined;
const hasOvernight = !!(overnightPauseMode && overnightPauseMode !== "none" && overnightPauseStart && overnightPauseEnd);
// Mechanics row: Rounds + (Your Pick?) + Timer
// When overnight is shown, Draft Date moves to the top row so mechanics has 23 items.
// When no overnight, Draft Date stays in this row giving 34 items.
const mechanicsColsClass = hasOvernight
? (hasPosition ? "grid-cols-2 sm:grid-cols-3" : "grid-cols-2")
: (hasPosition ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3");
const timerPanel = (
<InfoPanel
label={timerLabel}
value={
<span className="flex items-center gap-1.5">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-4 w-4 shrink-0 text-muted-foreground" />
: <Hourglass className="h-4 w-4 shrink-0 text-muted-foreground" />}
{timerValue}
</span>
}
sub={timerSub}
/>
);
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">Draft Info</h2>
</div>
{isDraftOrderSet ? (
<Button asChild>
<Link to={draftRoomHref}>Enter Draft Room</Link>
</Button>
) : queueBuilderHref ? (
<Button asChild variant="outline">
<Link to={queueBuilderHref}>Set Pre-Draft Rankings</Link>
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-2">
{/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */}
{hasOvernight && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<InfoPanel
label="Draft Date & Time"
value={draftDate ? <span className="block leading-tight" suppressHydrationWarning>{formatDraftDateTime(draftDate, draftTimezone)}</span> : "TBD"}
sub={draftDate ? <span suppressHydrationWarning>{formatRelativeDate(draftDate)}</span> : undefined}
/>
<InfoPanel
label="Overnight Pause"
value={
<span className="flex items-center gap-1.5">
<Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
{formatHHMM(overnightPauseStart ?? "")} {formatHHMM(overnightPauseEnd ?? "")}
</span>
}
sub={overnightPauseMode === "league" && overnightPauseTimezone
? overnightPauseTimezone
: "per-user timezone"}
/>
</div>
)}
{/* Mechanics row: Rounds + Your Pick? + Timer (+ Draft Date when no overnight) */}
<div className={`grid gap-2 ${mechanicsColsClass}`}>
{!hasOvernight && (
<InfoPanel
label="Draft Date & Time"
value={draftDate ? <span className="block leading-tight" suppressHydrationWarning>{formatDraftDateTime(draftDate, draftTimezone)}</span> : "TBD"}
sub={draftDate ? <span suppressHydrationWarning>{formatRelativeDate(draftDate)}</span> : undefined}
/>
)}
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
{hasPosition && (
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
)}
{timerPanel}
{/* placeholder to keep grid balanced on mobile when only 2 items */}
{hasOvernight && !hasPosition && <div />}
</div>
</CardContent>
</Card>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Formats "23:00" → "11 PM", "07:00" → "7 AM". */
function formatHHMM(hhmm: string): string {
const [hStr, mStr] = hhmm.split(":");
const h = parseInt(hStr, 10);
const m = parseInt(mStr, 10);
const period = h < 12 ? "AM" : "PM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return m === 0 ? `${h12} ${period}` : `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
function formatDraftDateTime(date: Date, timezone: string | null | undefined): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: getDisplayTimezone(timezone),
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZoneName: "shortGeneric",
}).formatToParts(date);
const getPart = (type: Intl.DateTimeFormatPartTypes) => (
parts.find((part) => part.type === type)?.value ?? ""
);
return `${getPart("month")} ${getPart("day")}, ${getPart("year")} ${getPart("hour")}:${getPart("minute")}${getPart("dayPeriod")} ${getPart("timeZoneName")}`;
}
function getDisplayTimezone(timezone: string | null | undefined): string {
if (!timezone) return "UTC";
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
return timezone;
} catch {
return "UTC";
}
}
function formatRelativeDate(date: Date): string {
const value = formatDistanceToNow(date, { addSuffix: true });
return value.charAt(0).toUpperCase() + value.slice(1);
}
/** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */
function formatHumanTime(seconds: number, options: { attributive?: boolean } = {}): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
const plural = (value: number) => (!options.attributive && value !== 1 ? "s" : "");
if (h > 0) parts.push(`${h} hour${plural(h)}`);
if (m > 0) parts.push(`${m} minute${plural(m)}`);
if (s > 0) parts.push(`${s} second${plural(s)}`);
return parts.join(" ") || "0 seconds";
}
// ─── Bare variant (active / completed) ────────────────────────────────────────
function DraftInfoBareVariant({
draftRounds,
draftTimerMode,
draftInitialTime,
draftIncrementTime,
sportsCount,
draftDateTime,
draftBoardHref,
}: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount);
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Draft</span>
</div>
<Link
to={draftBoardHref}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View Draft Board
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex gap-3">
{/* Mode icon */}
<div className="flex size-16 shrink-0 items-center justify-center bg-black">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-6 w-6 text-white" />
: <Hourglass className="h-6 w-6 text-white" />}
</div>
<div className="min-w-0">
{draftDateTime && (
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
<span className="text-xs font-semibold uppercase tracking-wide text-electric">
{format(new Date(draftDateTime), "MMM d, yyyy")}
</span>
<span className="text-border">|</span>
<span className="text-xs text-muted-foreground">
{format(new Date(draftDateTime), "p")}
</span>
</div>
)}
<div className="text-sm font-bold uppercase tracking-wide leading-tight">
{draftRounds} rounds ({sportsCount} sport{sportsCount !== 1 ? "s" : ""}
{flexSpots > 0 && ` + ${flexSpots} flex`})
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{draftTimerMode === "standard"
? `Standard — ${formatHumanTime(draftInitialTime)}/pick`
: `Chess Clock — ${formatHumanTime(draftInitialTime)} + ${formatHumanTime(draftIncrementTime)} increment`}
</div>
</div>
</div>
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function DraftInfoCard(props: DraftInfoCardProps) {
if (props.isDraftOrPreDraft) {
return <DraftInfoCardVariant {...props} />;
}
return <DraftInfoBareVariant {...props} />;
}