From 8ec2d728b7b54e93ff571a4857c0ba6c4a9bc965 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 14 May 2026 23:07:47 -0700 Subject: [PATCH] Improve draft info panel formatting --- app/components/league/DraftInfoCard.tsx | 73 ++++++++++++++----- .../league/__tests__/DraftInfoCard.test.tsx | 71 ++++++++++++++++++ app/routes/leagues/$leagueId.server.ts | 5 +- app/routes/leagues/$leagueId.tsx | 3 + 4 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 app/components/league/__tests__/DraftInfoCard.test.tsx diff --git a/app/components/league/DraftInfoCard.tsx b/app/components/league/DraftInfoCard.tsx index 16905bf..186f740 100644 --- a/app/components/league/DraftInfoCard.tsx +++ b/app/components/league/DraftInfoCard.tsx @@ -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 ( -
+
{label} - {value} + {value} {sub && {sub}}
); @@ -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({ {/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */} {hasOvernight && ( -
+
{formatDraftDateTime(draftDate, draftTimezone)} : "TBD"} + sub={draftDate ? {formatRelativeDate(draftDate)} : undefined} /> {!hasOvernight && ( {formatDraftDateTime(draftDate, draftTimezone)} : "TBD"} + sub={draftDate ? {formatRelativeDate(draftDate)} : undefined} /> )} @@ -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"; } diff --git a/app/components/league/__tests__/DraftInfoCard.test.tsx b/app/components/league/__tests__/DraftInfoCard.test.tsx new file mode 100644 index 0000000..1b555de --- /dev/null +++ b/app/components/league/__tests__/DraftInfoCard.test.tsx @@ -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({ui}); +} + +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(); + + 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(); + + expect(screen.getByText("8 hour time bank")).toBeInTheDocument(); + expect(screen.getByText("+30 minute increment")).toBeInTheDocument(); + }); + + it("describes standard timers in human terms", () => { + renderWithRouter( + + ); + + expect(screen.getByText("30 seconds")).toBeInTheDocument(); + expect(screen.getByText("per pick")).toBeInTheDocument(); + }); + + it("formats the draft date in the provided timezone", () => { + renderWithRouter( + + ); + + expect(screen.getByText("May 21, 2026 12:00AM ET")).toBeInTheDocument(); + }); +}); diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index bd58b79..a2cfb61 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -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, diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 8609672..86cd700 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -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}`} />