+
{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}`}
/>