brackt/app/routes/leagues/new.tsx

546 lines
22 KiB
TypeScript
Raw Normal View History

import { useState } from "react";
import { Form, redirect, Link } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import type { Route } from "./+types/new";
import { createLeague, setCurrentSeason } from "~/models/league";
import { createCommissioner } from "~/models/commissioner";
import { createSeason } from "~/models/season";
import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { generateUniqueTeamNames } from "~/utils/team-names";
import { format } from "date-fns";
import { CalendarIcon, Check } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Checkbox } from "~/components/ui/checkbox";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
export function meta(): Route.MetaDescriptors {
return [{ title: "New League - Brackt" }];
}
export async function loader() {
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
// Fetch templates with their sports seasons for mapping
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id);
return {
...template,
sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map(
(ts: any) => ts.sportsSeason.id
) || []
};
})
);
return {
templates: templatesWithSports,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
};
}
export async function action(args: Route.ActionArgs) {
const { userId } = await getAuth(args);
const { request } = args;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get("name");
const teamCount = formData.get("teamCount");
const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
const draftDateTime = formData.get("draftDateTime");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
// Map draft speed to time values
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
// Validation
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
}
if (typeof teamCount !== "string") {
return { error: "Number of teams is required" };
}
const teamCountNum = parseInt(teamCount, 10);
if (isNaN(teamCountNum) || teamCountNum < 6 || teamCountNum > 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" };
}
// Extract and validate scoring rules
const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
];
const scoringRules: Record<string, number> = {};
for (const field of scoringFields) {
const value = formData.get(field);
if (typeof value === "string") {
const points = parseInt(value, 10);
if (!isNaN(points)) {
if (points < 0 || points > 1000) {
return { error: `${field} must be between 0 and 1000 points` };
}
scoringRules[field] = points;
}
}
}
try {
// Create league
const league = await createLeague({
name: name.trim(),
createdBy: userId,
});
// Make creator a commissioner
await createCommissioner({
leagueId: league.id,
userId: userId,
});
// Create first season
const currentYear = new Date().getFullYear();
const season = await createSeason({
leagueId: league.id,
year: currentYear,
status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" ? templateId : null,
draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
...scoringRules,
});
// Set this as the current season for the league
await setCurrentSeason(league.id, season.id);
// 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) => ({
seasonId: season.id,
sportsSeasonId: id as string,
}))
);
}
// Create teams with fun random names
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
const teams = teamNames.map((name, i) => ({
seasonId: season.id,
name,
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));
await createManyTeams(teams);
// Redirect to league homepage
return redirect(`/leagues/${league.id}`);
} catch (error) {
console.error("Error creating league:", error);
return { error: "Failed to create league. Please try again." };
}
}
export default function NewLeague({ loaderData, actionData }: Route.ComponentProps) {
const { templates, allSportsSeasons } = loaderData;
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedSports, setSelectedSports] = useState<Set<string>>(new Set());
const [draftRounds, setDraftRounds] = useState<number>(20);
const [draftDate, setDraftDate] = useState<Date>();
const [draftTime, setDraftTime] = useState<string>("");
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock");
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
newSelected.delete(sportId);
} else {
newSelected.add(sportId);
}
setSelectedSports(newSelected);
};
const handleTemplateClick = (templateId: string, sportsSeasonIds: string[]) => {
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>
<CardHeader>
<CardTitle className="text-3xl">Start a New League</CardTitle>
<CardDescription>
Create your fantasy league and invite your friends to compete.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
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="12" required>
<SelectTrigger id="teamCount">
<SelectValue placeholder="Select number of teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">6 Teams</SelectItem>
<SelectItem value="7">7 Teams</SelectItem>
<SelectItem value="8">8 Teams</SelectItem>
<SelectItem value="9">9 Teams</SelectItem>
<SelectItem value="10">10 Teams</SelectItem>
<SelectItem value="11">11 Teams</SelectItem>
<SelectItem value="12">12 Teams</SelectItem>
<SelectItem value="13">13 Teams</SelectItem>
<SelectItem value="14">14 Teams</SelectItem>
<SelectItem value="15">15 Teams</SelectItem>
<SelectItem value="16">16 Teams</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose between 6 and 16 teams
</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-2">
<Label htmlFor="draftDate">Draft Date & Time</Label>
<div className="grid gap-2 sm:grid-cols-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"justify-start text-left font-normal",
!draftDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={draftDate}
onSelect={setDraftDate}
initialFocus
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={draftTime}
onChange={(e) => setDraftTime(e.target.value)}
placeholder="Select time"
/>
</div>
{draftDate && draftTime && (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
)}
<p className="text-sm text-muted-foreground">
You must set a draft date and time before starting the draft
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label>
<select
id="draftTimerMode"
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label>
<select
id="draftSpeed"
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={timerMode === "standard" ? "90" : "standard"}
key={timerMode}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
{timerMode === "standard"
? "Time per pick — resets to this value after each selection"
: "Initial time bank + increment added after each pick"}
</p>
</div>
<ScoringRulesEditor disabled={false} />
<div className="space-y-4">
<Label>Sports Selection</Label>
<p className="text-sm text-muted-foreground">
Choose a template to auto-select sports, or manually select your own
</p>
<input type="hidden" name="templateId" value={selectedTemplate} />
{/* Template Cards */}
{templates.length > 0 && (
<div className="grid gap-3">
{templates.map((template) => (
<Card
key={template.id}
className={`cursor-pointer transition-all ${
selectedTemplate === template.id
? "ring-2 ring-primary"
: "hover:border-primary/50"
}`}
onClick={() => handleTemplateClick(template.id, template.sportsSeasonIds)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{template.name}</CardTitle>
<CardDescription>{template.year}</CardDescription>
</div>
{selectedTemplate === template.id && (
<div className="rounded-full bg-primary text-primary-foreground p-1">
<Check className="h-4 w-4" />
</div>
)}
</div>
</CardHeader>
{template.description && (
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">{template.description}</p>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Sports Checkboxes */}
<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-64 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)}
/>
<Label
htmlFor={season.id}
className="font-normal cursor-pointer flex-1"
>
<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>
</div>
</div>
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
<div className="flex items-center space-x-2">
<Checkbox
id="joinAsPlayer"
name="joinAsPlayer"
defaultChecked={true}
/>
<Label htmlFor="joinAsPlayer" className="font-normal cursor-pointer">
I want to play in this league (claim a team)
</Label>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
<div className="flex gap-4">
<Button type="submit" className="flex-1">
Create League
</Button>
<Button type="button" variant="outline" asChild>
<Link to="/">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
);
}