feat: add draft rounds configuration to league settings

This commit is contained in:
Chris Parsons 2025-10-15 21:50:02 -07:00
parent 0e68bd2037
commit 501c5b183f
7 changed files with 1356 additions and 130 deletions

View file

@ -3,7 +3,7 @@ import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { isCommissioner } from "~/models/commissioner";
import { findCurrentSeasonWithSports } from "~/models/season";
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findAllSportsSeasons } from "~/models/sports-season";
@ -117,6 +117,31 @@ export async function action(args: Route.ActionArgs) {
return { error: "No active season found" };
}
if (intent === "update-draft-rounds") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft rounds after draft has started" };
}
const draftRounds = formData.get("draftRounds");
const draftRoundsNum = typeof draftRounds === "string" ? parseInt(draftRounds, 10) : null;
if (draftRoundsNum === null || isNaN(draftRoundsNum)) {
return { error: "Draft rounds must be a valid number" };
}
const sportsCount = season.seasonSports?.length || 0;
if (draftRoundsNum < sportsCount) {
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
}
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
return { error: "Draft rounds must be between 1 and 50" };
}
await updateSeason(season.id, { draftRounds: draftRoundsNum });
return { success: true, message: "Draft rounds updated successfully" };
}
if (intent === "update-sports") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" };
@ -281,6 +306,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id)
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
// Update draft order when loader data changes (after randomization)
useEffect(() => {
@ -293,8 +319,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = season && season.status === "pre_draft";
const canEditDraftRounds = season && season.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners);
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
@ -333,8 +366,10 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<p className="text-muted-foreground">{league.name}</p>
</div>
<div className="space-y-6">
{/* Update League Form */}
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="update" />
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
@ -342,68 +377,111 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
Update your league's basic information
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update" />
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
defaultValue={league.name}
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
defaultValue={league.name}
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select
name="teamCount"
defaultValue={teamCount.toString()}
disabled={!canEditTeamCount}
>
<SelectTrigger id="teamCount">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
<SelectItem
key={num}
value={num.toString()}
disabled={num < minTeamCount}
>
{num} Teams
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{canEditTeamCount
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
: "Team count cannot be changed after draft has started"}
</p>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
<Button type="submit" className="w-full">
Save Changes
</Button>
</Form>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select
name="teamCount"
defaultValue={teamCount.toString()}
disabled={!canEditTeamCount}
>
<SelectTrigger id="teamCount">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
<SelectItem
key={num}
value={num.toString()}
disabled={num < minTeamCount}
>
{num} Teams
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{canEditTeamCount
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
: "Team count cannot be changed after draft has started"}
</p>
</div>
</CardContent>
</Card>
{/* Sports Seasons Management */}
{/* Draft Rounds */}
<Card>
<CardHeader>
<CardTitle>Draft Rounds</CardTitle>
<CardDescription>
{canEditDraftRounds
? "Set the number of draft rounds for your league"
: "Draft rounds cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
disabled={!canEditDraftRounds}
required
>
<SelectTrigger id="draftRounds">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 50 }, (_, i) => i + 1)
.filter(num => num >= minRounds)
.map((num) => (
<SelectItem
key={num}
value={num.toString()}
>
{num} Round{num !== 1 ? 's' : ''}
{num === recommendedRounds && ' (recommended)'}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Minimum: {minRounds} (number of sports selected)
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
</p>
{sportsCount > 0 && draftRounds < sportsCount && (
<p className="text-sm font-medium text-destructive">
You need at least {sportsCount} rounds for the {sportsCount} sports selected
</p>
)}
{sportsCount > 0 && draftRounds >= sportsCount && (
<p className="text-sm font-medium text-primary">
Flex Spots: {flexSpots}
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Sports Seasons */}
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
@ -413,74 +491,72 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
: "Sports cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update-sports" />
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Sports Seasons ({selectedSports.size} selected)
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((season) => (
<div key={season.id} className="flex items-center space-x-2">
<Checkbox
id={season.id}
name="sportsSeasons"
value={season.id}
checked={selectedSports.has(season.id)}
onCheckedChange={() => handleSportToggle(season.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={season.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
<Badge variant="outline" className="ml-2 text-xs">
{season.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
<div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Sports Seasons ({selectedSports.size} selected)
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((season) => (
<div key={season.id} className="flex items-center space-x-2">
<Checkbox
id={season.id}
name="sportsSeasons"
value={season.id}
checked={selectedSports.has(season.id)}
onCheckedChange={() => handleSportToggle(season.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={season.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
<Badge variant="outline" className="ml-2 text-xs">
{season.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
</div>
{canEditSports && (
<Button type="submit" className="w-full">
Save Sports Selection
</Button>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
Sports updated successfully!
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
</Form>
</div>
</CardContent>
</Card>
{/* Save Button */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
Settings updated successfully!
</div>
)}
<Button type="submit" size="lg" className="w-full">
Save All Settings
</Button>
</Form>
<div className="space-y-6">
{/* Draft Order Management */}
<Card>
<CardHeader>

View file

@ -12,6 +12,7 @@ import {
findUserByClerkId,
findDraftSlotsBySeasonId,
} from "~/models";
import { findSeasonWithSportsSeasons } from "~/models/season";
import type { Route } from "./+types/$leagueId";
import { Button } from "~/components/ui/button";
import {
@ -34,8 +35,9 @@ export async function loader(args: Route.LoaderArgs) {
throw new Response("League not found", { status: 404 });
}
// Fetch current season
// Fetch current season with sports
const season = await findCurrentSeason(leagueId);
const seasonWithSports = season ? await findSeasonWithSportsSeasons(season.id) : null;
// Fetch commissioners
const commissioners = await findCommissionersByLeagueId(leagueId);
@ -110,6 +112,9 @@ export async function loader(args: Route.LoaderArgs) {
// Count available teams
const availableTeamCount = teams.filter((t) => !t.ownerId).length;
// Count sports in season
const sportsCount = seasonWithSports?.seasonSports?.length || 0;
return {
league,
season,
@ -121,6 +126,7 @@ export async function loader(args: Route.LoaderArgs) {
commissionerMap: Object.fromEntries(commissionerMap),
availableTeamCount,
draftSlots,
sportsCount,
};
}
@ -136,6 +142,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
commissionerMap,
availableTeamCount,
draftSlots,
sportsCount,
} = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
@ -332,12 +339,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</p>
</div>
{season && (
<div>
<p className="text-sm text-muted-foreground">Season Status</p>
<p className="font-medium capitalize">
{season.status.replace("_", " ")}
</p>
</div>
<>
<div>
<p className="text-sm text-muted-foreground">Season Status</p>
<p className="font-medium capitalize">
{season.status.replace("_", " ")}
</p>
</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">Sports Selected</p>
<p className="font-medium">{sportsCount}</p>
</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>
</div>
</>
)}
</CardContent>
</Card>

View file

@ -66,6 +66,7 @@ export async function action(args: Route.ActionArgs) {
const name = formData.get("name");
const teamCount = formData.get("teamCount");
const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
// Validation
if (typeof name !== "string" || !name.trim()) {
@ -81,6 +82,11 @@ export async function action(args: Route.ActionArgs) {
return { error: "Number of teams must be between 6 and 16" };
}
const draftRoundsNum = typeof draftRounds === "string" ? parseInt(draftRounds, 10) : 20;
if (isNaN(draftRoundsNum) || draftRoundsNum < 1 || draftRoundsNum > 50) {
return { error: "Draft rounds must be between 1 and 50" };
}
try {
// Create league
const league = await createLeague({
@ -101,6 +107,7 @@ export async function action(args: Route.ActionArgs) {
year: currentYear,
status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
draftRounds: draftRoundsNum,
});
// Set this as the current season for the league
@ -108,6 +115,14 @@ export async function action(args: Route.ActionArgs) {
// Add selected sports to the season
const selectedSports = formData.getAll("sportsSeasons");
// Validate that draft rounds is at least the number of sports
if (selectedSports.length > draftRoundsNum) {
return {
error: `You need at least ${selectedSports.length} draft rounds for the ${selectedSports.length} sports selected. Currently set to ${draftRoundsNum} rounds.`
};
}
if (selectedSports.length > 0) {
await linkMultipleSportsToSeason(
selectedSports.map((id) => ({
@ -139,6 +154,7 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
const { templates, allSportsSeasons } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
const [draftRounds, setDraftRounds] = useState<number>(20);
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
@ -154,6 +170,13 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
setSelectedTemplate(templateId);
setSelectedSports(new Set(sportsSeasonIds));
};
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
return (
<div className="container max-w-2xl mx-auto py-8 px-4">
<Card>
@ -203,6 +226,49 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftRounds">Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
required
>
<SelectTrigger id="draftRounds">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 50 }, (_, i) => i + 1)
.filter(num => num >= minRounds)
.map((num) => (
<SelectItem
key={num}
value={num.toString()}
>
{num} Round{num !== 1 ? 's' : ''}
{num === recommendedRounds && ' (recommended)'}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Minimum: {minRounds} (number of sports selected)
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
</p>
{sportsCount > 0 && draftRounds < sportsCount && (
<p className="text-sm font-medium text-destructive">
You need at least {sportsCount} rounds for the {sportsCount} sports selected
</p>
)}
{sportsCount > 0 && draftRounds >= sportsCount && (
<p className="text-sm font-medium text-primary">
Flex Spots: {flexSpots}
</p>
)}
</div>
</div>
<div className="space-y-4">
<Label>Sports Selection</Label>
<p className="text-sm text-muted-foreground">

View file

@ -67,6 +67,7 @@ export const seasons = pgTable("seasons", {
status: seasonStatusEnum("status").notNull().default("pre_draft"),
templateId: uuid("template_id")
.references(() => seasonTemplates.id, { onDelete: "set null" }),
draftRounds: integer("draft_rounds").notNull().default(20),
flexSpots: integer("flex_spots").notNull().default(0),
inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1 @@
ALTER TABLE "seasons" ADD COLUMN "draft_rounds" integer DEFAULT 20 NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -106,6 +106,13 @@
"when": 1760588333462,
"tag": "0014_lowly_gateway",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1760589195551,
"tag": "0015_exotic_loners",
"breakpoints": true
}
]
}