brackt/app/components/league/DraftInfoCard.tsx
Chris Parsons dbc23f14ce
Add overnight pause for draft timers (#335)
* Add overnight pause feature for draft timers

Protects players from having their pick timer expire while asleep.
Admins configure a nightly window (league-wide or per-user timezone);
the timer freezes during that window while autodraft can still fire.

Fixes #66

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS error: guard getUserDisplayName against undefined user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:31:52 -07:00

261 lines
10 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 } from "date-fns";
import { ChevronRight, ChessPawn, Hourglass, LayoutGrid, Moon } from "lucide-react";
import { Link } from "react-router";
import { formatClockTime } from "~/lib/draft-timer";
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;
/** 1-based draft position for the current user; omit or undefined to hide the panel */
userDraftPosition?: number;
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 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="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,
draftRoomHref,
userDraftPosition,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
}: DraftInfoCardProps) {
const flexSpots = Math.max(0, draftRounds - sportsCount);
const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer";
const timerValue = formatClockTime(draftInitialTime);
const timerSub = draftTimerMode === "chess_clock"
? `+${formatClockTime(draftIncrementTime)} per pick`
: "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>
)}
</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-2 gap-2">
<InfoPanel
label="Draft Date"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : 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"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : 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}`;
}
/** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */
function formatHumanTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts: string[] = [];
if (h > 0) parts.push(`${h} hour${h !== 1 ? "s" : ""}`);
if (m > 0) parts.push(`${m} minute${m !== 1 ? "s" : ""}`);
if (s > 0) parts.push(`${s} second${s !== 1 ? "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} />;
}