Improve draft info panel formatting (#428)
This commit is contained in:
parent
9e9a37d4d2
commit
d4a9fc8aaf
4 changed files with 134 additions and 18 deletions
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
71
app/components/league/__tests__/DraftInfoCard.test.tsx
Normal file
71
app/components/league/__tests__/DraftInfoCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue