brackt/app/routes/leagues/$leagueId.settings.tsx
Chris Parsons 6df4f86920
Improve flag config validation and remove settings completion tracking (#390)
* Fix mobile league settings: prevent sports scroll and remove completion checkmarks

- Add overflow-hidden to sport label text span so flex truncation is properly contained
- Add min-w-0 to SettingsSection header inner flex item to prevent title/description overflow
- Remove completion check marks from mobile grid nav buttons (league settings has no completion concept)
- Remove "X of Y set" counter from mobile nav header
- Remove isComplete field from SettingsGridSection type and all data

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

* Fix race window in user creation and restore FlagSvg safety fallback

auth.server.ts: switch generateId to application-generated UUIDs so the
ID is known before the insert, then move flagConfig assignment from
create.after (a separate UPDATE) to create.before so it lands in the
initial INSERT with no race window.

FlagSvg.tsx: validate the config with parseFlagConfig before rendering;
corrupt or missing data falls back to a neutral gray triband flag rather
than silently rendering blank SVG shapes.

https://claude.ai/code/session_01T7iTb9YZLuWJFtKV753tdB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-07 10:09:45 -07:00

502 lines
22 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" },
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds" },
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize" },
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected` },
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts" },
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams` },
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isDanger: true },
];
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}
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>
);
}