From 05fe1493a3e9fa6f935ce179520f257f484a44c2 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 5 May 2026 14:19:50 -0700 Subject: [PATCH] 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 * 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 * Typescript fix * Remove type asserting --------- Co-authored-by: Claude Sonnet 4.6 --- .claude/settings.json | 3 +- .../league/settings/DraftOrderSection.tsx | 169 ++ .../league/settings/DraftSetupSection.tsx | 190 ++ .../league/settings/HistoryDangerSection.tsx | 134 ++ .../league/settings/LeagueBasicsSection.tsx | 123 + .../league/settings/LeagueSettingsHeader.tsx | 22 + .../league/settings/NotificationsSection.tsx | 42 + .../league/settings/PeopleSection.tsx | 156 ++ .../league/settings/ScoringSection.tsx | 46 + .../league/settings/SettingsMessages.tsx | 55 + .../league/settings/SettingsNavigation.tsx | 127 + .../league/settings/SettingsSaveBar.tsx | 31 + .../league/settings/SettingsSection.tsx | 64 + .../league/settings/SortableDraftOrderRow.tsx | 64 + .../league/settings/SportsSection.tsx | 91 + .../league/settings/UnsavedChangesDialogs.tsx | 69 + .../leagues/$leagueId.settings.server.ts | 770 ++++++ app/routes/leagues/$leagueId.settings.tsx | 2076 +++-------------- .../leagues/__tests__/settings-update.test.ts | 267 ++- 19 files changed, 2694 insertions(+), 1805 deletions(-) create mode 100644 app/components/league/settings/DraftOrderSection.tsx create mode 100644 app/components/league/settings/DraftSetupSection.tsx create mode 100644 app/components/league/settings/HistoryDangerSection.tsx create mode 100644 app/components/league/settings/LeagueBasicsSection.tsx create mode 100644 app/components/league/settings/LeagueSettingsHeader.tsx create mode 100644 app/components/league/settings/NotificationsSection.tsx create mode 100644 app/components/league/settings/PeopleSection.tsx create mode 100644 app/components/league/settings/ScoringSection.tsx create mode 100644 app/components/league/settings/SettingsMessages.tsx create mode 100644 app/components/league/settings/SettingsNavigation.tsx create mode 100644 app/components/league/settings/SettingsSaveBar.tsx create mode 100644 app/components/league/settings/SettingsSection.tsx create mode 100644 app/components/league/settings/SortableDraftOrderRow.tsx create mode 100644 app/components/league/settings/SportsSection.tsx create mode 100644 app/components/league/settings/UnsavedChangesDialogs.tsx create mode 100644 app/routes/leagues/$leagueId.settings.server.ts diff --git a/.claude/settings.json b/.claude/settings.json index 76caf3d..84465da 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 } ] } diff --git a/app/components/league/settings/DraftOrderSection.tsx b/app/components/league/settings/DraftOrderSection.tsx new file mode 100644 index 0000000..90b00b5 --- /dev/null +++ b/app/components/league/settings/DraftOrderSection.tsx @@ -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>; + onDirtyChange: (dirty: boolean) => void; + onResetChanges: () => void; + teams: DraftOrderTeam[]; + ownerMap: Record; + navigationState: "idle" | "loading" | "submitting"; + updateIntent: FormDataEntryValue | null | undefined; + actionData: SettingsActionData; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 6 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + return ( + Set : undefined} + className={active ? undefined : "hidden"} + > + {!draftOrderSet && ( +
+
+

+ The draft cannot begin until a draft order is set. +

+

+ Randomize the order now, or drag and drop teams into position manually below. +

+
+ {canEditDraftOrder && ( +
+ + +
+ )} +
+ )} +
+

Manually set draft order

+
+ + { + 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); + }); + }} + > + +
+ {draftOrderTeams.map((teamId, index) => { + const team = teams.find((t) => t.id === teamId); + if (!team) return null; + + return ( + + ); + })} +
+
+
+ {canEditDraftOrder ? ( +
+ {hasUnsavedChanges && ( +

+ You have unsaved draft order changes. +

+ )} +
+ {hasUnsavedChanges && ( + + )} + +
+
+ ) : ( +

Draft order cannot be changed after the draft has started.

+ )} +
+
+ {draftOrderSet && canEditDraftOrder && ( +
+ + +
+ )} + +
+ ); +} diff --git a/app/components/league/settings/DraftSetupSection.tsx b/app/components/league/settings/DraftSetupSection.tsx new file mode 100644 index 0000000..eeaec80 --- /dev/null +++ b/app/components/league/settings/DraftSetupSection.tsx @@ -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 ( + {canEditDraftRounds ? "Editable" : "Locked"}} + className={active ? undefined : "hidden"} + > + + + + + + + + {draftDate && draftTime ? ( + + ) : ( + + )} + +
+
+
+
+
+ +

Minimum {minRounds}; recommended {recommendedRounds}.

+
+ {flexSpots} flex +
+ + {!canEditDraftRounds && ( +

Round count cannot be changed after draft starts.

+ )} +
+ +
+ +
+ + + + + + onDraftDateChange(date)} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + /> + + + onDraftTimeChange(e.target.value)} + disabled={!canEditDraftRounds} + /> +
+

+ Set this before starting the draft room. +

+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/app/components/league/settings/HistoryDangerSection.tsx b/app/components/league/settings/HistoryDangerSection.tsx new file mode 100644 index 0000000..4b6e0af --- /dev/null +++ b/app/components/league/settings/HistoryDangerSection.tsx @@ -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 ( + Careful} + className={cn("border-destructive/40", !active && "hidden")} + > +
+
+
+ +
+

Audit Log

+

View the full history of commissioner actions for this season.

+
+
+ +
+ + {isAdmin && season && ( +
+
+ +
+

Reset Draft

+

+ Delete all picks, queues, and timers. Draft order is preserved. +

+
+
+ + + + + + + Reset the draft? + + This deletes all draft picks, queues, and timers, then returns the season to pre-draft. This cannot be undone. + + + + Cancel +
+ + + Reset Draft + +
+
+
+
+
+ )} + +
+
+ +
+

Delete League

+

+ Permanently delete this league and all associated data. +

+
+
+ + + + + + + Are you absolutely sure? + + This permanently deletes {league.name} and all associated data including seasons, teams, and commissioners. + + + + Cancel +
+ + + Delete League + +
+
+
+
+
+
+
+ ); +} diff --git a/app/components/league/settings/LeagueBasicsSection.tsx b/app/components/league/settings/LeagueBasicsSection.tsx new file mode 100644 index 0000000..b888b6f --- /dev/null +++ b/app/components/league/settings/LeagueBasicsSection.tsx @@ -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 ( + Always editable} + className={active ? undefined : "hidden"} + > + +
+
+ + onLeagueNameChange(e.target.value)} + placeholder="Enter league name" + required + minLength={3} + maxLength={50} + /> +
+ +
+
+ +

Minimum {minTeamCount} based on teams with owners.

+
+ + {!canEditTeamCount && ( +

Locked after the draft starts.

+ )} +
+ {season?.inviteCode && ( +
+
+

Invite Link

+

+ {/* 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."; + })()} +

+
+
+ e.currentTarget.select()} className="text-sm" /> + +
+
+ )} +
+
+ ); +} diff --git a/app/components/league/settings/LeagueSettingsHeader.tsx b/app/components/league/settings/LeagueSettingsHeader.tsx new file mode 100644 index 0000000..cb3aef8 --- /dev/null +++ b/app/components/league/settings/LeagueSettingsHeader.tsx @@ -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 ( +
+
+

League Settings

+

{leagueName}

+
+ +
+ ); +} diff --git a/app/components/league/settings/NotificationsSection.tsx b/app/components/league/settings/NotificationsSection.tsx new file mode 100644 index 0000000..46e3453 --- /dev/null +++ b/app/components/league/settings/NotificationsSection.tsx @@ -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 ( + {savedDiscordWebhookUrl ? "Configured" : "Optional"}} + className={active ? undefined : "hidden"} + > +
+ + onDiscordWebhookUrlChange(e.target.value)} + placeholder="https://discord.com/api/webhooks/..." + /> +

+ Create a webhook in Discord under Server Settings, Integrations, Webhooks. +

+
+
+ ); +} diff --git a/app/components/league/settings/PeopleSection.tsx b/app/components/league/settings/PeopleSection.tsx new file mode 100644 index 0000000..c2a4f9d --- /dev/null +++ b/app/components/league/settings/PeopleSection.tsx @@ -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; + commissioners: Commissioner[]; + currentUserId: string; + teamCountValue: number; + teamsWithOwners: number; + leagueMembers: LeagueMember[]; + isAdmin: boolean; + navigationState: "idle" | "loading" | "submitting"; +}) { + return ( + {teamsWithOwners}/{teamCountValue} filled} + className={active ? undefined : "hidden"} + > +
+ {teams.map((team) => { + const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; + return ( +
+
+

{team.name}

+

+ {team.ownerId ? `Owner: ${ownerName || "Unknown"}` : "No owner"} +

+
+
+ {team.ownerId && ( +
+ + + +
+ )} + {isAdmin && ( +
+ + + + +
+ )} +
+
+ ); + })} +
+ +
+
+ +

Commissioners

+
+
+ {commissioners.map((commissioner) => ( +
+
+

+ {commissioner.userName} + {commissioner.userId === currentUserId && (you)} +

+ {!teams.some((t) => t.ownerId === commissioner.userId) && ( +

No team in current season

+ )} +
+ {commissioners.length > 1 && commissioner.userId !== currentUserId && ( +
+ + + +
+ )} +
+ ))} +
+ +
+ + + +
+
+
+ ); +} diff --git a/app/components/league/settings/ScoringSection.tsx b/app/components/league/settings/ScoringSection.tsx new file mode 100644 index 0000000..41579be --- /dev/null +++ b/app/components/league/settings/ScoringSection.tsx @@ -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 ( + {canEditSports ? "Editable" : "Locked"}} + className={active ? undefined : "hidden"} + > +
+ + {(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => ( + + ))} +
+
+ ); +} diff --git a/app/components/league/settings/SettingsMessages.tsx b/app/components/league/settings/SettingsMessages.tsx new file mode 100644 index 0000000..1d52a83 --- /dev/null +++ b/app/components/league/settings/SettingsMessages.tsx @@ -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 ( +
+ {actionData.error} +
+ ); + } + if (actionData.testSuccess) { + return ( +
+ Test notification sent to Discord. +
+ ); + } + if (actionData.success) { + return ( +
+ {actionData.message ?? "Settings updated successfully."} +
+ ); + } + return null; +} + +export function DraftOrderMessage({ actionData }: { actionData: SettingsActionData }) { + if (!actionData || actionData.section !== "draft-order") return null; + if (actionData.error) { + return ( +
+ {actionData.error} +
+ ); + } + if (actionData.success && actionData.message) { + return ( +
+ {actionData.message} +
+ ); + } + return null; +} diff --git a/app/components/league/settings/SettingsNavigation.tsx b/app/components/league/settings/SettingsNavigation.tsx new file mode 100644 index 0000000..ec8371c --- /dev/null +++ b/app/components/league/settings/SettingsNavigation.tsx @@ -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; + subtitle: string; + isComplete: boolean; + isDanger?: boolean; +}; + +export function SettingsMobileGridNav({ + sections, + completedCount, + onSectionChange, +}: { + sections: readonly SettingsGridSection[]; + completedCount: number; + onSectionChange: (sectionId: string) => void; +}) { + return ( +
+
+

+ Manage +

+

+ {completedCount} of {sections.length} set +

+
+
+ {sections.map((section) => ( + + ))} +
+
+ ); +} + +export function SettingsMobileSectionPill({ + onShowGrid, +}: { + onShowGrid: () => void; +}) { + return ( +
+ +
+ ); +} + +export function SettingsDesktopNav({ + sections, + activeSection, + onSectionChange, +}: { + sections: readonly SettingsGridSection[]; + activeSection: string; + onSectionChange: (sectionId: string) => void; +}) { + return ( + + ); +} diff --git a/app/components/league/settings/SettingsSaveBar.tsx b/app/components/league/settings/SettingsSaveBar.tsx new file mode 100644 index 0000000..6964b89 --- /dev/null +++ b/app/components/league/settings/SettingsSaveBar.tsx @@ -0,0 +1,31 @@ +import { Button } from "~/components/ui/button"; + +export function SettingsSaveBar({ + hasUnsavedChanges, + isSubmitting, + onReset, +}: { + hasUnsavedChanges: boolean; + isSubmitting: boolean; + onReset: () => void; +}) { + return ( +
+ {hasUnsavedChanges && ( +

+ You have unsaved settings changes. +

+ )} +
+ {hasUnsavedChanges && ( + + )} + +
+
+ ); +} diff --git a/app/components/league/settings/SettingsSection.tsx b/app/components/league/settings/SettingsSection.tsx new file mode 100644 index 0000000..090606c --- /dev/null +++ b/app/components/league/settings/SettingsSection.tsx @@ -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 ( + + {children} + + ); +} + +export function SettingsSection({ + id, + icon, + title, + description, + status, + children, + className, +}: { + id: string; + icon: ComponentType; + title: string; + description: string; + status?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+
+
+ +
+

{title}

+

{description}

+
+
+ {status &&
{status}
} +
+
+
{children}
+
+ ); +} diff --git a/app/components/league/settings/SortableDraftOrderRow.tsx b/app/components/league/settings/SortableDraftOrderRow.tsx new file mode 100644 index 0000000..1c1c0cd --- /dev/null +++ b/app/components/league/settings/SortableDraftOrderRow.tsx @@ -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 ( +
+ + +
+ {index + 1} +
+
+

{teamName}

+ {ownerName &&

{ownerName}

} +
+
+ ); +} diff --git a/app/components/league/settings/SportsSection.tsx b/app/components/league/settings/SportsSection.tsx new file mode 100644 index 0000000..fde375e --- /dev/null +++ b/app/components/league/settings/SportsSection.tsx @@ -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; + displaySportsSeasons: SportsSeason[]; + onSportToggle: (id: string) => void; + onClearAll: () => void; +}) { + return ( + {canEditSports ? "Editable" : "Locked"}} + className={active ? undefined : "hidden"} + > +
+
+ + {canEditSports && selectedSports.size > 0 && ( + + )} +
+
+ {displaySportsSeasons.length > 0 ? ( + displaySportsSeasons.map((ss) => { + const selected = selectedSports.has(ss.id); + return ( + + ); + }) + ) : ( +

No sports seasons available.

+ )} +
+
+
+ ); +} diff --git a/app/components/league/settings/UnsavedChangesDialogs.tsx b/app/components/league/settings/UnsavedChangesDialogs.tsx new file mode 100644 index 0000000..59075e3 --- /dev/null +++ b/app/components/league/settings/UnsavedChangesDialogs.tsx @@ -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 ( + + + + Leave without saving? + + You have unsaved settings changes. If you leave this page, those changes will be lost. + + + + blocker.reset?.()}> + Stay + + blocker.proceed?.()}> + Leave without saving + + + + + ); +} + +export function SwitchSettingsSectionDialog({ + open, + onOpenChange, + onStay, + onDiscard, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onStay: () => void; + onDiscard: () => void; +}) { + return ( + + + + Switch sections without saving? + + You have unsaved settings changes. Save before switching sections, or discard your edits to continue. + + + + + Stay + + + Discard changes + + + + + ); +} diff --git a/app/routes/leagues/$leagueId.settings.server.ts b/app/routes/leagues/$leagueId.settings.server.ts new file mode 100644 index 0000000..9eb0d64 --- /dev/null +++ b/app/routes/leagues/$leagueId.settings.server.ts @@ -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 => 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 => o !== null); + + const commishTimezone = (await findUserById(userId))?.timezone ?? null; + + return { + league, + season, + teams, + teamCount: teams.length, + teamsWithOwners, + allSportsSeasons: allSportsSeasons as Array, + 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 = {}; + + 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)[field]) { + (seasonUpdates as Record)[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; + const updatesAsMap = seasonUpdates as Record; + + 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" }; +} diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index f9e573a..7f77288 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -1,86 +1,41 @@ import { useState, useEffect } from "react"; -import { Form, Link, redirect, useNavigation } from "react-router"; -import { auth } from "~/lib/auth.server"; +import { Form, useBeforeUnload, useBlocker, useNavigation } from "react-router"; import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; -import { logger } from "~/lib/logger"; -import { findLeagueById, updateLeague, deleteLeague } from "~/models/league"; -import { sendStandingsUpdateNotification } from "~/services/discord"; import { - isCommissioner, - hasCommissionerRecord, - findCommissionersByLeagueId, - createCommissioner, - countCommissionersByLeagueId, - removeCommissionerByLeagueAndUser, -} from "~/models/commissioner"; -import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season"; -import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam, findTeamById, renameTeam } from "~/models/team"; -import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user"; -import { prependOwnerToTeamName, stripOwnerFromTeamName, generateUniqueTeamNames } from "~/utils/team-names"; -import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; -import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season"; -import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot"; -import { database } from "~/database/context"; -import * as schema from "~/database/schema"; -import { deleteAllDraftPicks } from "~/models/draft-pick"; -import { clearAllQueuesForSeason } from "~/models/draft-queue"; -import { deleteSeasonTimers } from "~/models/draft-timer"; -import { logCommissionerAction } from "~/models/audit-log"; -import { parseDraftSpeed } from "~/lib/draft-timer"; + AlertTriangle, + Bell, + ListOrdered, + Shield, + Swords, + TrendingUp, + Trophy, + Users, +} from "lucide-react"; import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import type { Route } from "./+types/$leagueId.settings"; +export { action, loader } from "./$leagueId.settings.server"; +import { OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { Calendar } from "~/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "~/components/ui/popover"; import { cn } from "~/lib/utils"; +import { SettingsMessage } from "~/components/league/settings/SettingsMessages"; +import { LeagueSettingsHeader } from "~/components/league/settings/LeagueSettingsHeader"; 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 { Checkbox } from "~/components/ui/checkbox"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "~/components/ui/alert-dialog"; -import { TimerModeSelector } from "~/components/league/TimerModeSelector"; -import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker"; -import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings"; -import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker"; + SettingsDesktopNav, + SettingsMobileGridNav, + SettingsMobileSectionPill, + type SettingsGridSection, +} from "~/components/league/settings/SettingsNavigation"; +import { SettingsSaveBar } from "~/components/league/settings/SettingsSaveBar"; +import { LeaveSettingsDialog, SwitchSettingsSectionDialog } from "~/components/league/settings/UnsavedChangesDialogs"; +import { DraftOrderSection } from "~/components/league/settings/DraftOrderSection"; +import { LeagueBasicsSection } from "~/components/league/settings/LeagueBasicsSection"; +import { DraftSetupSection } from "~/components/league/settings/DraftSetupSection"; +import { SportsSection } from "~/components/league/settings/SportsSection"; +import { ScoringSection } from "~/components/league/settings/ScoringSection"; +import { NotificationsSection } from "~/components/league/settings/NotificationsSection"; +import { PeopleSection } from "~/components/league/settings/PeopleSection"; +import { HistoryDangerSection } from "~/components/league/settings/HistoryDangerSection"; import { toast } from "sonner"; -import { - applyBracktSportsForSeason, - seedOrRepairBracktTemplate, - syncPrivateBracktParticipants, -} from "~/services/brackt.server"; - -function isValidHHMM(s: string): boolean { - return /^\d{2}:\d{2}$/.test(s); -} export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }]; @@ -88,805 +43,101 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { const BRACKT_SLUG = "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 { params } = args; - const { leagueId } = params; +type LoaderSeason = Route.ComponentProps["loaderData"]["season"]; +type LoaderSportsSeason = Route.ComponentProps["loaderData"]["allSportsSeasons"][number] & { + fantasySeasonId?: string | null; +}; - if (!userId) { - throw new Response("Unauthorized", { status: 401 }); - } - - // Fetch league - const league = await findLeagueById(leagueId); - - if (!league) { - throw new Response("League not found", { status: 404 }); - } - - // Check if user is authorized (creator or commissioner) - const userIsCommissioner = await isCommissioner(leagueId, userId); - const isCreator = league.createdBy === userId; - - if (!isCreator && !userIsCommissioner) { - throw new Response("Forbidden - You must be a commissioner to access settings", { - status: 403, - }); - } - - await seedOrRepairBracktTemplate(); - - // Get current season with sports and draftable seasons in parallel - 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) : []; - - // Count teams with owners - const teamsWithOwners = teams.filter(team => team.ownerId !== null).length; - - // Check if user is admin - const isAdmin = await isUserAdmin(userId); - - // Get all users (only for admins - used in team assignment dropdown) - const allUsers = isAdmin ? await findAllUsers() : []; - - // Get commissioners for this league with user info - const commissioners = await findCommissionersByLeagueId(leagueId); - - // Batch-fetch all users needed for commissioner and owner lookups in one query - 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 => o !== null); - const ownerMap = new Map(validOwners.map((o) => [o.id, o.name])); - - // League members (team owners) - available to all commissioners for adding co-commissioners - const leagueMembers = validOwners.map((o) => ({ - id: o.id, - name: o.name, - })); - - const commishTimezone = (await findUserById(userId))?.timezone ?? null; - - return { - league, - season, - teams, - teamCount: teams.length, - teamsWithOwners, - allSportsSeasons: allSportsSeasons as Array, - draftSlots, - isAdmin, - allUsers, - leagueMembers, - ownerMap: Object.fromEntries(ownerMap), - commissioners: commissionerUserData, - currentUserId: userId, - commishTimezone, +function getInitialScoringPreset(season: LoaderSeason) { + const rules: ScoringRules = { + pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, + pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, + pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, + pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, + pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, + pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, + pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, + pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, }; + if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => rules[k as keyof ScoringRules] === v)) return "brackt" as const; + if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => rules[k as keyof ScoringRules] === v)) return "omnifantasy" as const; + return "custom" as const; } -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 }); - } - - // Verify authorization - const league = await findLeagueById(leagueId); - if (!league) { - throw new Response("League not found", { status: 404 }); - } - - const userIsCommissioner = await isCommissioner(leagueId, userId); - const isCreator = league.createdBy === userId; - - if (!isCreator && !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." }; - } - } - - // Get current season to check status - 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" }; - } - - const teams = await findTeamsBySeasonId(season.id); - const teamIds = formData.getAll("teamOrder") as string[]; - - // Validate that all teams are included - if (teamIds.length !== teams.length) { - return { error: "All teams must be included in the draft order" }; - } - - // Validate that all team IDs are valid - 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" }; - } - } - - 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" }; - } - - if (intent === "randomize-draft-order") { - if (season.status !== "pre_draft") { - return { error: "Cannot modify draft order after draft has started" }; - } - - const teams = await findTeamsBySeasonId(season.id); - const teamIds = teams.map(t => t.id); - - await randomizeDraftOrder(season.id, teamIds); - - // Fetch the new order that was just written so we can log it accurately - 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" }; - } - - 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" }; - } - - // Check if user is admin (only admins can assign owners) - const isAdmin = await isUserAdmin(userId); - if (!isAdmin) { - return { error: "Only admins can assign team owners" }; - } - - // Look up user before assigning to fail fast and get their name - const assignedUser = await findUserById(assignedUserId); - if (!assignedUser) { - return { error: "User not found. Please try again." }; - } - - // Check if user is already assigned to a team in this season - const teams = await findTeamsBySeasonId(season.id); - const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId); - - if (userAlreadyHasTeam) { - return { error: "This user is already assigned to a team in this league" }; - } - - // Check if the target team already has an owner - 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") { - // Check if user is admin (only admins can reset draft) - const isAdmin = await isUserAdmin(userId); - if (!isAdmin) { - return { error: "Only admins can reset the draft" }; - } - - try { - // Delete all draft picks - await deleteAllDraftPicks(season.id); - - // Clear all draft queues - await clearAllQueuesForSeason(season.id); - - // Delete all draft timers - await deleteSeasonTimers(season.id); - - const previousPickNumber = season.currentPickNumber; - - // Set season status back to pre_draft and reset draft state (keeps draft order intact) - 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") { - // Delete the league - await deleteLeague(leagueId); - - // Redirect to home with success message - 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; - - // League-level fields (name, isPublicDraftBoard) are already saved above. - // Now handle season-specific updates. - try { - // Update season settings - const seasonUpdates: Partial = {}; - - // Handle draft rounds (only if value actually changed) - 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; - } - } - } - - // Handle draft date/time (only if value actually changed) - 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; - } - } - - // Set draft times from speed preset (only if draftSpeed was submitted and values changed) - 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; - } - } - - // Timer mode (only if submitted and changed) - if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) { - seasonUpdates.draftTimerMode = draftTimerMode; - } - - // Overnight pause settings - 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; - } - } - } - - // Handle scoring rules (only if in pre_draft status and values changed) - 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)[field]) { - (seasonUpdates as Record)[field] = points; - } - } - } - } - } - - // Update season if there are changes - 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; - const updatesAsMap = seasonUpdates as Record; - - 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]])), - }, - }); - } - } - - // Handle sports changes (only valid in pre_draft) - 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), - }, - }); - } - } - - // Handle team count changes if provided - 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; - - // Validate team count - 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)` }; - } - - // Add or remove teams as needed - 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, - }) - ); - - // Fetch existing slots before the transaction to determine append position - const existingSlots = await findDraftSlotsBySeasonId(season.id); - const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0); - - // Create teams and their draft slots atomically - 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" }; - } - // Remove teams without owners (from the end) - 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" }; - } - - // Check if user already has a commissioner record (don't use isCommissioner — it - // returns true for site admins, causing a false "already a commissioner" error) - 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" }; - } - - // Prevent self-removal - if (commissionerUserId === userId) { - return { error: "You cannot remove yourself as a commissioner" }; - } - - // Prevent removing the last 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" }; +function hasFantasySeasonId(season: object): boolean { + return "fantasySeasonId" in season && typeof season.fantasySeasonId === "string" && season.fantasySeasonId.length > 0; } +function findTemplateBrackt(seasons: LoaderSportsSeason[]): LoaderSportsSeason | undefined { + return seasons.find((ss) => ss.sport.slug === BRACKT_SLUG && !hasFantasySeasonId(ss)); +} + +function getInitialSelectedSports({ + season, + allSportsSeasons, +}: { + season: LoaderSeason; + allSportsSeasons: LoaderSportsSeason[]; +}) { + const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? []; + const linkedIds = linkedSeasons.map((ss) => ss.id); + const templateBrackt = findTemplateBrackt(allSportsSeasons); + const privateBrackt = linkedSeasons.find((ss) => ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss)); + + if (templateBrackt && privateBrackt) { + return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id))); + } + return new Set(linkedIds); +} + +const SETTINGS_SECTIONS = [ + { id: "league-basics", label: "League Basics" }, + { id: "draft-setup", label: "Draft Settings" }, + { id: "draft-order", label: "Draft Order" }, + { id: "sports", label: "Sports" }, + { id: "scoring", label: "Scoring" }, + { id: "notifications", label: "Notifications" }, + { id: "people", label: "People" }, + { id: "history-danger", label: "History & Danger" }, +] as const; + +type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; + +const UPDATE_SECTIONS: SettingsSectionId[] = [ + "league-basics", + "draft-setup", + "sports", + "scoring", + "notifications", +]; + export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { - const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone } = loaderData; + const { + league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, + draftSlots, isAdmin, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone, + } = loaderData; + const navigation = useNavigation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [mobileView, setMobileView] = useState<"grid" | "section">("grid"); + const [activeSection, setActiveSection] = useState("league-basics"); + const [pendingSection, setPendingSection] = useState(null); + + // Two separate dirty flags: one for the multi-section settings form, one for draft order + const [hasUnsavedSettingsChanges, setHasUnsavedSettingsChanges] = useState(false); + const [hasDraftOrderChanges, setHasDraftOrderChanges] = useState(false); + + // Settings form state + const [leagueName, setLeagueName] = useState(league.name); + const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard); + const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? ""); + const [teamCountValue, setTeamCountValue] = useState(teamCount); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none"); const [overnightStart, setOvernightStart] = useState(season?.overnightPauseStart ?? "23:00"); const [overnightEnd, setOvernightEnd] = useState(season?.overnightPauseEnd ?? "07:00"); const [overnightTimezone, setOvernightTimezone] = useState(season?.overnightPauseTimezone ?? commishTimezone ?? ""); - const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => { - const r: ScoringRules = { - pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, - pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, - pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, - pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, - pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, - pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, - pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, - pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, - }; - if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "brackt"; - if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "omnifantasy"; - return "custom"; - }); + const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => getInitialScoringPreset(season)); const [scoringRules, setScoringRules] = useState({ pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, @@ -907,101 +158,132 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone return "standard"; }); const [selectedSports, setSelectedSports] = useState>( - () => { - const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? []; - const linkedIds = linkedSeasons.map((ss) => ss.id); - const templateBrackt = allSportsSeasons.find((ss) => { - const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId; - return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId; - }); - const privateBrackt = linkedSeasons.find((ss) => { - const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId; - return ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId; - }); - - if (templateBrackt && privateBrackt) { - return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id))); - } - - return new Set(linkedIds); - } - ); - const [draftOrderTeams, setDraftOrderTeams] = useState( - draftSlots.length > 0 - ? draftSlots.map((slot) => slot.teamId) - : teams.map((team) => team.id) + () => getInitialSelectedSports({ season, allSportsSeasons }) ); const [draftRounds, setDraftRounds] = useState(season?.draftRounds || 20); const [draftDate, setDraftDate] = useState( season?.draftDateTime ? new Date(season.draftDateTime) : undefined ); const [draftTime, setDraftTime] = useState( - season?.draftDateTime - ? format(new Date(season.draftDateTime), "HH:mm") - : "" + season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "" ); - // Update draft order when loader data changes (after randomization) + + // Draft order state (separate form, separate dirty flag) + const [draftOrderTeams, setDraftOrderTeams] = useState( + draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id) + ); + + const [copied, setCopied] = useState(false); + + // Sync draft order from loader after randomization or other server updates useEffect(() => { - const newOrder = draftSlots.length > 0 + const savedOrder = draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id); - setDraftOrderTeams(newOrder); + setDraftOrderTeams(savedOrder); + setHasDraftOrderChanges(false); }, [draftSlots, teams]); - const canEditSports = season && season.status === "pre_draft"; - const canEditTeamCount = season && season.status === "pre_draft"; - const canEditDraftOrder = season && season.status === "pre_draft"; - const canEditDraftRounds = season && season.status === "pre_draft"; - const minTeamCount = Math.max(6, teamsWithOwners); + // Clear settings dirty flag after a successful (non-error) submission + useEffect(() => { + if (!hasUnsavedSettingsChanges || navigation.state !== "idle") return; + if (actionData && !("error" in actionData)) { + setHasUnsavedSettingsChanges(false); + } + }, [actionData, hasUnsavedSettingsChanges, navigation.state]); - // Calculate flex spots and minimum rounds + const canEditSports = season?.status === "pre_draft"; + const canEditTeamCount = season?.status === "pre_draft"; + const canEditDraftOrder = season?.status === "pre_draft"; + const canEditDraftRounds = season?.status === "pre_draft"; + const minTeamCount = Math.max(6, teamsWithOwners); const sportsCount = selectedSports.size; const minRounds = sportsCount; const recommendedRounds = Math.ceil(sportsCount * 1.25); const flexSpots = Math.max(0, draftRounds - sportsCount); - - const handleSportToggle = (sportId: string) => { - const newSelected = new Set(selectedSports); - if (newSelected.has(sportId)) { - newSelected.delete(sportId); - } else { - newSelected.add(sportId); - } - setSelectedSports(newSelected); - }; - - const handleDelete = () => { - setIsDeleteDialogOpen(false); - // The form submission will handle the actual deletion - }; + const updateIntent = navigation.formData?.get("intent"); + const draftOrderSet = draftSlots.length === teams.length && teams.length > 0; + const showUpdateForm = UPDATE_SECTIONS.includes(activeSection); + const isSubmittingSettings = navigation.state === "submitting" && updateIntent === "update"; + const isSubmittingDraftOrder = navigation.state === "submitting" && updateIntent === "set-draft-order"; + const shouldWarnUnsaved = (hasUnsavedSettingsChanges || hasDraftOrderChanges) && !isSubmittingSettings && !isSubmittingDraftOrder; + const blocker = useBlocker(shouldWarnUnsaved); + const inviteUrl = season?.inviteCode + ? `${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}` + : ""; const displaySportsSeasons = (() => { - const templateBrackt = allSportsSeasons.find((ss) => { - const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId; - return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId; - }); + const templateBrackt = findTemplateBrackt(allSportsSeasons); if (!templateBrackt) return allSportsSeasons; - return allSportsSeasons.filter((ss) => { - const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId; - return !(ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId); - }); + return allSportsSeasons.filter((ss) => !(ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss))); })(); - const moveDraftSlot = (fromIndex: number, toIndex: number) => { - const newOrder = [...draftOrderTeams]; - const [movedTeam] = newOrder.splice(fromIndex, 1); - newOrder.splice(toIndex, 0, movedTeam); - setDraftOrderTeams(newOrder); + useBeforeUnload( + (event) => { + if (!shouldWarnUnsaved) return; + event.preventDefault(); + event.returnValue = ""; + }, + { capture: true } + ); + + const markDirty = () => setHasUnsavedSettingsChanges(true); + + const resetSettingsFormState = () => { + setLeagueName(league.name); + setIsPublicDraftBoard(league.isPublicDraftBoard); + setDiscordWebhookUrl(league.discordWebhookUrl ?? ""); + setTeamCountValue(teamCount); + setTimerMode(season?.draftTimerMode ?? "chess_clock"); + setOvernightMode(season?.overnightPauseMode ?? "none"); + setOvernightStart(season?.overnightPauseStart ?? "23:00"); + setOvernightEnd(season?.overnightPauseEnd ?? "07:00"); + setOvernightTimezone(season?.overnightPauseTimezone ?? commishTimezone ?? ""); + setScoringPreset(getInitialScoringPreset(season)); + setScoringRules({ + pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, + pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, + pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, + pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, + pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, + pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, + pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, + pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, + }); + setSelectedSports(getInitialSelectedSports({ season, allSportsSeasons })); + setDraftRounds(season?.draftRounds || 20); + setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined); + setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""); + setHasUnsavedSettingsChanges(false); }; - const getTeamById = (teamId: string) => { - return teams.find((t) => t.id === teamId); + const resetDraftOrder = () => { + setDraftOrderTeams( + draftSlots.length > 0 ? draftSlots.map((s) => s.teamId) : teams.map((t) => t.id) + ); + setHasDraftOrderChanges(false); + }; + + const handleSectionChange = (sectionId: SettingsSectionId, { mobile = false } = {}) => { + if (sectionId === activeSection && !mobile) return; + if (hasUnsavedSettingsChanges) { + setPendingSection(sectionId); + return; + } + setActiveSection(sectionId); + if (mobile) setMobileView("section"); + }; + + const discardChangesAndSwitchSection = () => { + if (!pendingSection) return; + resetSettingsFormState(); + setActiveSection(pendingSection); + setPendingSection(null); + setMobileView("section"); }; - const [copied, setCopied] = useState(false); const handleCopyInviteLink = async () => { if (!season?.inviteCode) return; - const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`; try { await navigator.clipboard.writeText(inviteUrl); setCopied(true); @@ -1012,796 +294,210 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone } }; + const handleSportToggle = (sportId: string) => { + setSelectedSports((prev) => { + const next = new Set(prev); + if (next.has(sportId)) { + next.delete(sportId); + } else { + next.add(sportId); + } + return next; + }); + }; + + const sectionGridData: SettingsGridSection[] = [ + { id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join", isComplete: true }, + { id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds", isComplete: !!(draftDate && draftTime) }, + { id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize", isComplete: draftOrderSet }, + { id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected`, isComplete: selectedSports.size > 0 }, + { id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties", isComplete: true }, + { id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts", isComplete: !!league.discordWebhookUrl }, + { id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams`, isComplete: false }, + { id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isComplete: false, isDanger: true }, + ]; + const completedSectionCount = sectionGridData.filter((s) => s.isComplete).length; + return ( -
-
-
-

League Settings

- -
-

{league.name}

+
+
+
- {season?.inviteCode && ( - - - Invite Link - - Share this link to invite people to join your league - {teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`} - - - -
- e.currentTarget.select()} - /> - -
-

- Anyone with this link can join your league -

-
-
+ {mobileView === "grid" && ( +
+ handleSectionChange(id as SettingsSectionId, { mobile: true })} + /> +
)} -
- + {mobileView === "section" && ( + setMobileView("grid")} /> + )} - {/* General Settings */} - - - General Settings - - Update your league's basic information - - - -
- - + handleSectionChange(id as SettingsSectionId)} + /> + +
+ {showUpdateForm && ( + + + + { setLeagueName(v); markDirty(); }} + isPublicDraftBoard={isPublicDraftBoard} + onIsPublicDraftBoardChange={(v) => { setIsPublicDraftBoard(v); markDirty(); }} + teamCountValue={teamCountValue} + onTeamCountValueChange={(v) => { setTeamCountValue(v); markDirty(); }} + teamsWithOwners={teamsWithOwners} + canEditTeamCount={canEditTeamCount} + minTeamCount={minTeamCount} + inviteUrl={inviteUrl} + copied={copied} + onCopyInviteLink={handleCopyInviteLink} /> -
-
- { setDraftRounds(v); markDirty(); }} + draftDate={draftDate} + onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }} + draftTime={draftTime} + onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }} + timerMode={timerMode} + onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }} + draftSpeed={draftSpeed} + onDraftSpeedChange={(v) => { setDraftSpeed(v); markDirty(); }} + overnightMode={overnightMode} + onOvernightModeChange={(v) => { setOvernightMode(v); markDirty(); }} + overnightStart={overnightStart} + onOvernightStartChange={(v) => { setOvernightStart(v); markDirty(); }} + overnightEnd={overnightEnd} + onOvernightEndChange={(v) => { setOvernightEnd(v); markDirty(); }} + overnightTimezone={overnightTimezone} + onOvernightTimezoneChange={(v) => { setOvernightTimezone(v); markDirty(); }} + commishTimezone={commishTimezone} /> - -
-
- - -

- {canEditTeamCount - ? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)` - : "Team count cannot be changed after draft has started"} -

-
-
-
- - {/* Notifications */} - - - Notifications - - Get Discord alerts when standings change after scoring events - - - -
- - { handleSportToggle(id); markDirty(); }} + onClearAll={() => { setSelectedSports(new Set()); markDirty(); }} /> -

- Create a webhook in your Discord server under Server Settings → Integrations → Webhooks. -

-
- {league.discordWebhookUrl && ( - - - - - - )} - {actionData && "testSuccess" in actionData && actionData.testSuccess && ( -

Test notification sent to Discord!

- )} -
-
- {/* Draft Rounds */} - - - Draft Rounds - - {canEditDraftRounds - ? "Set the number of draft rounds for your league" - : "Draft rounds cannot be modified after the draft has started"} - - - -
- - -
-

- 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} -

- )} -
-
+ { setScoringPreset(v); markDirty(); }} + scoringRules={scoringRules} + onScoringRulesChange={(v) => { setScoringRules(v); markDirty(); }} + /> -
- -
- - - - - - date < new Date(new Date().setHours(0, 0, 0, 0))} - /> - - - setDraftTime(e.target.value)} - placeholder="Select time" - disabled={!canEditDraftRounds} - /> -
- {draftDate && draftTime && ( - - )} - {!draftDate && !draftTime && ( - - )} -

- {canEditDraftRounds - ? "You must set a draft date and time before starting the draft" - : "Draft date cannot be changed after draft has started"} -

-
+ { setDiscordWebhookUrl(v); markDirty(); }} + /> -
- - - -
+ -
- - - -
-
-
+ + + )} - {/* Overnight Pause */} - - - Overnight Pause - - Protect players from having their pick timer expire while they're asleep. - Autodraft will still fire for teams that have it enabled. - - - - - - - - - - + - {/* Scoring Rules */} - - - Scoring Rules - - Points awarded for each finishing position in each sports season. - - - - - {(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => ( - - ))} - - + { if (!open) setPendingSection(null); }} + onStay={() => setPendingSection(null)} + onDiscard={discardChangesAndSwitchSection} + /> - {/* Sports Seasons */} - - - Sports Seasons - - {canEditSports - ? "Select the sports seasons that teams can draft from" - : "Sports cannot be modified after the draft has started"} - - - - -
-
-

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

-
- -
- -
- {displaySportsSeasons.length > 0 ? ( - displaySportsSeasons.map((ss) => ( -
- handleSportToggle(ss.id)} - disabled={!canEditSports} - /> - -
- )) - ) : ( -

- No sports seasons available -

- )} + + + + + {league.discordWebhookUrl && activeSection === "notifications" && ( +
+ + +
+
+

Test Discord notification

+

Sends a sample standings update to the saved webhook.

-
-
- - - - {/* Save Button */} - {actionData?.error && ( -
- {actionData.error} -
- )} - - {actionData?.success && ( -
- Settings updated successfully! -
- )} - - - - -
- {/* Draft Order Management */} - - - Draft Order - - {canEditDraftOrder - ? "Set the draft order for your league. Drag teams to reorder or randomize." - : "Draft order cannot be changed after the draft has started"} - - - -
- {draftSlots.length === 0 && canEditDraftOrder && ( -
-

- Note: Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order. -

-
- )} - -
- - -
-
- {draftOrderTeams.map((teamId, index) => { - const team = getTeamById(teamId); - if (!team) return null; - - return ( -
- -
- {index + 1} -
-
-

{team.name}

- {team.ownerId && ( -

{ownerMap[team.ownerId] ?? "Unknown"}

- )} -
- {canEditDraftOrder && ( -
- - -
- )} -
- ); - })} -
-
- - {canEditDraftOrder && ( -
- -
- )} - - {actionData?.success && actionData?.message && ( -
- {actionData.message} -
- )} - - {actionData?.error && ( -
- {actionData.error} -
- )} -
- - {canEditDraftOrder && ( -
- - -
- )} -
-
-
- - {/* Team Management */} - - - Team Management - - Manage team ownership for this league - - - -
- {teams.map((team) => { - const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; - return ( -
-
-

{team.name}

-

- {team.ownerId ? ( - Owner: {ownerName || "Unknown"} - ) : ( - No owner - )} -

-
-
- {team.ownerId && ( -
- - - -
- )} - {isAdmin && ( -
- - - - -
- )} -
-
- ); - })} -
-
-
- - {/* Commissioner Management */} - - - Commissioner Management - - Commissioners can manage league settings and oversee the draft. They do not need to own a team. - - - - {actionData?.error && !actionData?.success && ( -
- {actionData.error} -
- )} - {actionData?.success && actionData?.message && ( -
- {actionData.message} -
- )} - -
- {commissioners.map((commissioner) => ( -
-
-

- {commissioner.userName} - {commissioner.userId === currentUserId && ( - (you) - )} -

- {!teams.some((t) => t.ownerId === commissioner.userId) && ( -

No team in current season

- )} -
- {commissioners.length > 1 && commissioner.userId !== currentUserId && ( -
- - - -
- )} -
- ))} -
- -
-

Add Commissioner

-
- - -
-
-
-
- - {/* Audit Log */} - - - Audit Log - - View the full history of commissioner actions for this season. All league members can see this log. - - - - - - - - {/* Danger Zone */} - - - Danger Zone - - Irreversible and destructive actions - - - - {/* Reset Draft - Admin Only */} - {isAdmin && season && ( -
-

Reset Draft

-

- Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved. -

- - - - - - - Reset the draft? - - This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status. - The draft order will remain intact. This action cannot be undone. - - - - Cancel -
- - - Reset Draft - -
-
-
-
- )} + + )} - {/* Delete League */} -
-

Delete League

-

- Permanently delete this league and all associated data. -

- - - - - - - Are you absolutely sure? - - This will permanently delete {league.name} and all - associated data including seasons, teams, and commissioners. This - action cannot be undone. - - - - Cancel -
- - - Delete League - -
-
-
-
-
-
-
+ +
); diff --git a/app/routes/leagues/__tests__/settings-update.test.ts b/app/routes/leagues/__tests__/settings-update.test.ts index 9ab7b3e..21ea7ac 100644 --- a/app/routes/leagues/__tests__/settings-update.test.ts +++ b/app/routes/leagues/__tests__/settings-update.test.ts @@ -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); + }); +});