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 { ChevronRight, ChessPawn, Hourglass, LayoutGrid, Moon } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { formatClockTime } from "~/lib/draft-timer";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
|
|
@ -24,6 +23,8 @@ export interface DraftInfoCardProps {
|
||||||
draftRoomHref: string;
|
draftRoomHref: string;
|
||||||
/** 1-based draft position for the current user; omit or undefined to hide the panel */
|
/** 1-based draft position for the current user; omit or undefined to hide the panel */
|
||||||
userDraftPosition?: number;
|
userDraftPosition?: number;
|
||||||
|
/** IANA timezone used to display the draft date and time. */
|
||||||
|
draftTimezone?: string | null;
|
||||||
overnightPauseMode?: "none" | "league" | "per_user";
|
overnightPauseMode?: "none" | "league" | "per_user";
|
||||||
overnightPauseStart?: string | null;
|
overnightPauseStart?: string | null;
|
||||||
overnightPauseEnd?: 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 }) {
|
function InfoPanel({ label, value, sub }: { label: string; value: React.ReactNode; sub?: React.ReactNode }) {
|
||||||
return (
|
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">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground leading-none">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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>}
|
{sub && <span className="text-xs text-muted-foreground leading-none">{sub}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -54,6 +55,7 @@ function DraftInfoCardVariant({
|
||||||
sportsCount,
|
sportsCount,
|
||||||
isDraftOrderSet,
|
isDraftOrderSet,
|
||||||
draftDateTime,
|
draftDateTime,
|
||||||
|
draftTimezone,
|
||||||
draftRoomHref,
|
draftRoomHref,
|
||||||
userDraftPosition,
|
userDraftPosition,
|
||||||
overnightPauseMode,
|
overnightPauseMode,
|
||||||
|
|
@ -64,9 +66,12 @@ function DraftInfoCardVariant({
|
||||||
const flexSpots = Math.max(0, draftRounds - sportsCount);
|
const flexSpots = Math.max(0, draftRounds - sportsCount);
|
||||||
|
|
||||||
const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer";
|
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"
|
const timerSub = draftTimerMode === "chess_clock"
|
||||||
? `+${formatClockTime(draftIncrementTime)} per pick`
|
? `+${formatHumanTime(draftIncrementTime, { attributive: true })} increment`
|
||||||
: "per pick";
|
: "per pick";
|
||||||
|
|
||||||
const roundsSub = [
|
const roundsSub = [
|
||||||
|
|
@ -118,11 +123,11 @@ function DraftInfoCardVariant({
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */}
|
{/* Scheduling row: Draft Date + Overnight Pause side-by-side when overnight is configured */}
|
||||||
{hasOvernight && (
|
{hasOvernight && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
label="Draft Date"
|
label="Draft Date & Time"
|
||||||
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
|
value={draftDate ? <span className="block leading-tight" suppressHydrationWarning>{formatDraftDateTime(draftDate, draftTimezone)}</span> : "TBD"}
|
||||||
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
|
sub={draftDate ? <span suppressHydrationWarning>{formatRelativeDate(draftDate)}</span> : undefined}
|
||||||
/>
|
/>
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
label="Overnight Pause"
|
label="Overnight Pause"
|
||||||
|
|
@ -143,9 +148,9 @@ function DraftInfoCardVariant({
|
||||||
<div className={`grid gap-2 ${mechanicsColsClass}`}>
|
<div className={`grid gap-2 ${mechanicsColsClass}`}>
|
||||||
{!hasOvernight && (
|
{!hasOvernight && (
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
label="Draft Date"
|
label="Draft Date & Time"
|
||||||
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
|
value={draftDate ? <span className="block leading-tight" suppressHydrationWarning>{formatDraftDateTime(draftDate, draftTimezone)}</span> : "TBD"}
|
||||||
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
|
sub={draftDate ? <span suppressHydrationWarning>{formatRelativeDate(draftDate)}</span> : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
|
<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}`;
|
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". */
|
/** 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 h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (h > 0) parts.push(`${h} hour${h !== 1 ? "s" : ""}`);
|
const plural = (value: number) => (!options.attributive && value !== 1 ? "s" : "");
|
||||||
if (m > 0) parts.push(`${m} minute${m !== 1 ? "s" : ""}`);
|
if (h > 0) parts.push(`${h} hour${plural(h)}`);
|
||||||
if (s > 0) parts.push(`${s} second${s !== 1 ? "s" : ""}`);
|
if (m > 0) parts.push(`${m} minute${plural(m)}`);
|
||||||
|
if (s > 0) parts.push(`${s} second${plural(s)}`);
|
||||||
return parts.join(" ") || "0 seconds";
|
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 })
|
? await getAuditLogForSeason(season.id, { limit: 5 })
|
||||||
: { entries: [], total: 0, hasMore: false };
|
: { 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
|
// Show timezone banner in per_user overnight pause mode when user hasn't set their timezone
|
||||||
const showTimezoneBanner =
|
const showTimezoneBanner =
|
||||||
userId &&
|
userId &&
|
||||||
season?.status === "pre_draft" &&
|
season?.status === "pre_draft" &&
|
||||||
season?.overnightPauseMode === "per_user"
|
season?.overnightPauseMode === "per_user"
|
||||||
? !(await findUserById(userId))?.timezone
|
? !currentUser?.timezone
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -226,6 +228,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
teams,
|
teams,
|
||||||
commissioners,
|
commissioners,
|
||||||
currentUserId: userId,
|
currentUserId: userId,
|
||||||
|
currentUserTimezone: currentUser?.timezone ?? null,
|
||||||
isUserCommissioner,
|
isUserCommissioner,
|
||||||
ownerMap: Object.fromEntries(ownerMap),
|
ownerMap: Object.fromEntries(ownerMap),
|
||||||
ownerAvatarDataByUserId,
|
ownerAvatarDataByUserId,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
teams,
|
teams,
|
||||||
commissioners,
|
commissioners,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
currentUserTimezone,
|
||||||
isUserCommissioner,
|
isUserCommissioner,
|
||||||
ownerMap,
|
ownerMap,
|
||||||
ownerAvatarDataByUserId,
|
ownerAvatarDataByUserId,
|
||||||
|
|
@ -206,6 +207,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
isDraftOrderSet={isDraftOrderSet}
|
isDraftOrderSet={isDraftOrderSet}
|
||||||
isDraftOrPreDraft={isDraftOrPreDraft}
|
isDraftOrPreDraft={isDraftOrPreDraft}
|
||||||
draftDateTime={season.draftDateTime}
|
draftDateTime={season.draftDateTime}
|
||||||
|
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
|
||||||
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
||||||
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
||||||
userDraftPosition={userDraftPosition}
|
userDraftPosition={userDraftPosition}
|
||||||
|
|
@ -311,6 +313,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
isDraftOrderSet={isDraftOrderSet}
|
isDraftOrderSet={isDraftOrderSet}
|
||||||
isDraftOrPreDraft={false}
|
isDraftOrPreDraft={false}
|
||||||
draftDateTime={season.draftDateTime}
|
draftDateTime={season.draftDateTime}
|
||||||
|
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
|
||||||
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
||||||
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue