brackt/app/routes/leagues/$leagueId.settings.tsx
Chris Parsons 05fe1493a3
Refactor league settings into per-section components (#379)
* Refactor league settings into per-section components (#347)

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

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

* Fix Stop hook loop: suppress output on typecheck success

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

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

* Typescript fix

* Remove type asserting

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:19:50 -07:00

504 lines
23 KiB
TypeScript

import { useState, useEffect } from "react";
import { Form, useBeforeUnload, useBlocker, useNavigation } from "react-router";
import { format } from "date-fns";
import {
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 { cn } from "~/lib/utils";
import { SettingsMessage } from "~/components/league/settings/SettingsMessages";
import { LeagueSettingsHeader } from "~/components/league/settings/LeagueSettingsHeader";
import {
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";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
}
const BRACKT_SLUG = "brackt";
type LoaderSeason = Route.ComponentProps["loaderData"]["season"];
type LoaderSportsSeason = Route.ComponentProps["loaderData"]["allSportsSeasons"][number] & {
fantasySeasonId?: string | null;
};
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;
}
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, 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<SettingsSectionId>("league-basics");
const [pendingSection, setPendingSection] = useState<SettingsSectionId | null>(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<number>(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">(() => getInitialScoringPreset(season));
const [scoringRules, setScoringRules] = useState<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,
});
const initTimerMode = season?.draftTimerMode ?? "chess_clock";
const [draftSpeed, setDraftSpeed] = useState(() => {
if (initTimerMode === "standard") return season?.draftIncrementTime?.toString() ?? "90";
if (season?.draftInitialTime === 60 && season?.draftIncrementTime === 10) return "fast";
if (season?.draftInitialTime === 120 && season?.draftIncrementTime === 15) return "standard";
if (season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600) return "slow";
if (season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600) return "very-slow";
return "standard";
});
const [selectedSports, setSelectedSports] = useState<Set<string>>(
() => getInitialSelectedSports({ season, allSportsSeasons })
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
const [draftDate, setDraftDate] = useState<Date | undefined>(
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
);
const [draftTime, setDraftTime] = useState<string>(
season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""
);
// Draft order state (separate form, separate dirty flag)
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
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 savedOrder = draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id);
setDraftOrderTeams(savedOrder);
setHasDraftOrderChanges(false);
}, [draftSlots, teams]);
// 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]);
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 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 = findTemplateBrackt(allSportsSeasons);
if (!templateBrackt) return allSportsSeasons;
return allSportsSeasons.filter((ss) => !(ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss)));
})();
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 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 handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
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 (
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
<div className={cn(mobileView === "section" && "hidden lg:block")}>
<LeagueSettingsHeader leagueId={league.id} leagueName={league.name} />
</div>
{mobileView === "grid" && (
<div className="lg:hidden">
<SettingsMobileGridNav
sections={sectionGridData}
completedCount={completedSectionCount}
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
/>
</div>
)}
{mobileView === "section" && (
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
)}
<div className={cn("grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]", mobileView === "grid" && "hidden lg:grid")}>
<SettingsDesktopNav
sections={sectionGridData}
activeSection={activeSection}
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId)}
/>
<main className="space-y-6">
{showUpdateForm && (
<Form method="post" noValidate className="space-y-6">
<input type="hidden" name="intent" value="update" />
<LeagueBasicsSection
active={activeSection === "league-basics"}
season={season ?? null}
leagueName={leagueName}
onLeagueNameChange={(v) => { 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}
/>
<DraftSetupSection
active={activeSection === "draft-setup"}
canEditDraftRounds={canEditDraftRounds}
minRounds={minRounds}
recommendedRounds={recommendedRounds}
flexSpots={flexSpots}
draftRounds={draftRounds}
onDraftRoundsChange={(v) => { 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}
/>
<SportsSection
active={activeSection === "sports"}
canEditSports={canEditSports}
selectedSports={selectedSports}
displaySportsSeasons={displaySportsSeasons}
onSportToggle={(id) => { handleSportToggle(id); markDirty(); }}
onClearAll={() => { setSelectedSports(new Set()); markDirty(); }}
/>
<ScoringSection
active={activeSection === "scoring"}
canEditSports={canEditSports}
season={season ?? null}
scoringPreset={scoringPreset}
onScoringPresetChange={(v) => { setScoringPreset(v); markDirty(); }}
scoringRules={scoringRules}
onScoringRulesChange={(v) => { setScoringRules(v); markDirty(); }}
/>
<NotificationsSection
active={activeSection === "notifications"}
savedDiscordWebhookUrl={league.discordWebhookUrl}
discordWebhookUrl={discordWebhookUrl}
onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }}
/>
<SettingsMessage actionData={actionData} />
<SettingsSaveBar
hasUnsavedChanges={hasUnsavedSettingsChanges}
isSubmitting={isSubmittingSettings}
onReset={resetSettingsFormState}
/>
</Form>
)}
<LeaveSettingsDialog blocker={blocker} />
<SwitchSettingsSectionDialog
open={pendingSection !== null}
onOpenChange={(open) => { if (!open) setPendingSection(null); }}
onStay={() => setPendingSection(null)}
onDiscard={discardChangesAndSwitchSection}
/>
<DraftOrderSection
active={activeSection === "draft-order"}
draftOrderSet={draftOrderSet}
canEditDraftOrder={canEditDraftOrder}
hasUnsavedChanges={hasDraftOrderChanges}
draftOrderTeams={draftOrderTeams}
setDraftOrderTeams={setDraftOrderTeams}
onDirtyChange={setHasDraftOrderChanges}
onResetChanges={resetDraftOrder}
teams={teams}
ownerMap={ownerMap}
navigationState={navigation.state}
updateIntent={updateIntent}
actionData={actionData}
/>
<PeopleSection
active={activeSection === "people"}
teams={teams}
ownerMap={ownerMap}
commissioners={commissioners}
currentUserId={currentUserId}
teamCountValue={teamCountValue}
teamsWithOwners={teamsWithOwners}
leagueMembers={leagueMembers}
isAdmin={isAdmin}
navigationState={navigation.state}
/>
{league.discordWebhookUrl && activeSection === "notifications" && (
<Form method="post" className="rounded-lg border bg-card p-4">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">Test Discord notification</p>
<p className="text-sm text-muted-foreground">Sends a sample standings update to the saved webhook.</p>
</div>
<Button
type="submit"
variant="outline"
disabled={navigation.state === "submitting" && updateIntent === "test-discord-webhook"}
>
Send Test
</Button>
</div>
</Form>
)}
<HistoryDangerSection
active={activeSection === "history-danger"}
league={{ id: league.id, name: league.name }}
season={season ?? null}
isAdmin={isAdmin}
isDeleteDialogOpen={isDeleteDialogOpen}
onDeleteDialogOpenChange={setIsDeleteDialogOpen}
navigationState={navigation.state}
/>
</main>
</div>
</div>
);
}