brackt/app/components/league/DraftInfoCard.tsx
Chris Parsons faecfd8ecc
Rename queue page to Set Pre-Draft Queue, remove VORP, fix re-add race condition (#436)
* Rename queue page to Set Pre-Draft Queue, remove VORP, fix re-add race condition

- Rename page title, h1, button, error message, and tests from
  "Pre-Draft Rankings" to "Set Pre-Draft Queue"
- Remove VORP values and "Sorted by VORP" labels from the all-players list
- Fix bug where removing a player then immediately re-adding would fail
  with "already in queue": track in-flight removes in a ref and guard
  handleAdd against firing while a remove is still in-flight

https://claude.ai/code/session_01TGAV9A2c72PNkL1ffY7Xeq

* Address code review findings on Set Pre-Draft Queue page

- Rename component function from PreDraftRankings to SetPreDraftQueue
- Guard handleRemove against temp IDs — items added optimistically but
  not yet written to the DB have no server-side record to delete; skip
  the API call and just drop them from local state
- Surface a toast when handleAdd is blocked by a pending remove instead
  of silently no-oping
- Group both refs before their shared useEffect for readability
- Drop justify-between from the All Players desktop heading (sole child)
- Update test describe blocks to "Set Pre-Draft Queue Access"

https://claude.ai/code/session_01TGAV9A2c72PNkL1ffY7Xeq

---------

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

307 lines
12 KiB
TypeScript
Raw Permalink 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 Queue</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} />;
}