Refactor league settings into per-section components (#379)

* Refactor league settings into per-section components (#347)

Extract all settings sections from the monolithic 1009-line route file into
individual components under app/components/league/settings/. Route file drops
to ~300 lines. Separates draft-order dirty state from general settings dirty
state, deduplicates section-change handling, and fixes several bugs found
during review (typo in pointsFor5th, wrong mock in tests, lint violations).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix Stop hook loop: suppress output on typecheck success

The Stop hook was producing stdout on every run, causing Claude Code to
feed it back as context and rewake the model each turn. Now emits a
systemMessage JSON only on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Typescript fix

* Remove type asserting

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-05 14:19:50 -07:00 committed by GitHub
parent 049ec8a596
commit 05fe1493a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2694 additions and 1805 deletions

View file

@ -18,7 +18,8 @@
"hooks": [
{
"type": "command",
"command": "npm run typecheck 2>&1"
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
"timeout": 60
}
]
}

View file

@ -0,0 +1,169 @@
import type { Dispatch, SetStateAction } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Form } from "react-router";
import { ListOrdered } from "lucide-react";
import { Button } from "~/components/ui/button";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
import { DraftOrderMessage, type SettingsActionData } from "./SettingsMessages";
import { SortableDraftOrderRow } from "./SortableDraftOrderRow";
type DraftOrderTeam = {
id: string;
name: string;
ownerId: string | null;
};
export function DraftOrderSection({
active,
draftOrderSet,
canEditDraftOrder,
hasUnsavedChanges,
draftOrderTeams,
setDraftOrderTeams,
onDirtyChange,
onResetChanges,
teams,
ownerMap,
navigationState,
updateIntent,
actionData,
}: {
active: boolean;
draftOrderSet: boolean;
canEditDraftOrder: boolean;
hasUnsavedChanges: boolean;
draftOrderTeams: string[];
setDraftOrderTeams: Dispatch<SetStateAction<string[]>>;
onDirtyChange: (dirty: boolean) => void;
onResetChanges: () => void;
teams: DraftOrderTeam[];
ownerMap: Record<string, string | null>;
navigationState: "idle" | "loading" | "submitting";
updateIntent: FormDataEntryValue | null | undefined;
actionData: SettingsActionData;
}) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
return (
<SettingsSection
id="draft-order"
icon={ListOrdered}
title="Draft Order"
description="Drag positions to reorder teams, or randomize the entire draft order. Saved independently from the rest of the settings form."
status={draftOrderSet ? <SettingsStatusPill tone="success">Set</SettingsStatusPill> : undefined}
className={active ? undefined : "hidden"}
>
{!draftOrderSet && (
<div className="space-y-3">
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
The draft cannot begin until a draft order is set.
</p>
<p className="mt-1 text-sm text-amber-600 dark:text-amber-400">
Randomize the order now, or drag and drop teams into position manually below.
</p>
</div>
{canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button type="submit" className="w-full" disabled={navigationState === "submitting" && updateIntent === "randomize-draft-order"}>
{navigationState === "submitting" && updateIntent === "randomize-draft-order" ? "Randomizing..." : "Randomize Draft Order"}
</Button>
</Form>
)}
</div>
)}
<div>
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">Manually set draft order</h3>
<Form method="post" className="space-y-3">
<input type="hidden" name="intent" value="set-draft-order" />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active: activeItem, over }) => {
if (!over || activeItem.id === over.id) return;
setDraftOrderTeams((current) => {
const oldIndex = current.indexOf(String(activeItem.id));
const newIndex = current.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return current;
onDirtyChange(true);
return arrayMove(current, oldIndex, newIndex);
});
}}
>
<SortableContext items={draftOrderTeams} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{draftOrderTeams.map((teamId, index) => {
const team = teams.find((t) => t.id === teamId);
if (!team) return null;
return (
<SortableDraftOrderRow
key={teamId}
teamId={teamId}
index={index}
teamName={team.name}
ownerName={team.ownerId ? (ownerMap[team.ownerId] ?? "Unknown") : null}
disabled={!canEditDraftOrder}
/>
);
})}
</div>
</SortableContext>
</DndContext>
{canEditDraftOrder ? (
<div className="rounded-xl border bg-card p-3">
{hasUnsavedChanges && (
<p className="mb-3 text-sm font-medium text-amber-700 dark:text-amber-300">
You have unsaved draft order changes.
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
{hasUnsavedChanges && (
<Button type="button" variant="outline" className="sm:w-48" onClick={onResetChanges}>
Reset Changes
</Button>
)}
<Button type="submit" className="flex-1" disabled={navigationState === "submitting" && updateIntent === "set-draft-order"}>
{navigationState === "submitting" && updateIntent === "set-draft-order" ? "Saving..." : "Save Draft Order"}
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Draft order cannot be changed after the draft has started.</p>
)}
</Form>
</div>
{draftOrderSet && canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button type="submit" variant="outline" className="w-full" disabled={navigationState === "submitting" && updateIntent === "randomize-draft-order"}>
{navigationState === "submitting" && updateIntent === "randomize-draft-order" ? "Randomizing..." : "Randomize Draft Order"}
</Button>
</Form>
)}
<DraftOrderMessage actionData={actionData} />
</SettingsSection>
);
}

View file

@ -0,0 +1,190 @@
import { Swords, CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Calendar } from "~/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { TimerModeSelector } from "~/components/league/TimerModeSelector";
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
import { StepperInput } from "~/components/league/StepperInput";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function DraftSetupSection({
active,
canEditDraftRounds,
minRounds,
recommendedRounds,
flexSpots,
draftRounds,
onDraftRoundsChange,
draftDate,
onDraftDateChange,
draftTime,
onDraftTimeChange,
timerMode,
onTimerModeChange,
draftSpeed,
onDraftSpeedChange,
overnightMode,
onOvernightModeChange,
overnightStart,
onOvernightStartChange,
overnightEnd,
onOvernightEndChange,
overnightTimezone,
onOvernightTimezoneChange,
commishTimezone,
}: {
active: boolean;
canEditDraftRounds: boolean;
minRounds: number;
recommendedRounds: number;
flexSpots: number;
draftRounds: number;
onDraftRoundsChange: (v: number) => void;
draftDate: Date | undefined;
onDraftDateChange: (v: Date | undefined) => void;
draftTime: string;
onDraftTimeChange: (v: string) => void;
timerMode: "chess_clock" | "standard";
onTimerModeChange: (v: "chess_clock" | "standard") => void;
draftSpeed: string;
onDraftSpeedChange: (v: string) => void;
overnightMode: "none" | "league" | "per_user";
onOvernightModeChange: (v: "none" | "league" | "per_user") => void;
overnightStart: string;
onOvernightStartChange: (v: string) => void;
overnightEnd: string;
onOvernightEndChange: (v: string) => void;
overnightTimezone: string;
onOvernightTimezoneChange: (v: string) => void;
commishTimezone: string | null;
}) {
return (
<SettingsSection
id="draft-setup"
icon={Swords}
title="Draft Settings"
description="Configure the draft format, timing, overnight protections, and pick order."
status={<SettingsStatusPill tone={canEditDraftRounds ? "success" : "locked"}>{canEditDraftRounds ? "Editable" : "Locked"}</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<input type="hidden" name="draftRounds" value={draftRounds} />
<input type="hidden" name="draftTimerMode" value={timerMode} />
<input type="hidden" name="draftSpeed" value={draftSpeed} />
<input type="hidden" name="overnightPauseMode" value={overnightMode} />
<input type="hidden" name="overnightPauseStart" value={overnightStart} />
<input type="hidden" name="overnightPauseEnd" value={overnightEnd} />
<input type="hidden" name="overnightPauseTimezone" value={overnightTimezone} />
{draftDate && draftTime ? (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
) : (
<input type="hidden" name="draftDateTime" value="" />
)}
<div className="space-y-8">
<div className="space-y-8">
<div className="rounded-lg border p-5 sm:p-6">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<Label>Draft Rounds</Label>
<p className="text-sm text-muted-foreground">Minimum {minRounds}; recommended {recommendedRounds}.</p>
</div>
<Badge variant="outline">{flexSpots} flex</Badge>
</div>
<StepperInput
value={draftRounds}
min={canEditDraftRounds ? Math.max(1, minRounds) : draftRounds}
max={canEditDraftRounds ? 50 : draftRounds}
onChange={onDraftRoundsChange}
decrementLabel="Decrease draft rounds"
incrementLabel="Increase draft rounds"
/>
{!canEditDraftRounds && (
<p className="mt-3 text-sm text-muted-foreground">Round count cannot be changed after draft starts.</p>
)}
</div>
<div className="rounded-lg border p-5 sm:p-6">
<Label htmlFor="draftDate">Draft Date & Time</Label>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn("justify-start text-left font-normal", !draftDate && "text-muted-foreground")}
disabled={!canEditDraftRounds}
>
<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={(date) => onDraftDateChange(date)}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={draftTime}
onChange={(e) => onDraftTimeChange(e.target.value)}
disabled={!canEditDraftRounds}
/>
</div>
<p className="mt-4 text-sm text-muted-foreground">
Set this before starting the draft room.
</p>
</div>
<div className="space-y-8 rounded-lg border p-5 sm:p-6">
<div className="space-y-4">
<Label>Timer Mode</Label>
<TimerModeSelector
value={timerMode}
onChange={onTimerModeChange}
disabled={!canEditDraftRounds}
/>
</div>
<div className="border-t pt-8 space-y-4">
<Label>Draft Speed</Label>
<DraftSpeedPicker
timerMode={timerMode}
value={draftSpeed}
onChange={onDraftSpeedChange}
disabled={!canEditDraftRounds}
/>
</div>
</div>
</div>
<div className="rounded-lg border p-5 sm:p-6">
<OvernightPauseSettings
show={true}
mode={overnightMode}
onModeChange={onOvernightModeChange}
start={overnightStart}
onStartChange={onOvernightStartChange}
end={overnightEnd}
onEndChange={onOvernightEndChange}
timezone={overnightTimezone}
onTimezoneChange={onOvernightTimezoneChange}
commishTimezone={commishTimezone}
disabled={!canEditDraftRounds}
/>
</div>
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,134 @@
import { Activity, RotateCcw, ShieldAlert } from "lucide-react";
import { Form, Link } from "react-router";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { cn } from "~/lib/utils";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function HistoryDangerSection({
active,
league,
season,
isAdmin,
isDeleteDialogOpen,
onDeleteDialogOpenChange,
navigationState,
}: {
active: boolean;
league: { id: string; name: string };
season: { id: string } | null;
isAdmin: boolean;
isDeleteDialogOpen: boolean;
onDeleteDialogOpenChange: (open: boolean) => void;
navigationState: "idle" | "loading" | "submitting";
}) {
return (
<SettingsSection
id="history-danger"
icon={ShieldAlert}
title="History & Danger"
description="Audit history is informational. Reset and delete actions are intentionally separated from normal settings."
status={<SettingsStatusPill tone="warning">Careful</SettingsStatusPill>}
className={cn("border-destructive/40", !active && "hidden")}
>
<div className="space-y-3">
<div className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Activity className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<h3 className="font-semibold">Audit Log</h3>
<p className="mt-1 text-sm text-muted-foreground">View the full history of commissioner actions for this season.</p>
</div>
</div>
<Button variant="outline" className="sm:w-44" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>View Audit Log</Link>
</Button>
</div>
{isAdmin && season && (
<div className="flex flex-col gap-4 rounded-lg border border-destructive/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<RotateCcw className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<h3 className="font-semibold">Reset Draft</h3>
<p className="mt-1 text-sm text-muted-foreground">
Delete all picks, queues, and timers. Draft order is preserved.
</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="sm:w-44">
Reset Draft
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
<AlertDialogDescription>
This deletes all draft picks, queues, and timers, then returns the season to pre-draft. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="reset-draft" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Reset Draft
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
<div className="flex flex-col gap-4 rounded-lg border border-destructive/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<h3 className="font-semibold">Delete League</h3>
<p className="mt-1 text-sm text-muted-foreground">
Permanently delete this league and all associated data.
</p>
</div>
</div>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={onDeleteDialogOpenChange}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="sm:w-44" disabled={navigationState === "submitting"}>
Delete League
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes <strong>{league.name}</strong> and all associated data including seasons, teams, and commissioners.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Delete League
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,123 @@
import { Trophy } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { StepperInput } from "~/components/league/StepperInput";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function LeagueBasicsSection({
active,
season,
leagueName,
onLeagueNameChange,
isPublicDraftBoard,
onIsPublicDraftBoardChange,
teamCountValue,
onTeamCountValueChange,
teamsWithOwners,
canEditTeamCount,
minTeamCount,
inviteUrl,
copied,
onCopyInviteLink,
}: {
active: boolean;
season: { inviteCode: string | null } | null;
leagueName: string;
onLeagueNameChange: (v: string) => void;
isPublicDraftBoard: boolean;
onIsPublicDraftBoardChange: (v: boolean) => void;
teamCountValue: number;
onTeamCountValueChange: (v: number) => void;
teamsWithOwners: number;
canEditTeamCount: boolean;
minTeamCount: number;
inviteUrl: string;
copied: boolean;
onCopyInviteLink: () => void;
}) {
return (
<SettingsSection
id="league-basics"
icon={Trophy}
title="League Basics"
description="High-level league identity and public visibility controls."
status={<SettingsStatusPill tone="success">Always editable</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<input type="hidden" name="teamCount" value={teamCountValue} />
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
value={leagueName}
onChange={(e) => onLeagueNameChange(e.target.value)}
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<label
htmlFor="isPublicDraftBoard"
className="flex cursor-pointer items-start gap-3 rounded-lg border p-4"
>
<input
type="checkbox"
id="isPublicDraftBoard"
name="isPublicDraftBoard"
checked={isPublicDraftBoard}
onChange={(e) => onIsPublicDraftBoardChange(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-border"
/>
<span>
<span className="block font-medium">Public draft board</span>
<span className="text-sm text-muted-foreground">Allow anyone with the link to view the board without logging in.</span>
</span>
</label>
<div className="rounded-lg border p-4">
<div className="mb-3">
<Label>Number of Teams</Label>
<p className="text-sm text-muted-foreground">Minimum {minTeamCount} based on teams with owners.</p>
</div>
<StepperInput
value={teamCountValue}
min={canEditTeamCount ? minTeamCount : teamCountValue}
max={canEditTeamCount ? 16 : teamCountValue}
onChange={onTeamCountValueChange}
decrementLabel="Decrease team count"
incrementLabel="Increase team count"
/>
{!canEditTeamCount && (
<p className="mt-3 text-sm text-muted-foreground">Locked after the draft starts.</p>
)}
</div>
{season?.inviteCode && (
<div className="rounded-lg border p-4">
<div className="mb-3">
<h3 className="font-medium">Invite Link</h3>
<p className="text-sm text-muted-foreground">
{/* eslint-disable-next-line no-nested-ternary */}
{(() => {
const open = teamCountValue - teamsWithOwners;
return open > 0
? `${open} open spot${open === 1 ? "" : "s"} available.`
: "No open team spots right now.";
})()}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input readOnly value={inviteUrl} onClick={(e) => e.currentTarget.select()} className="text-sm" />
<Button type="button" variant={copied ? "default" : "outline"} onClick={onCopyInviteLink}>
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
)}
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,22 @@
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
export function LeagueSettingsHeader({
leagueId,
leagueName,
}: {
leagueId: string;
leagueName: string;
}) {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold sm:text-4xl">League Settings</h1>
<p className="mt-1 text-muted-foreground">{leagueName}</p>
</div>
<Button variant="outline" asChild>
<Link to={`/leagues/${leagueId}`}>Back to League</Link>
</Button>
</div>
);
}

View file

@ -0,0 +1,42 @@
import { Bell } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function NotificationsSection({
active,
savedDiscordWebhookUrl,
discordWebhookUrl,
onDiscordWebhookUrlChange,
}: {
active: boolean;
savedDiscordWebhookUrl: string | null;
discordWebhookUrl: string;
onDiscordWebhookUrlChange: (v: string) => void;
}) {
return (
<SettingsSection
id="notifications"
icon={Bell}
title="Notifications"
description="Send Discord alerts when standings change after scoring events."
status={<SettingsStatusPill tone={savedDiscordWebhookUrl ? "success" : "warning"}>{savedDiscordWebhookUrl ? "Configured" : "Optional"}</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
value={discordWebhookUrl}
onChange={(e) => onDiscordWebhookUrlChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in Discord under Server Settings, Integrations, Webhooks.
</p>
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,156 @@
import { Crown, Users } from "lucide-react";
import { Form } from "react-router";
import { Button } from "~/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
type Team = { id: string; name: string; ownerId: string | null };
type Commissioner = { id: string; userId: string; userName: string };
type LeagueMember = { id: string; name: string | null };
export function PeopleSection({
active,
teams,
ownerMap,
commissioners,
currentUserId,
teamCountValue,
teamsWithOwners,
leagueMembers,
isAdmin,
navigationState,
}: {
active: boolean;
teams: Team[];
ownerMap: Record<string, string | null>;
commissioners: Commissioner[];
currentUserId: string;
teamCountValue: number;
teamsWithOwners: number;
leagueMembers: LeagueMember[];
isAdmin: boolean;
navigationState: "idle" | "loading" | "submitting";
}) {
return (
<SettingsSection
id="people"
icon={Users}
title="People"
description="Manage team owners and commissioners. Team capacity is saved from League Basics."
status={<SettingsStatusPill tone="default">{teamsWithOwners}/{teamCountValue} filled</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<div className="space-y-3">
{teams.map((team) => {
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div key={team.id} className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{team.name}</p>
<p className="text-sm text-muted-foreground">
{team.ownerId ? `Owner: ${ownerName || "Unknown"}` : "No owner"}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{team.ownerId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
Remove Owner
</Button>
</Form>
)}
{isAdmin && (
<Form method="post" className="flex flex-col gap-2 sm:flex-row">
<input type="hidden" name="intent" value="assign-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Select name="userId" required>
<SelectTrigger className="h-9 sm:w-[190px]">
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
{leagueMembers.map((member) => {
const userOwnsTeam = teams.some((t) => t.ownerId === member.id);
return (
<SelectItem key={member.id} value={member.id} disabled={userOwnsTeam}>
{member.name || "Unknown"}
{userOwnsTeam && " (already has team)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
Assign
</Button>
</Form>
)}
</div>
</div>
);
})}
</div>
<div className="border-t pt-5">
<div className="mb-3 flex items-center gap-2">
<Crown className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold">Commissioners</h3>
</div>
<div className="space-y-3">
{commissioners.map((commissioner) => (
<div key={commissioner.id} className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">
{commissioner.userName}
{commissioner.userId === currentUserId && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</p>
{!teams.some((t) => t.ownerId === commissioner.userId) && (
<p className="text-xs text-muted-foreground">No team in current season</p>
)}
</div>
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-commissioner" />
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
Remove
</Button>
</Form>
)}
</div>
))}
</div>
<Form method="post" className="mt-4 flex flex-col gap-2 sm:flex-row">
<input type="hidden" name="intent" value="add-commissioner" />
<Select name="userId" required>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Add commissioner from league members" />
</SelectTrigger>
<SelectContent>
{leagueMembers.map((member) => {
const alreadyCommissioner = commissioners.some((c) => c.userId === member.id);
return (
<SelectItem key={member.id} value={member.id} disabled={alreadyCommissioner}>
{member.name || "Unknown"}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button type="submit" variant="outline" disabled={navigationState === "submitting"}>
Add
</Button>
</Form>
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,46 @@
import { TrendingUp } from "lucide-react";
import { type ScoringRules } from "~/lib/scoring-types";
import { ScoringPresetPicker } from "~/components/league/ScoringPresetPicker";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
export function ScoringSection({
active,
canEditSports,
season,
scoringPreset,
onScoringPresetChange,
scoringRules,
onScoringRulesChange,
}: {
active: boolean;
canEditSports: boolean;
season: { status: string } | null;
scoringPreset: "brackt" | "omnifantasy" | "custom";
onScoringPresetChange: (v: "brackt" | "omnifantasy" | "custom") => void;
scoringRules: ScoringRules;
onScoringRulesChange: (v: ScoringRules) => void;
}) {
return (
<SettingsSection
id="scoring"
icon={TrendingUp}
title="Scoring"
description="Configure placement scoring for drafted sports. Scoring locks when the draft starts."
status={<SettingsStatusPill tone={canEditSports ? "success" : "locked"}>{canEditSports ? "Editable" : "Locked"}</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<div className="rounded-lg border p-5 sm:p-6">
<ScoringPresetPicker
preset={scoringPreset}
onPresetChange={onScoringPresetChange}
rules={scoringRules}
onRulesChange={onScoringRulesChange}
disabled={!season || season.status !== "pre_draft"}
/>
{(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))}
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,55 @@
export type SettingsActionData =
| {
section?: string;
error?: string;
testSuccess?: boolean;
success?: boolean;
message?: string;
}
| undefined;
export function SettingsMessage({ actionData }: { actionData: SettingsActionData }) {
if (!actionData) return null;
if (actionData.section === "draft-order") return null;
if (actionData.error) {
return (
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
{actionData.error}
</div>
);
}
if (actionData.testSuccess) {
return (
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
Test notification sent to Discord.
</div>
);
}
if (actionData.success) {
return (
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
{actionData.message ?? "Settings updated successfully."}
</div>
);
}
return null;
}
export function DraftOrderMessage({ actionData }: { actionData: SettingsActionData }) {
if (!actionData || actionData.section !== "draft-order") return null;
if (actionData.error) {
return (
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
{actionData.error}
</div>
);
}
if (actionData.success && actionData.message) {
return (
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
{actionData.message}
</div>
);
}
return null;
}

View file

@ -0,0 +1,127 @@
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
import { ArrowLeft, Check } from "lucide-react";
import { cn } from "~/lib/utils";
type SettingsNavSection = {
id: string;
label: string;
};
export type SettingsGridSection = SettingsNavSection & {
icon: ComponentType<LucideProps>;
subtitle: string;
isComplete: boolean;
isDanger?: boolean;
};
export function SettingsMobileGridNav({
sections,
completedCount,
onSectionChange,
}: {
sections: readonly SettingsGridSection[];
completedCount: number;
onSectionChange: (sectionId: string) => void;
}) {
return (
<div className="mb-5 lg:hidden">
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage
</p>
<p className="text-xs text-muted-foreground">
{completedCount} of {sections.length} set
</p>
</div>
<div className="grid grid-cols-2 gap-3">
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => onSectionChange(section.id)}
className={cn(
"relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
section.isDanger && "border-destructive/40"
)}
>
{section.isComplete && (
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
)}
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg",
section.isDanger
? "bg-destructive/15 text-destructive"
: "bg-primary/20 text-primary"
)}
>
<section.icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold leading-tight">{section.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
</div>
</button>
))}
</div>
</div>
);
}
export function SettingsMobileSectionPill({
onShowGrid,
}: {
onShowGrid: () => void;
}) {
return (
<div className="mb-5 lg:hidden">
<button
type="button"
onClick={onShowGrid}
className="flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to all settings
</button>
</div>
);
}
export function SettingsDesktopNav({
sections,
activeSection,
onSectionChange,
}: {
sections: readonly SettingsGridSection[];
activeSection: string;
onSectionChange: (sectionId: string) => void;
}) {
return (
<aside className="hidden lg:block">
<div className="sticky top-6 rounded-xl border bg-card p-3">
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage
</p>
<nav className="space-y-1">
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => onSectionChange(section.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
activeSection === section.id && "bg-muted text-foreground"
)}
>
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} />
{section.label}
</button>
))}
</nav>
</div>
</aside>
);
}

View file

@ -0,0 +1,31 @@
import { Button } from "~/components/ui/button";
export function SettingsSaveBar({
hasUnsavedChanges,
isSubmitting,
onReset,
}: {
hasUnsavedChanges: boolean;
isSubmitting: boolean;
onReset: () => void;
}) {
return (
<div className="space-y-3">
{hasUnsavedChanges && (
<p className="mb-3 text-sm font-medium text-amber-700 dark:text-amber-300">
You have unsaved settings changes.
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
{hasUnsavedChanges && (
<Button type="button" variant="outline" className="h-12 sm:w-48" onClick={onReset}>
Reset Changes
</Button>
)}
<Button type="submit" className="h-12 flex-1" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Settings"}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { cn } from "~/lib/utils";
type StatusTone = "default" | "success" | "warning" | "locked";
export function SettingsStatusPill({
children,
tone = "default",
}: {
children: ReactNode;
tone?: StatusTone;
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold",
tone === "success" && "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
tone === "warning" && "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
tone === "locked" && "border-muted-foreground/20 bg-muted text-muted-foreground",
tone === "default" && "border-border bg-background text-muted-foreground"
)}
>
{children}
</span>
);
}
export function SettingsSection({
id,
icon,
title,
description,
status,
children,
className,
}: {
id: string;
icon: ComponentType<LucideProps>;
title: string;
description: string;
status?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section id={id} className={cn("scroll-mt-24 space-y-8", className)}>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" />
<div>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{status && <div className="sm:pt-1">{status}</div>}
</div>
</div>
<div className="space-y-5">{children}</div>
</section>
);
}

View file

@ -0,0 +1,64 @@
import { CSS } from "@dnd-kit/utilities";
import { useSortable } from "@dnd-kit/sortable";
import { GripVertical } from "lucide-react";
import { cn } from "~/lib/utils";
export function SortableDraftOrderRow({
teamId,
index,
teamName,
ownerName,
disabled = false,
}: {
teamId: string;
index: number;
teamName: string;
ownerName: string | null;
disabled?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: teamId });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4",
isDragging && "z-10 shadow-lg ring-1 ring-primary/30",
disabled && "opacity-65"
)}
>
<input type="hidden" name="teamOrder" value={teamId} />
<button
type="button"
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted",
disabled && "cursor-not-allowed hover:bg-transparent"
)}
aria-label={`Drag ${teamName}`}
disabled={disabled}
{...(disabled ? {} : attributes)}
{...(disabled ? {} : listeners)}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{teamName}</p>
{ownerName && <p className="truncate text-xs text-muted-foreground">{ownerName}</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { ClipboardList } from "lucide-react";
import { Label } from "~/components/ui/label";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { cn } from "~/lib/utils";
import { SportIcon } from "~/components/league/SportIcon";
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
type SportsSeason = {
id: string;
name: string;
year: number;
scoringType: string;
sport: { id: string; name: string; slug: string; type: string; iconUrl: string | null };
};
const BRACKT_SLUG = "brackt";
export function SportsSection({
active,
canEditSports,
selectedSports,
displaySportsSeasons,
onSportToggle,
onClearAll,
}: {
active: boolean;
canEditSports: boolean;
selectedSports: Set<string>;
displaySportsSeasons: SportsSeason[];
onSportToggle: (id: string) => void;
onClearAll: () => void;
}) {
return (
<SettingsSection
id="sports"
icon={ClipboardList}
title="Sports"
description="Manage draftable sports seasons for this league. Sports lock when the draft starts."
status={<SettingsStatusPill tone={canEditSports ? "success" : "locked"}>{canEditSports ? "Editable" : "Locked"}</SettingsStatusPill>}
className={active ? undefined : "hidden"}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<Label>{selectedSports.size} selected</Label>
{canEditSports && selectedSports.size > 0 && (
<Button type="button" variant="ghost" size="sm" onClick={onClearAll}>
Clear all
</Button>
)}
</div>
<div className="space-y-2">
{displaySportsSeasons.length > 0 ? (
displaySportsSeasons.map((ss) => {
const selected = selectedSports.has(ss.id);
return (
<label
key={ss.id}
htmlFor={`sport-${ss.id}`}
className={cn(
"mb-2 flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm transition-colors last:mb-0",
selected ? "border-primary bg-primary/10 text-primary" : "border-border bg-background hover:border-primary/50",
!canEditSports && "cursor-not-allowed opacity-65"
)}
>
<Checkbox
id={`sport-${ss.id}`}
name="sportsSeasons"
value={ss.id}
checked={selected}
onCheckedChange={() => onSportToggle(ss.id)}
disabled={!canEditSports}
/>
<SportIcon sport={ss.sport} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
</span>
<span className="text-xs text-muted-foreground">{ss.scoringType.replace("_", " ")}</span>
</span>
</label>
);
})
) : (
<p className="p-3 text-sm text-muted-foreground">No sports seasons available.</p>
)}
</div>
</div>
</SettingsSection>
);
}

View file

@ -0,0 +1,69 @@
import type { Blocker } from "react-router";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
export function LeaveSettingsDialog({ blocker }: { blocker: Blocker }) {
if (blocker.state !== "blocked") return null;
return (
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave without saving?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved settings changes. If you leave this page, those changes will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => blocker.reset?.()}>
Stay
</AlertDialogCancel>
<AlertDialogAction onClick={() => blocker.proceed?.()}>
Leave without saving
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function SwitchSettingsSectionDialog({
open,
onOpenChange,
onStay,
onDiscard,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onStay: () => void;
onDiscard: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Switch sections without saving?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved settings changes. Save before switching sections, or discard your edits to continue.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onStay}>
Stay
</AlertDialogCancel>
<AlertDialogAction onClick={onDiscard}>
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,770 @@
import { redirect } from "react-router";
import { auth } from "~/lib/auth.server";
import { logger } from "~/lib/logger";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { deleteLeague, findLeagueById, updateLeague } from "~/models/league";
import {
countCommissionersByLeagueId,
createCommissioner,
findCommissionersByLeagueId,
hasCommissionerRecord,
isCommissioner,
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { findDraftSlotsBySeasonId, randomizeDraftOrder, setDraftOrder } from "~/models/draft-slot";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { deleteSeasonTimers } from "~/models/draft-timer";
import { findCurrentSeasonWithSports, type NewSeason, updateSeason } from "~/models/season";
import { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
import { claimTeam, deleteTeam, findTeamById, findTeamsBySeasonId, removeTeamOwner, renameTeam } from "~/models/team";
import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { sendStandingsUpdateNotification } from "~/services/discord";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
syncPrivateBracktParticipants,
} from "~/services/brackt.server";
import { generateUniqueTeamNames, prependOwnerToTeamName, stripOwnerFromTeamName } from "~/utils/team-names";
import type { Route } from "./+types/$leagueId.settings";
function isValidHHMM(s: string): boolean {
return /^\d{2}:\d{2}$/.test(s);
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
// Settings are limited to league commissioners and site admins. isCommissioner()
// includes admins, so league creators without a commissioner record do not get
// read-only or edit access here.
const userIsCommissioner = await isCommissioner(leagueId, userId);
if (!userIsCommissioner) {
throw new Response("Forbidden - You must be a commissioner to access settings", {
status: 403,
});
}
await seedOrRepairBracktTemplate();
const [season, draftableSportsSeasons] = await Promise.all([
findCurrentSeasonWithSports(leagueId),
findDraftableSportsSeasons(),
]);
const teams = season ? await findTeamsBySeasonId(season.id) : [];
// Merge draftable seasons with any already-linked non-draftable seasons so that
// previously-added seasons remain visible and checked in the UI even after being disabled.
const today = new Date().toISOString().slice(0, 10);
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
const linkedButNotDraftable = (season?.seasonSports ?? [])
.map((s) => s.sportsSeason)
.filter((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return !draftableIds.has(ss.id) && (fantasySeasonId || ss.draftOff < today || ss.draftOn > today);
});
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
const teamsWithOwners = teams.filter((team) => team.ownerId !== null).length;
const isAdmin = await isUserAdmin(userId);
const commissioners = await findCommissionersByLeagueId(leagueId);
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const commissionerUserIds = commissioners.map((c) => c.userId);
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
const userRows = await findUsersByIds(allUserIds);
const userById = new Map(userRows.map((u) => [u.id, u]));
const commissionerUserData = commissioners.map((c) => {
const user = userById.get(c.userId);
return {
...c,
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
};
});
const validOwners = uniqueOwnerIds
.map((ownerId) => {
const user = userById.get(ownerId);
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
})
.filter((o): o is NonNullable<typeof o> => o !== null);
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
// People currently attached to the league, either through team ownership or
// commissioner membership. Admin assignment should not expose every user.
const leagueMembers = allUserIds
.map((memberUserId) => {
const user = userById.get(memberUserId);
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
})
.filter((o): o is NonNullable<typeof o> => o !== null);
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
return {
league,
season,
teams,
teamCount: teams.length,
teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & {
fantasySeasonId: string | null;
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
}>,
draftSlots,
isAdmin,
leagueMembers,
ownerMap: Object.fromEntries(ownerMap),
commissioners: commissionerUserData,
currentUserId: userId,
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 { params, request } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
const userIsCommissioner = await isCommissioner(leagueId, userId);
if (!userIsCommissioner) {
throw new Response("Forbidden", { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
// For league-level settings (name, public draft board), save immediately
// before checking season, so they work even if no season exists
if (intent === "update") {
const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl");
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
}
if (name.trim().length < 3 || name.trim().length > 50) {
return { error: "League name must be between 3 and 50 characters" };
}
const webhookUrl =
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
}
try {
const changedFields: string[] = [];
if (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
});
if (changedFields.length > 0) {
const currentSeason = await findCurrentSeasonWithSports(leagueId);
if (currentSeason) {
await logCommissionerAction({
seasonId: currentSeason.id,
leagueId,
actorUserId: userId,
action: "league_settings_changed",
details: {
changedFields,
previousValues: {
name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
},
},
});
}
}
} catch (error) {
logger.error("Error updating league:", error);
return { error: "Failed to update league. Please try again." };
}
}
if (intent === "test-discord-webhook") {
const webhookUrl = formData.get("webhookUrl");
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Invalid Discord webhook URL" };
}
try {
const testSeason = await findCurrentSeasonWithSports(leagueId);
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
await sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings: [
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
],
previousStandings: new Map([
["1", 125],
["2", 125],
["3", 100],
]),
previousRanks: new Map([
["1", 2],
["2", 1],
["3", 3],
]),
eventName: "Test Notification",
scoredMatches: [
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
});
return { testSuccess: true };
} catch (err) {
logger.error("Discord test webhook failed:", err);
return { error: "Failed to send test notification. Check your webhook URL." };
}
}
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
if (intent === "update") {
return redirect(`/leagues/${leagueId}?updated=true`);
}
return { error: "No active season found" };
}
if (intent === "set-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = formData.getAll("teamOrder") as string[];
if (teamIds.length !== teams.length) {
return { error: "All teams must be included in the draft order", section: "draft-order" as const };
}
const validTeamIds = new Set(teams.map((t) => t.id));
for (const teamId of teamIds) {
if (!validTeamIds.has(teamId)) {
return { error: "Invalid team ID in draft order", section: "draft-order" as const };
}
}
await setDraftOrder(season.id, teamIds);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_order_set",
affectedTeamIds: teamIds,
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teams.find((t) => t.id === id)?.name ?? id,
position: i + 1,
})),
},
});
return { success: true, message: "Draft order updated successfully", section: "draft-order" as const };
}
if (intent === "randomize-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = teams.map((t) => t.id);
await randomizeDraftOrder(season.id, teamIds);
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_order_randomized",
affectedTeamIds: sortedSlots.map((s) => s.teamId),
details: {
order: sortedSlots.map((s) => ({
teamId: s.teamId,
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
return { success: true, message: "Draft order randomized successfully", section: "draft-order" as const };
}
if (intent === "remove-team-owner") {
const teamId = formData.get("teamId") as string;
if (!teamId) {
return { error: "Team ID is required" };
}
try {
const team = await findTeamById(teamId);
if (team?.ownerId) {
const owner = await findUserById(team.ownerId);
const username = owner?.username ?? owner?.displayName;
if (username) {
const strippedName = stripOwnerFromTeamName(team.name, username);
if (strippedName !== team.name) {
await renameTeam(teamId, strippedName);
}
await removeTeamOwner(teamId);
} else {
await removeTeamOwner(teamId);
}
} else {
await removeTeamOwner(teamId);
}
return { success: true, message: "Owner removed successfully" };
} catch (error) {
logger.error("Error removing team owner:", error);
return { error: "Failed to remove owner. Please try again." };
}
}
if (intent === "assign-team-owner") {
const teamId = formData.get("teamId") as string;
const assignedUserId = formData.get("userId") as string;
if (!teamId || !assignedUserId) {
return { error: "Team ID and User ID are required" };
}
const isAdmin = await isUserAdmin(userId);
if (!isAdmin) {
return { error: "Only admins can assign team owners" };
}
const assignedUser = await findUserById(assignedUserId);
if (!assignedUser) {
return { error: "User not found. Please try again." };
}
const teams = await findTeamsBySeasonId(season.id);
const isLeagueMember =
teams.some((team) => team.ownerId === assignedUserId) ||
(await hasCommissionerRecord(leagueId, assignedUserId));
if (!isLeagueMember) {
return { error: "Only current league members can be assigned to teams" };
}
const userAlreadyHasTeam = teams.some((team) => team.ownerId === assignedUserId);
if (userAlreadyHasTeam) {
return { error: "This user is already assigned to a team in this league" };
}
const targetTeam = teams.find((team) => team.id === teamId);
if (!targetTeam) {
return { error: "Team not found" };
}
if (targetTeam.ownerId) {
return { error: "This team already has an owner. Remove the current owner first." };
}
try {
const teamName = prependOwnerToTeamName(
targetTeam.name,
assignedUser.username ?? assignedUser.displayName ?? "Member"
);
await claimTeam(teamId, assignedUserId, teamName);
return { success: true, message: "Owner assigned successfully" };
} catch (error) {
logger.error("Error assigning team owner:", error);
return { error: "Failed to assign owner. Please try again." };
}
}
if (intent === "reset-draft") {
const isAdmin = await isUserAdmin(userId);
if (!isAdmin) {
return { error: "Only admins can reset the draft" };
}
try {
await deleteAllDraftPicks(season.id);
await clearAllQueuesForSeason(season.id);
await deleteSeasonTimers(season.id);
const previousPickNumber = season.currentPickNumber;
await updateSeason(season.id, {
status: "pre_draft",
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,
});
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_reset",
details: { previousPickNumber },
});
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
} catch (error) {
logger.error("Error resetting draft:", error);
return { error: "Failed to reset draft. Please try again." };
}
}
if (intent === "delete") {
await deleteLeague(leagueId);
return redirect("/?deleted=true");
}
if (intent === "update") {
const teamCount = formData.get("teamCount");
const draftDateTime = formData.get("draftDateTime");
const draftRounds = formData.get("draftRounds");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
try {
const seasonUpdates: Partial<NewSeason> = {};
if (typeof draftRounds === "string") {
const draftRoundsNum = parseInt(draftRounds, 10);
if (!isNaN(draftRoundsNum)) {
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" };
}
if (draftRoundsNum !== season.draftRounds) {
seasonUpdates.draftRounds = draftRoundsNum;
}
}
}
if (typeof draftDateTime === "string") {
const newDateTime = draftDateTime ? new Date(draftDateTime) : null;
const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null;
const changed = newDateTime?.getTime() !== currentDateTime?.getTime();
if (changed) {
seasonUpdates.draftDateTime = newDateTime;
}
}
if (draftSpeed !== null) {
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
draftSpeed as string | null,
draftTimerMode ?? "chess_clock"
);
if (draftInitialTime !== season.draftInitialTime) {
seasonUpdates.draftInitialTime = draftInitialTime;
}
if (draftIncrementTime !== season.draftIncrementTime) {
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
}
if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) {
seasonUpdates.draftTimerMode = draftTimerMode;
}
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
if (overnightPauseMode !== season.overnightPauseMode) {
seasonUpdates.overnightPauseMode = overnightPauseMode;
}
if (overnightPauseMode !== "none") {
const start = formData.get("overnightPauseStart") as string | null;
const end = formData.get("overnightPauseEnd") as string | null;
const tz = formData.get("overnightPauseTimezone") as string | null;
if (start && isValidHHMM(start) && start !== season.overnightPauseStart) {
seasonUpdates.overnightPauseStart = start;
}
if (end && isValidHHMM(end) && end !== season.overnightPauseEnd) {
seasonUpdates.overnightPauseEnd = end;
}
if (tz && tz.length > 0 && tz !== season.overnightPauseTimezone) {
seasonUpdates.overnightPauseTimezone = tz;
}
}
}
if (season.status === "pre_draft") {
const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th",
];
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` };
}
if (points !== (season as unknown as Record<string, unknown>)[field]) {
(seasonUpdates as Record<string, unknown>)[field] = points;
}
}
}
}
}
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
const seasonAsMap = season as unknown as Record<string, unknown>;
const updatesAsMap = seasonUpdates as Record<string, unknown>;
if (draftFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_settings_changed",
details: {
changedFields: draftFields,
previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])),
newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])),
},
});
}
if (scoringChangedFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "scoring_rules_changed",
details: {
changedFields: scoringChangedFields,
previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])),
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])),
},
});
}
}
if (season.status === "pre_draft") {
const selectedSports = formData.getAll("sportsSeasons").map(String);
const currentSportIds = new Set(
season.seasonSports?.map((s) => s.sportsSeason.id) || []
);
const sportsToApply = await applyBracktSportsForSeason(season.id, selectedSports);
const newSportIds = new Set(sportsToApply);
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id));
const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id));
if (removedIds.length > 0 || addedIds.length > 0) {
const nameMap = new Map(
season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? []
);
const addedSeasons = await findSportsSeasonsByIds(addedIds);
const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`]));
const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "sports_changed",
details: {
added: addedNames,
removed: removedIds.map((id) => nameMap.get(id) ?? id),
},
});
}
}
if (typeof teamCount === "string") {
const newTeamCount = parseInt(teamCount, 10);
if (!isNaN(newTeamCount)) {
const teams = await findTeamsBySeasonId(season.id);
const currentTeamCount = teams.length;
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
if (newTeamCount < 6 || newTeamCount > 16) {
return { error: "Number of teams must be between 6 and 16" };
}
if (newTeamCount < teamsWithOwners) {
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
}
if (newTeamCount > currentTeamCount) {
const newNames = generateUniqueTeamNames(newTeamCount - currentTeamCount);
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: newNames[i],
ownerId: null as null,
})
);
const existingSlots = await findDraftSlotsBySeasonId(season.id);
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
const db = database();
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(schema.teams)
.values(teamsToAdd)
.returning();
await tx.insert(schema.draftSlots).values(
newTeams.map((team, i) => ({
seasonId: season.id,
teamId: team.id,
draftOrder: maxOrder + i + 1,
}))
);
});
await syncPrivateBracktParticipants(season.id);
} else if (newTeamCount < currentTeamCount) {
if (season.status !== "pre_draft") {
return { error: "Teams cannot be removed after the draft has started" };
}
const teamsToRemove = teams
.filter((t) => t.ownerId === null)
.slice(-(currentTeamCount - newTeamCount));
for (const team of teamsToRemove) {
await deleteTeam(team.id);
}
await syncPrivateBracktParticipants(season.id);
}
}
}
return redirect(`/leagues/${leagueId}?updated=true`);
} catch (error) {
logger.error("Error updating season settings:", error);
return { error: "Failed to update season settings. Please try again." };
}
}
if (intent === "add-commissioner") {
const newCommissionerUserId = formData.get("userId") as string;
if (!newCommissionerUserId) {
return { error: "User is required" };
}
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
if (alreadyCommissioner) {
return { error: "This user is already a commissioner" };
}
try {
await createCommissioner({ leagueId, userId: newCommissionerUserId });
return { success: true, message: "Commissioner added successfully" };
} catch (error) {
logger.error("Error adding commissioner:", error);
return { error: "Failed to add commissioner. Please try again." };
}
}
if (intent === "remove-commissioner") {
const commissionerUserId = formData.get("commissionerUserId") as string;
if (!commissionerUserId) {
return { error: "User is required" };
}
if (commissionerUserId === userId) {
return { error: "You cannot remove yourself as a commissioner" };
}
const count = await countCommissionersByLeagueId(leagueId);
if (count <= 1) {
return { error: "Cannot remove the last commissioner" };
}
try {
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
return { success: true, message: "Commissioner removed successfully" };
} catch (error) {
logger.error("Error removing commissioner:", error);
return { error: "Failed to remove commissioner. Please try again." };
}
}
return { error: "Invalid action" };
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { updateLeague } from '~/models/league';
import { findCurrentSeasonWithSports, updateSeason } from '~/models/season';
import { parseDraftSpeed } from '~/lib/draft-timer';
// Mock all models used in the settings update intent
vi.mock('~/models/league', () => ({
findLeagueById: vi.fn(),
updateLeague: vi.fn(),
@ -15,6 +15,7 @@ vi.mock('~/models/commissioner', () => ({
createCommissioner: vi.fn(),
countCommissionersByLeagueId: vi.fn(),
removeCommissionerByLeagueAndUser: vi.fn(),
hasCommissionerRecord: vi.fn(),
}));
vi.mock('~/models/season', () => ({
@ -27,7 +28,9 @@ vi.mock('~/models/team', () => ({
createManyTeams: vi.fn(),
deleteTeam: vi.fn(),
removeTeamOwner: vi.fn(),
assignTeamOwner: vi.fn(),
claimTeam: vi.fn(),
findTeamById: vi.fn(),
renameTeam: vi.fn(),
}));
const mockLeague = {
@ -75,6 +78,92 @@ const mockSeason = {
updatedAt: new Date('2025-01-01'),
};
// Mirrors the route-level settings access policy: the settings page requires a
// commissioner or admin record — creator status alone is not sufficient.
function canAccessLeagueSettings(opts: {
isAuthenticated: boolean;
leagueExists: boolean;
isCommissionerOrAdmin: boolean;
}): { allowed: true } | { allowed: false; status: 401 | 403 | 404 } {
if (!opts.isAuthenticated) return { allowed: false, status: 401 };
if (!opts.leagueExists) return { allowed: false, status: 404 };
if (!opts.isCommissionerOrAdmin) return { allowed: false, status: 403 };
return { allowed: true };
}
function canUpdateScoring(status: string) { return status === 'pre_draft'; }
function isValidScoringPoints(points: number) { return points >= 0 && points <= 1000; }
function isValidTeamCount(count: number) { return count >= 6 && count <= 16; }
function validateOwnerAssignment(opts: {
assignedUserId: string;
leagueMemberIds: string[];
existingOwnerIds: string[];
}): { allowed: true } | { allowed: false; error: string } {
if (!opts.leagueMemberIds.includes(opts.assignedUserId)) {
return { allowed: false, error: 'Only current league members can be assigned to teams' };
}
if (opts.existingOwnerIds.includes(opts.assignedUserId)) {
return { allowed: false, error: 'This user is already assigned to a team in this league' };
}
return { allowed: true };
}
// ---------------------------------------------------------------------------
// Access policy
// ---------------------------------------------------------------------------
describe('Settings Access Policy', () => {
it('rejects unauthenticated users', () => {
expect(canAccessLeagueSettings({
isAuthenticated: false,
leagueExists: true,
isCommissionerOrAdmin: false,
})).toEqual({ allowed: false, status: 401 });
});
it('rejects non-commissioner league members and creators', () => {
expect(canAccessLeagueSettings({
isAuthenticated: true,
leagueExists: true,
isCommissionerOrAdmin: false,
})).toEqual({ allowed: false, status: 403 });
});
it('allows commissioners and admins via the shared commissioner check', () => {
expect(canAccessLeagueSettings({
isAuthenticated: true,
leagueExists: true,
isCommissionerOrAdmin: true,
})).toEqual({ allowed: true });
});
});
// ---------------------------------------------------------------------------
// Owner assignment scope
// ---------------------------------------------------------------------------
describe('Settings Update - Owner Assignment Scope', () => {
it('rejects assigning someone who is not already attached to the league', () => {
expect(validateOwnerAssignment({
assignedUserId: 'outside-user',
leagueMemberIds: ['commissioner-1', 'owner-1'],
existingOwnerIds: ['owner-1'],
})).toEqual({
allowed: false,
error: 'Only current league members can be assigned to teams',
});
});
it('allows league commissioners without a team to be assigned', () => {
expect(validateOwnerAssignment({
assignedUserId: 'commissioner-1',
leagueMemberIds: ['commissioner-1', 'owner-1'],
existingOwnerIds: ['owner-1'],
})).toEqual({ allowed: true });
});
});
// ---------------------------------------------------------------------------
// Name validation
// ---------------------------------------------------------------------------
@ -100,16 +189,13 @@ describe('Settings Update - Name Validation', () => {
it('should reject a name shorter than 3 characters', () => {
const name = 'AB';
const trimmed = name.trim();
const tooShort = trimmed.length < 3;
const tooLong = trimmed.length > 50;
expect(tooShort || tooLong).toBe(true);
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
});
it('should reject a name longer than 50 characters', () => {
const name = 'A'.repeat(51);
const trimmed = name.trim();
const tooLong = trimmed.length > 50;
expect(tooLong).toBe(true);
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
});
it('should accept a name that is exactly 3 characters', () => {
@ -174,7 +260,6 @@ describe('Settings Update - League Save', () => {
const error = new Error('Database connection failed');
vi.mocked(updateLeague).mockRejectedValue(error);
// Simulate the try-catch around updateLeague in the action
let result: { error: string } | undefined;
try {
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false });
@ -189,11 +274,10 @@ describe('Settings Update - League Save', () => {
vi.mocked(updateLeague).mockRejectedValue(new Error('DB error'));
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(mockSeason);
// Action returns early on league save error; season functions should not run
try {
await updateLeague('league-1', { name: 'Test', isPublicDraftBoard: false });
} catch {
// early return would happen here
// early return happens here in the real action
}
expect(findCurrentSeasonWithSports).not.toHaveBeenCalled();
@ -233,104 +317,53 @@ describe('Settings Update - Season Save', () => {
it('should reject draft rounds below 1', () => {
const rounds = 0;
const invalid = rounds < 1 || rounds > 50;
expect(invalid).toBe(true);
expect(rounds < 1 || rounds > 50).toBe(true);
});
it('should reject draft rounds above 50', () => {
const rounds = 51;
const invalid = rounds < 1 || rounds > 50;
expect(invalid).toBe(true);
expect(rounds < 1 || rounds > 50).toBe(true);
});
it('should reject draft rounds less than the number of sports selected', () => {
const sportsCount = 5;
const draftRounds = 3;
const tooFew = draftRounds < sportsCount;
expect(tooFew).toBe(true);
expect(draftRounds < sportsCount).toBe(true);
});
it('should map "fast" draft speed to correct timer values', () => {
const draftSpeed: string = 'fast';
let initialTime: number;
let incrementTime: number;
switch (draftSpeed) {
case 'fast':
initialTime = 60;
incrementTime = 10;
break;
case 'standard':
initialTime = 120;
incrementTime = 15;
break;
case 'slow':
initialTime = 28800;
incrementTime = 3600;
break;
case 'very-slow':
initialTime = 43200;
incrementTime = 3600;
break;
default:
initialTime = 120;
incrementTime = 15;
}
expect(initialTime).toBe(60);
expect(incrementTime).toBe(10);
it('should map "fast" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('fast', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 60, draftIncrementTime: 10 });
});
it('should map "standard" draft speed to correct timer values', () => {
const draftSpeed: string = 'standard';
let initialTime = 120;
let incrementTime = 15;
if (draftSpeed === 'fast') { initialTime = 60; incrementTime = 10; }
else if (draftSpeed === 'slow') { initialTime = 28800; incrementTime = 3600; }
else if (draftSpeed === 'very-slow') { initialTime = 43200; incrementTime = 3600; }
expect(initialTime).toBe(120);
expect(incrementTime).toBe(15);
it('should map "standard" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('standard', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
});
it('should map "slow" draft speed to correct timer values', () => {
const draftSpeed: string = 'slow';
let initialTime = 120;
let incrementTime = 15;
if (draftSpeed === 'fast') { initialTime = 60; incrementTime = 10; }
else if (draftSpeed === 'slow') { initialTime = 28800; incrementTime = 3600; }
else if (draftSpeed === 'very-slow') { initialTime = 43200; incrementTime = 3600; }
expect(initialTime).toBe(28800);
expect(incrementTime).toBe(3600);
it('should map "slow" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('slow', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 28800, draftIncrementTime: 3600 });
});
it('should map "very-slow" draft speed to correct timer values', () => {
const draftSpeed: string = 'very-slow';
let initialTime = 120;
let incrementTime = 15;
if (draftSpeed === 'fast') { initialTime = 60; incrementTime = 10; }
else if (draftSpeed === 'slow') { initialTime = 28800; incrementTime = 3600; }
else if (draftSpeed === 'very-slow') { initialTime = 43200; incrementTime = 3600; }
expect(initialTime).toBe(43200);
expect(incrementTime).toBe(3600);
it('should map "very-slow" chess_clock speed to correct timer values', () => {
const result = parseDraftSpeed('very-slow', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 43200, draftIncrementTime: 3600 });
});
it('should fall back to standard speed for an unknown draft speed value', () => {
const draftSpeed: string = 'turbo-ultra';
let initialTime = 120;
let incrementTime = 15;
it('should fall back to standard speed for an unknown chess_clock speed value', () => {
const result = parseDraftSpeed('turbo-ultra', 'chess_clock');
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
});
if (draftSpeed === 'fast') { initialTime = 60; incrementTime = 10; }
else if (draftSpeed === 'slow') { initialTime = 28800; incrementTime = 3600; }
else if (draftSpeed === 'very-slow') { initialTime = 43200; incrementTime = 3600; }
it('should use raw seconds for standard timer mode', () => {
const result = parseDraftSpeed('90', 'standard');
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
});
expect(initialTime).toBe(120);
expect(incrementTime).toBe(15);
it('should fall back to 90s when standard mode speed is not a number', () => {
const result = parseDraftSpeed(null, 'standard');
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
});
it('should return a season-specific error message when updateSeason throws', async () => {
@ -360,26 +393,14 @@ describe('Settings Update - Season Save', () => {
});
it('should only update scoring fields when season status is pre_draft', () => {
const preDraftStatus: string = 'pre_draft';
const draftStatus: string = 'draft';
// Scoring changes allowed in pre_draft
const canUpdateScoring = preDraftStatus === 'pre_draft';
expect(canUpdateScoring).toBe(true);
// Scoring changes NOT allowed during draft
const canUpdateScoringDuringDraft = draftStatus === 'pre_draft';
expect(canUpdateScoringDuringDraft).toBe(false);
expect(canUpdateScoring('pre_draft')).toBe(true);
expect(canUpdateScoring('draft')).toBe(false);
});
it('should validate scoring points are between 0 and 1000', () => {
const validPoints = 500;
const tooLow = -1;
const tooHigh = 1001;
expect(validPoints >= 0 && validPoints <= 1000).toBe(true);
expect(tooLow >= 0 && tooLow <= 1000).toBe(false);
expect(tooHigh >= 0 && tooHigh <= 1000).toBe(false);
expect(isValidScoringPoints(500)).toBe(true);
expect(isValidScoringPoints(-1)).toBe(false);
expect(isValidScoringPoints(1001)).toBe(false);
});
});
@ -393,29 +414,23 @@ describe('Settings Update - Team Count Changes', () => {
});
it('should reject team count below 6', () => {
const newTeamCount = 5;
const invalid = newTeamCount < 6 || newTeamCount > 16;
expect(invalid).toBe(true);
expect(isValidTeamCount(5)).toBe(false);
});
it('should reject team count above 16', () => {
const newTeamCount = 17;
const invalid = newTeamCount < 6 || newTeamCount > 16;
expect(invalid).toBe(true);
expect(isValidTeamCount(17)).toBe(false);
});
it('should reject team count below the number of teams with owners', () => {
const teamsWithOwners = 8;
const newTeamCount = 6;
const belowOwnerCount = newTeamCount < teamsWithOwners;
expect(belowOwnerCount).toBe(true);
expect(newTeamCount < teamsWithOwners).toBe(true);
});
it('should allow a valid team count that is >= teams with owners', () => {
const teamsWithOwners = 6;
const newTeamCount = 10;
const valid = newTeamCount >= 6 && newTeamCount <= 16 && newTeamCount >= teamsWithOwners;
expect(valid).toBe(true);
expect(isValidTeamCount(newTeamCount) && newTeamCount >= teamsWithOwners).toBe(true);
});
it('should create new teams when increasing team count', async () => {
@ -441,3 +456,27 @@ describe('Settings Update - Team Count Changes', () => {
]);
});
});
// ---------------------------------------------------------------------------
// Draft order action responses
// ---------------------------------------------------------------------------
describe('Settings - Draft Order Action Responses', () => {
it('set-draft-order success includes section: draft-order', () => {
const result = { success: true, message: 'Draft order updated successfully', section: 'draft-order' as const };
expect(result.section).toBe('draft-order');
expect(result.success).toBe(true);
});
it('randomize-draft-order success includes section: draft-order', () => {
const result = { success: true, message: 'Draft order randomized successfully', section: 'draft-order' as const };
expect(result.section).toBe('draft-order');
expect(result.message).toContain('randomized');
});
it('draft-order messages should not be shown in global SettingsMessage', () => {
const actionData = { success: true, message: 'Draft order randomized successfully', section: 'draft-order' as const };
const isDraftOrderMessage = 'section' in actionData && actionData.section === 'draft-order';
expect(isDraftOrderMessage).toBe(true);
});
});