Improve draft info panel formatting (#428)

This commit is contained in:
Chris Parsons 2026-05-14 23:42:04 -07:00 committed by GitHub
parent 9e9a37d4d2
commit d4a9fc8aaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 134 additions and 18 deletions

View file

@ -1,7 +1,6 @@
import { format } from "date-fns";
import { format, formatDistanceToNow } 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";
@ -24,6 +23,8 @@ export interface DraftInfoCardProps {
draftRoomHref: 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;
@ -34,11 +35,11 @@ export interface DraftInfoCardProps {
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">
<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="text-xl font-bold leading-none">{value}</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>
);
@ -54,6 +55,7 @@ function DraftInfoCardVariant({
sportsCount,
isDraftOrderSet,
draftDateTime,
draftTimezone,
draftRoomHref,
userDraftPosition,
overnightPauseMode,
@ -64,9 +66,12 @@ function DraftInfoCardVariant({
const flexSpots = Math.max(0, draftRounds - sportsCount);
const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer";
const timerValue = formatClockTime(draftInitialTime);
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"
? `+${formatClockTime(draftIncrementTime)} per pick`
? `+${formatHumanTime(draftIncrementTime, { attributive: true })} increment`
: "per pick";
const roundsSub = [
@ -118,11 +123,11 @@ function DraftInfoCardVariant({
<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">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<InfoPanel
label="Draft Date"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
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"
@ -143,9 +148,9 @@ function DraftInfoCardVariant({
<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}
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} />
@ -173,15 +178,49 @@ function formatHHMM(hhmm: string): string {
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): string {
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[] = [];
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" : ""}`);
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";
}

View file

@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router";
import { DraftInfoCard, type DraftInfoCardProps } from "../DraftInfoCard";
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>);
}
const baseProps: DraftInfoCardProps = {
draftRounds: 8,
draftTimerMode: "chess_clock",
draftInitialTime: 28800,
draftIncrementTime: 1800,
sportsCount: 6,
isDraftOrderSet: true,
isDraftOrPreDraft: true,
draftDateTime: new Date("2026-05-21T04:00:00.000Z"),
draftTimezone: "America/Los_Angeles",
draftBoardHref: "/leagues/1/draft-board/1",
draftRoomHref: "/leagues/1/draft/1",
};
describe("DraftInfoCard", () => {
afterEach(() => {
vi.useRealTimers();
});
it("shows the full draft date and relative timing before the draft", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-18T04:00:00.000Z"));
renderWithRouter(<DraftInfoCard {...baseProps} />);
expect(screen.getByText("Draft Date & Time")).toBeInTheDocument();
expect(screen.getByText("May 20, 2026 9:00PM PT")).toBeInTheDocument();
expect(screen.getByText("In 3 days")).toBeInTheDocument();
});
it("describes chess clock bank and increment in human terms", () => {
renderWithRouter(<DraftInfoCard {...baseProps} />);
expect(screen.getByText("8 hour time bank")).toBeInTheDocument();
expect(screen.getByText("+30 minute increment")).toBeInTheDocument();
});
it("describes standard timers in human terms", () => {
renderWithRouter(
<DraftInfoCard
{...baseProps}
draftTimerMode="standard"
draftInitialTime={30}
draftIncrementTime={30}
/>
);
expect(screen.getByText("30 seconds")).toBeInTheDocument();
expect(screen.getByText("per pick")).toBeInTheDocument();
});
it("formats the draft date in the provided timezone", () => {
renderWithRouter(
<DraftInfoCard
{...baseProps}
draftTimezone="America/New_York"
/>
);
expect(screen.getByText("May 21, 2026 12:00AM ET")).toBeInTheDocument();
});
});

View file

@ -211,12 +211,14 @@ export async function loader(args: Route.LoaderArgs) {
? await getAuditLogForSeason(season.id, { limit: 5 })
: { entries: [], total: 0, hasMore: false };
const currentUser = userId ? await findUserById(userId) : null;
// Show timezone banner in per_user overnight pause mode when user hasn't set their timezone
const showTimezoneBanner =
userId &&
season?.status === "pre_draft" &&
season?.overnightPauseMode === "per_user"
? !(await findUserById(userId))?.timezone
? !currentUser?.timezone
: false;
return {
@ -226,6 +228,7 @@ export async function loader(args: Route.LoaderArgs) {
teams,
commissioners,
currentUserId: userId,
currentUserTimezone: currentUser?.timezone ?? null,
isUserCommissioner,
ownerMap: Object.fromEntries(ownerMap),
ownerAvatarDataByUserId,

View file

@ -29,6 +29,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
teams,
commissioners,
currentUserId,
currentUserTimezone,
isUserCommissioner,
ownerMap,
ownerAvatarDataByUserId,
@ -206,6 +207,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={isDraftOrPreDraft}
draftDateTime={season.draftDateTime}
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
userDraftPosition={userDraftPosition}
@ -311,6 +313,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
isDraftOrderSet={isDraftOrderSet}
isDraftOrPreDraft={false}
draftDateTime={season.draftDateTime}
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
/>