import { useState, useEffect } from "react"; import { Form, redirect, Link } from "react-router"; import { auth } from "~/lib/auth.server"; import type { Route } from "./+types/new"; import { logger } from "~/lib/logger"; 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 { findUserById } from "~/models/user"; 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"; import { TimezoneSelect } from "~/components/league/TimezoneSelect"; export function meta(): Route.MetaDescriptors { return [{ title: "New League - Brackt" }]; } export async function loader(args: Route.LoaderArgs) { const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; const templates = await findActiveSeasonTemplates(); const allSportsSeasons = await findDraftableSportsSeasons(); const templatesWithSports = await Promise.all( templates.map(async (template) => { const templateWithSports = await findSeasonTemplateWithSportsSeasons(template.id); return { ...template, sportsSeasonIds: templateWithSports?.seasonTemplateSports?.map( (ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id ) || [] }; }) ); const commishTimezone = userId ? ((await findUserById(userId))?.timezone ?? null) : null; return { templates: templatesWithSports, allSportsSeasons: allSportsSeasons as Array, commishTimezone, }; } export async function action(args: Route.ActionArgs) { const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; 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"; const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } = parseDraftSpeed(draftSpeed as string | null, draftTimerMode); 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" }; } const scoringFields = [ "pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th", "pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th" ]; const scoringRules: Record = {}; 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; } } } const rawPauseMode = formData.get("overnightPauseMode") as string | null; const overnightPauseMode: "none" | "league" | "per_user" = rawPauseMode === "league" || rawPauseMode === "per_user" ? rawPauseMode : "none"; const overnightPauseStart = overnightPauseMode !== "none" ? (formData.get("overnightPauseStart") as string | null) ?? "23:00" : null; const overnightPauseEnd = overnightPauseMode !== "none" ? (formData.get("overnightPauseEnd") as string | null) ?? "07:00" : null; const overnightPauseTimezone = overnightPauseMode !== "none" ? (formData.get("overnightPauseTimezone") as string | null) ?? null : null; try { const league = await createLeague({ name: name.trim(), createdBy: userId, }); await createCommissioner({ leagueId: league.id, userId: userId, }); 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, overnightPauseMode, overnightPauseStart, overnightPauseEnd, overnightPauseTimezone, ...scoringRules, }); await setCurrentSeason(league.id, season.id); const selectedSports = formData.getAll("sportsSeasons"); 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, })) ); } const joinAsPlayer = formData.get("joinAsPlayer") === "on"; const teamNames = generateUniqueTeamNames(teamCountNum); const teams = teamNames.map((teamName, i) => ({ seasonId: season.id, name: teamName, ownerId: joinAsPlayer && i === 0 ? userId : null, })); await createManyTeams(teams); return redirect(`/leagues/${league.id}`); } catch (error) { logger.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, commishTimezone } = loaderData; const [selectedTemplate, setSelectedTemplate] = useState(""); const [selectedSports, setSelectedSports] = useState>(new Set()); const [draftRounds, setDraftRounds] = useState(20); const [draftDate, setDraftDate] = useState(); const [draftTime, setDraftTime] = useState(""); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">("chess_clock"); const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">("none"); const [overnightTimezone, setOvernightTimezone] = useState(commishTimezone ?? ""); useEffect(() => { if (!overnightTimezone) { const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; if (detected) setOvernightTimezone(detected); } }, [overnightTimezone]); 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)); }; const sportsCount = selectedSports.size; const minRounds = sportsCount; const recommendedRounds = Math.ceil(sportsCount * 1.25); const flexSpots = Math.max(0, draftRounds - sportsCount); return (
Start a New League Create your fantasy league and invite your friends to compete.

Choose between 6 and 16 teams

Minimum: {minRounds} (number of sports selected) {recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}

{sportsCount > 0 && draftRounds < sportsCount && (

⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected

)} {sportsCount > 0 && draftRounds >= sportsCount && (

Flex Spots: {flexSpots}

)}
date < new Date(new Date().setHours(0, 0, 0, 0))} /> setDraftTime(e.target.value)} placeholder="Select time" />
{draftDate && draftTime && ( )}

You must set a draft date and time before starting the draft

Chess Clock: unused time carries over between picks. Standard: timer resets each pick.

{timerMode === "standard" ? "Time per pick — resets to this value after each selection" : "Initial time bank + increment added after each pick"}

Protect players from having their pick timer expire while they're asleep. Autodraft still fires normally.

{overnightMode === "per_user" && (

Each player's profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.

{!commishTimezone && (

You haven't set your timezone yet —{" "} set it in your profile .

)}
)} {overnightMode !== "none" && ( <>
{overnightMode === "per_user" && (

Each player sets their timezone in their profile. This is used as a fallback if they haven't set one.

)}
)}

Choose a template to auto-select sports, or manually select your own

{templates.length > 0 && (
{templates.map((template) => ( handleTemplateClick(template.id, template.sportsSeasonIds)} >
{template.name} {template.year}
{selectedTemplate === template.id && (
)}
{template.description && (

{template.description}

)}
))}
)}

Recommendation: Select 16-20 sports seasons for optimal league balance and variety.

{allSportsSeasons.length > 0 ? ( allSportsSeasons.map((season) => (
handleSportToggle(season.id)} />
)) ) : (

No sports seasons available

)}
{actionData?.error && (
{actionData.error}
)}
); }