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); + }); +});