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 { getAuth } from "@clerk/react-router/server";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league"; import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { findCurrentSeasonWithSports } from "~/models/season"; import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team"; import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findAllSportsSeasons } from "~/models/sports-season"; import { findAllSportsSeasons } from "~/models/sports-season";
@ -117,6 +117,31 @@ export async function action(args: Route.ActionArgs) {
return { error: "No active season found" }; 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 (intent === "update-sports") {
if (season.status !== "pre_draft") { if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" }; 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) ? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id) : teams.map((team: any) => team.id)
); );
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
// Update draft order when loader data changes (after randomization) // Update draft order when loader data changes (after randomization)
useEffect(() => { useEffect(() => {
@ -293,8 +319,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const canEditSports = season && season.status === "pre_draft"; const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft"; const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = 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); 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 handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports); const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) { if (newSelected.has(sportId)) {
@ -333,8 +366,10 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<p className="text-muted-foreground">{league.name}</p> <p className="text-muted-foreground">{league.name}</p>
</div> </div>
<div className="space-y-6"> <Form method="post" className="space-y-6">
{/* Update League Form */} <input type="hidden" name="intent" value="update" />
{/* General Settings */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>General Settings</CardTitle> <CardTitle>General Settings</CardTitle>
@ -342,10 +377,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
Update your league's basic information Update your league's basic information
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update" />
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">League Name</Label> <Label htmlFor="name">League Name</Label>
<Input <Input
@ -389,21 +421,67 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
: "Team count cannot be changed after draft has started"} : "Team count cannot be changed after draft has started"}
</p> </p>
</div> </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>
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Sports Seasons</CardTitle> <CardTitle>Sports Seasons</CardTitle>
@ -413,9 +491,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
: "Sports cannot be modified after the draft has started"} : "Sports cannot be modified after the draft has started"}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="update-sports" />
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3"> <div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
@ -459,28 +535,28 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</div> </div>
</div> </div>
</div> </div>
</CardContent>
</Card>
{canEditSports && ( {/* Save Button */}
<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 && ( {actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm"> <div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error} {actionData.error}
</div> </div>
)} )}
</Form>
</CardContent>
</Card>
{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 */} {/* Draft Order Management */}
<Card> <Card>
<CardHeader> <CardHeader>

View file

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

View file

@ -66,6 +66,7 @@ export async function action(args: Route.ActionArgs) {
const name = formData.get("name"); const name = formData.get("name");
const teamCount = formData.get("teamCount"); const teamCount = formData.get("teamCount");
const templateId = formData.get("templateId"); const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
// Validation // Validation
if (typeof name !== "string" || !name.trim()) { 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" }; 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 { try {
// Create league // Create league
const league = await createLeague({ const league = await createLeague({
@ -101,6 +107,7 @@ export async function action(args: Route.ActionArgs) {
year: currentYear, year: currentYear,
status: "pre_draft", status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null, templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
draftRounds: draftRoundsNum,
}); });
// Set this as the current season for the league // 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 // Add selected sports to the season
const selectedSports = formData.getAll("sportsSeasons"); 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) { if (selectedSports.length > 0) {
await linkMultipleSportsToSeason( await linkMultipleSportsToSeason(
selectedSports.map((id) => ({ selectedSports.map((id) => ({
@ -139,6 +154,7 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
const { templates, allSportsSeasons } = loaderData; const { templates, allSportsSeasons } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState<string>(""); const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set()); const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
const [draftRounds, setDraftRounds] = useState<number>(20);
const handleSportToggle = (sportId: string) => { const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports); const newSelected = new Set(selectedSports);
@ -154,6 +170,13 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
setSelectedTemplate(templateId); setSelectedTemplate(templateId);
setSelectedSports(new Set(sportsSeasonIds)); 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 ( return (
<div className="container max-w-2xl mx-auto py-8 px-4"> <div className="container max-w-2xl mx-auto py-8 px-4">
<Card> <Card>
@ -203,6 +226,49 @@ export default function NewLeague({ loaderData, actionData }: Route.ComponentPro
</p> </p>
</div> </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"> <div className="space-y-4">
<Label>Sports Selection</Label> <Label>Sports Selection</Label>
<p className="text-sm text-muted-foreground"> <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"), status: seasonStatusEnum("status").notNull().default("pre_draft"),
templateId: uuid("template_id") templateId: uuid("template_id")
.references(() => seasonTemplates.id, { onDelete: "set null" }), .references(() => seasonTemplates.id, { onDelete: "set null" }),
draftRounds: integer("draft_rounds").notNull().default(20),
flexSpots: integer("flex_spots").notNull().default(0), flexSpots: integer("flex_spots").notNull().default(0),
inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(), inviteCode: varchar("invite_code", { length: 20 }).notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(), 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, "when": 1760588333462,
"tag": "0014_lowly_gateway", "tag": "0014_lowly_gateway",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1760589195551,
"tag": "0015_exotic_loners",
"breakpoints": true
} }
] ]
} }