brackt/app/routes/leagues/$leagueId.tsx
Chris Parsons 789408e428
Fix chess clock increment not applied to all pick types (#67 regression) (#220)
Fixes #220

When standard draft mode was added, the chess clock increment was
accidentally restricted to owner-only picks. Commissioner, admin, and
auto-picks stopped earning the increment, causing teams that timed out
to freeze at 0s and instant-autopick every subsequent round.

Fix:
- make-pick: all pick types earn the increment in chess clock mode
- force-manual-pick: same; deduplicate standard/chess-clock branches
  into a single update with mode-selected SQL; remove now-unused
  timerSnapshot query
- draft-utils executeAutoPick: restore increment for chess clock
  auto-picks; add missing seed insert when no timer row exists
- server/timer.ts: fix fallback initialization to use draftIncrementTime
  in standard mode (was always using draftInitialTime)
- leagues/$leagueId.tsx: show Draft Timer Mode in League Info panel

Tests:
- Update draft.make-pick.timer-mode to cover owner/commissioner/admin
  in both modes (commissioner section previously asserted frozen bank)
- Add draft.force-manual-pick.timer-mode for commissioner/admin force
  picks in both modes
- Add executeAutoPick.timer for timer-triggered auto-picks in both modes
- Update draft.force-manual-pick to reflect new chess clock behavior
- Replace fragile toHaveBeenCalledTimes(2) assertions with
  toHaveBeenCalledWith checks on the timer set call

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:00:32 -07:00

533 lines
21 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { format } from "date-fns";
import type { Route } from "./+types/$leagueId";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { getDisplayRank } from "~/lib/standings-display";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
}
export { loader } from "./$leagueId.server";
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const {
league,
season,
teams,
commissioners,
currentUserId,
isUserCommissioner,
ownerMap,
commissionerMap,
availableTeamCount,
sportsCount,
teamsWithOwners,
isDraftOrderSet,
draftSlots,
sportsSeasons,
standings,
origin,
upcomingCalendarEvents,
} = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId);
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
const processedParamsRef = useRef<string | null>(null);
useEffect(() => {
const paramString = searchParams.toString();
if (!paramString || paramString === processedParamsRef.current) return;
if (searchParams.get("updated") === "true") {
processedParamsRef.current = paramString;
toast.success("Team updated successfully");
setSearchParams({});
} else if (searchParams.get("joined") === "true") {
processedParamsRef.current = paramString;
toast.success("Welcome to the league! You've been assigned a team.");
setSearchParams({});
} else if (searchParams.get("left") === "true") {
processedParamsRef.current = paramString;
toast.success("You have left the league. Your team is now available.");
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
// Pre-compute standings map to avoid O(n²) lookups in render
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
// Pre-compute sorted standings list
const sortedTeams = [...teams].toSorted((a, b) => {
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
return rankA - rankB;
});
// Pre-compute sorted sports seasons: active → upcoming → completed,
// season_standings last within active, then alphabetical by sport name
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const;
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
if (a.status === "active") {
const aSeasonLong = a.scoringPattern === "season_standings" ? 1 : 0;
const bSeasonLong = b.scoringPattern === "season_standings" ? 1 : 0;
if (aSeasonLong !== bSeasonLong) return aSeasonLong - bSeasonLong;
}
return a.sport.name.localeCompare(b.sport.name);
});
const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft";
const isActiveOrCompleted = season?.status === "active" || season?.status === "completed";
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">{league.name}</h1>
<div className="flex gap-2">
{myTeam && (
<Button variant="outline" asChild>
<Link to={`/teams/${myTeam.id}/settings`}>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
</Button>
)}
</div>
</div>
{season && (
<p className="text-muted-foreground">
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
{season?.status === "draft" && (
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-yellow-700 dark:text-yellow-400">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-500 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-yellow-500" />
</span>
<span className="font-medium">Draft is live!</span>
<span className="hidden text-sm sm:inline">
Your league draft is currently in progress.
</span>
</div>
<Button asChild size="sm" variant="outline" className="shrink-0 border-yellow-500/60 text-yellow-700 hover:bg-yellow-500/20 dark:text-yellow-400">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
)}
<div className="grid gap-6 md:grid-cols-3">
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Standings</CardTitle>
<CardDescription>
{season.status === "completed" ? "Final standings" : "Current season standings"}
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
Full Standings
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
{sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
{getDisplayRank(standing, standings.length)}
</div>
<div className="flex-1 min-w-0">
<TeamNameDisplay
teamName={team.name}
ownerName={ownerName}
href={`/leagues/${league.id}/standings/${season.id}/teams/${team.id}`}
/>
</div>
<div className="text-sm font-medium tabular-nums">
{standing ? standing.totalPoints : 0} pts
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Sports Seasons Section */}
{season && sortedSportsSeasons.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{sortedSportsSeasons.map((sportSeason) => (
<SportSeasonCard
key={sportSeason.id}
leagueId={league.id}
sportSeasonId={sportSeason.id}
sportName={sportSeason.sport.name}
seasonName={sportSeason.name}
status={sportSeason.status}
scoringPattern={sportSeason.scoringPattern}
upcomingParticipantEvents={sportSeason.upcomingParticipantEvents}
/>
))}
</div>
</CardContent>
</Card>
)}
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
<Card>
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league (
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "}
available)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${origin}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
)}
{/* Draft Order card - shown when order is set, pre_draft/draft */}
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{season.status === "pre_draft"
? "Upcoming draft order"
: "Current draft order"}
</CardDescription>
</div>
<Button asChild size="sm">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{draftSlots.map((slot, index) => {
const ownerName = slot.team.ownerId
? ownerMap[slot.team.ownerId]
: null;
return (
<div
key={slot.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{slot.team.name}
</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">
{ownerName}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
{season && isDraftOrPreDraft && (
<Card>
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId
? ownerMap[team.ownerId]
: null;
return (
<Card
key={team.id}
className={isOwned ? "border-primary" : ""}
>
<CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription>
{team.ownerId ? (
isOwned ? (
<span className="text-primary font-medium">
Your Team
</span>
) : (
<span>{ownerName || "Unknown Owner"}</span>
)
) : (
<span className="text-muted-foreground">
Available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
{upcomingCalendarEvents.length > 0 && (
<UpcomingCalendarPanel events={upcomingCalendarEvents} showLeague={false} />
)}
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{new Date(league.createdAt).toLocaleDateString()}
</p>
</div>
{season && (
<>
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Teams Filled
</p>
<p className="font-medium">
{teamsWithOwners} of {teams.length}
</p>
</div>
)}
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Draft Order Set
</p>
<div className="flex items-center gap-2">
<p className="font-medium">
{isDraftOrderSet ? "Yes" : "No"}
</p>
{!isDraftOrderSet && isUserCommissioner && (
<Button size="sm" variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Order
</Link>
</Button>
)}
</div>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">
Draft Rounds
</p>
<p className="font-medium">{season.draftRounds}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Draft Timer Mode
</p>
<p className="font-medium">
{season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Sports Selected
</p>
<p className="font-medium">{sportsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Draft Board</p>
<Link
to={`/leagues/${league.id}/draft-board/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Draft Board
</Link>
</div>
{isActiveOrCompleted && (
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
to={`/leagues/${league.id}/standings/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Standings
</Link>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)}
</p>
<p className="text-xs text-muted-foreground">
Picks not tied to a specific sport
</p>
</div>
{season.draftDateTime && (
<div>
<p className="text-sm text-muted-foreground">
Draft Date
</p>
<p className="font-medium">
{format(new Date(season.draftDateTime), "PPP 'at' p")}
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Commissioners</CardTitle>
<CardDescription>
{commissioners.length} commissioner
{commissioners.length !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{commissioners.map((commissioner) => {
const commissionerName = commissionerMap[commissioner.userId];
const hasTeam = teams.some(
(t) => t.ownerId === commissioner.userId
);
return (
<div
key={commissioner.id}
className="py-2 border-b last:border-0"
>
<p className="font-medium">
{commissionerName || "Unknown User"}
{commissioner.userId === currentUserId && (
<span className="ml-2 text-xs text-primary">
(You)
</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground">
No team
</p>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}