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>
This commit is contained in:
parent
049ec8a596
commit
b1a1a472f5
18 changed files with 2686 additions and 1804 deletions
169
app/components/league/settings/DraftOrderSection.tsx
Normal file
169
app/components/league/settings/DraftOrderSection.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { Form } from "react-router";
|
||||||
|
import { ListOrdered } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
import { DraftOrderMessage, type SettingsActionData } from "./SettingsMessages";
|
||||||
|
import { SortableDraftOrderRow } from "./SortableDraftOrderRow";
|
||||||
|
|
||||||
|
type DraftOrderTeam = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DraftOrderSection({
|
||||||
|
active,
|
||||||
|
draftOrderSet,
|
||||||
|
canEditDraftOrder,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
draftOrderTeams,
|
||||||
|
setDraftOrderTeams,
|
||||||
|
onDirtyChange,
|
||||||
|
onResetChanges,
|
||||||
|
teams,
|
||||||
|
ownerMap,
|
||||||
|
navigationState,
|
||||||
|
updateIntent,
|
||||||
|
actionData,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
draftOrderSet: boolean;
|
||||||
|
canEditDraftOrder: boolean;
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
draftOrderTeams: string[];
|
||||||
|
setDraftOrderTeams: Dispatch<SetStateAction<string[]>>;
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
onResetChanges: () => void;
|
||||||
|
teams: DraftOrderTeam[];
|
||||||
|
ownerMap: Record<string, string | null>;
|
||||||
|
navigationState: "idle" | "loading" | "submitting";
|
||||||
|
updateIntent: FormDataEntryValue | null | undefined;
|
||||||
|
actionData: SettingsActionData;
|
||||||
|
}) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 6 },
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="draft-order"
|
||||||
|
icon={ListOrdered}
|
||||||
|
title="Draft Order"
|
||||||
|
description="Drag positions to reorder teams, or randomize the entire draft order. Saved independently from the rest of the settings form."
|
||||||
|
status={draftOrderSet ? <SettingsStatusPill tone="success">Set</SettingsStatusPill> : undefined}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
{!draftOrderSet && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
|
||||||
|
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
The draft cannot begin until a draft order is set.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
Randomize the order now, or drag and drop teams into position manually below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEditDraftOrder && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="randomize-draft-order" />
|
||||||
|
<Button type="submit" className="w-full" disabled={navigationState === "submitting" && updateIntent === "randomize-draft-order"}>
|
||||||
|
{navigationState === "submitting" && updateIntent === "randomize-draft-order" ? "Randomizing..." : "Randomize Draft Order"}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-muted-foreground">Manually set draft order</h3>
|
||||||
|
<Form method="post" className="space-y-3">
|
||||||
|
<input type="hidden" name="intent" value="set-draft-order" />
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={({ active: activeItem, over }) => {
|
||||||
|
if (!over || activeItem.id === over.id) return;
|
||||||
|
|
||||||
|
setDraftOrderTeams((current) => {
|
||||||
|
const oldIndex = current.indexOf(String(activeItem.id));
|
||||||
|
const newIndex = current.indexOf(String(over.id));
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return current;
|
||||||
|
onDirtyChange(true);
|
||||||
|
return arrayMove(current, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={draftOrderTeams} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{draftOrderTeams.map((teamId, index) => {
|
||||||
|
const team = teams.find((t) => t.id === teamId);
|
||||||
|
if (!team) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableDraftOrderRow
|
||||||
|
key={teamId}
|
||||||
|
teamId={teamId}
|
||||||
|
index={index}
|
||||||
|
teamName={team.name}
|
||||||
|
ownerName={team.ownerId ? (ownerMap[team.ownerId] ?? "Unknown") : null}
|
||||||
|
disabled={!canEditDraftOrder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
{canEditDraftOrder ? (
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<p className="mb-3 text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
You have unsaved draft order changes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<Button type="button" variant="outline" className="sm:w-48" onClick={onResetChanges}>
|
||||||
|
Reset Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="flex-1" disabled={navigationState === "submitting" && updateIntent === "set-draft-order"}>
|
||||||
|
{navigationState === "submitting" && updateIntent === "set-draft-order" ? "Saving..." : "Save Draft Order"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Draft order cannot be changed after the draft has started.</p>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
{draftOrderSet && canEditDraftOrder && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="randomize-draft-order" />
|
||||||
|
<Button type="submit" variant="outline" className="w-full" disabled={navigationState === "submitting" && updateIntent === "randomize-draft-order"}>
|
||||||
|
{navigationState === "submitting" && updateIntent === "randomize-draft-order" ? "Randomizing..." : "Randomize Draft Order"}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
<DraftOrderMessage actionData={actionData} />
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
app/components/league/settings/DraftSetupSection.tsx
Normal file
190
app/components/league/settings/DraftSetupSection.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Swords, CalendarIcon } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Calendar } from "~/components/ui/calendar";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { TimerModeSelector } from "~/components/league/TimerModeSelector";
|
||||||
|
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
|
||||||
|
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
|
||||||
|
import { StepperInput } from "~/components/league/StepperInput";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
export function DraftSetupSection({
|
||||||
|
active,
|
||||||
|
canEditDraftRounds,
|
||||||
|
minRounds,
|
||||||
|
recommendedRounds,
|
||||||
|
flexSpots,
|
||||||
|
draftRounds,
|
||||||
|
onDraftRoundsChange,
|
||||||
|
draftDate,
|
||||||
|
onDraftDateChange,
|
||||||
|
draftTime,
|
||||||
|
onDraftTimeChange,
|
||||||
|
timerMode,
|
||||||
|
onTimerModeChange,
|
||||||
|
draftSpeed,
|
||||||
|
onDraftSpeedChange,
|
||||||
|
overnightMode,
|
||||||
|
onOvernightModeChange,
|
||||||
|
overnightStart,
|
||||||
|
onOvernightStartChange,
|
||||||
|
overnightEnd,
|
||||||
|
onOvernightEndChange,
|
||||||
|
overnightTimezone,
|
||||||
|
onOvernightTimezoneChange,
|
||||||
|
commishTimezone,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
canEditDraftRounds: boolean;
|
||||||
|
minRounds: number;
|
||||||
|
recommendedRounds: number;
|
||||||
|
flexSpots: number;
|
||||||
|
draftRounds: number;
|
||||||
|
onDraftRoundsChange: (v: number) => void;
|
||||||
|
draftDate: Date | undefined;
|
||||||
|
onDraftDateChange: (v: Date | undefined) => void;
|
||||||
|
draftTime: string;
|
||||||
|
onDraftTimeChange: (v: string) => void;
|
||||||
|
timerMode: "chess_clock" | "standard";
|
||||||
|
onTimerModeChange: (v: "chess_clock" | "standard") => void;
|
||||||
|
draftSpeed: string;
|
||||||
|
onDraftSpeedChange: (v: string) => void;
|
||||||
|
overnightMode: "none" | "league" | "per_user";
|
||||||
|
onOvernightModeChange: (v: "none" | "league" | "per_user") => void;
|
||||||
|
overnightStart: string;
|
||||||
|
onOvernightStartChange: (v: string) => void;
|
||||||
|
overnightEnd: string;
|
||||||
|
onOvernightEndChange: (v: string) => void;
|
||||||
|
overnightTimezone: string;
|
||||||
|
onOvernightTimezoneChange: (v: string) => void;
|
||||||
|
commishTimezone: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="draft-setup"
|
||||||
|
icon={Swords}
|
||||||
|
title="Draft Settings"
|
||||||
|
description="Configure the draft format, timing, overnight protections, and pick order."
|
||||||
|
status={<SettingsStatusPill tone={canEditDraftRounds ? "success" : "locked"}>{canEditDraftRounds ? "Editable" : "Locked"}</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="draftRounds" value={draftRounds} />
|
||||||
|
<input type="hidden" name="draftTimerMode" value={timerMode} />
|
||||||
|
<input type="hidden" name="draftSpeed" value={draftSpeed} />
|
||||||
|
<input type="hidden" name="overnightPauseMode" value={overnightMode} />
|
||||||
|
<input type="hidden" name="overnightPauseStart" value={overnightStart} />
|
||||||
|
<input type="hidden" name="overnightPauseEnd" value={overnightEnd} />
|
||||||
|
<input type="hidden" name="overnightPauseTimezone" value={overnightTimezone} />
|
||||||
|
{draftDate && draftTime ? (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="draftDateTime"
|
||||||
|
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input type="hidden" name="draftDateTime" value="" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="rounded-lg border p-5 sm:p-6">
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Draft Rounds</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">Minimum {minRounds}; recommended {recommendedRounds}.</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{flexSpots} flex</Badge>
|
||||||
|
</div>
|
||||||
|
<StepperInput
|
||||||
|
value={draftRounds}
|
||||||
|
min={canEditDraftRounds ? Math.max(1, minRounds) : draftRounds}
|
||||||
|
max={canEditDraftRounds ? 50 : draftRounds}
|
||||||
|
onChange={onDraftRoundsChange}
|
||||||
|
decrementLabel="Decrease draft rounds"
|
||||||
|
incrementLabel="Increase draft rounds"
|
||||||
|
/>
|
||||||
|
{!canEditDraftRounds && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">Round count cannot be changed after draft starts.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-5 sm:p-6">
|
||||||
|
<Label htmlFor="draftDate">Draft Date & Time</Label>
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn("justify-start text-left font-normal", !draftDate && "text-muted-foreground")}
|
||||||
|
disabled={!canEditDraftRounds}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={draftDate}
|
||||||
|
onSelect={(date) => onDraftDateChange(date)}
|
||||||
|
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={draftTime}
|
||||||
|
onChange={(e) => onDraftTimeChange(e.target.value)}
|
||||||
|
disabled={!canEditDraftRounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
Set this before starting the draft room.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 rounded-lg border p-5 sm:p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Timer Mode</Label>
|
||||||
|
<TimerModeSelector
|
||||||
|
value={timerMode}
|
||||||
|
onChange={onTimerModeChange}
|
||||||
|
disabled={!canEditDraftRounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t pt-8 space-y-4">
|
||||||
|
<Label>Draft Speed</Label>
|
||||||
|
<DraftSpeedPicker
|
||||||
|
timerMode={timerMode}
|
||||||
|
value={draftSpeed}
|
||||||
|
onChange={onDraftSpeedChange}
|
||||||
|
disabled={!canEditDraftRounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-5 sm:p-6">
|
||||||
|
<OvernightPauseSettings
|
||||||
|
show={true}
|
||||||
|
mode={overnightMode}
|
||||||
|
onModeChange={onOvernightModeChange}
|
||||||
|
start={overnightStart}
|
||||||
|
onStartChange={onOvernightStartChange}
|
||||||
|
end={overnightEnd}
|
||||||
|
onEndChange={onOvernightEndChange}
|
||||||
|
timezone={overnightTimezone}
|
||||||
|
onTimezoneChange={onOvernightTimezoneChange}
|
||||||
|
commishTimezone={commishTimezone}
|
||||||
|
disabled={!canEditDraftRounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
app/components/league/settings/HistoryDangerSection.tsx
Normal file
134
app/components/league/settings/HistoryDangerSection.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { Activity, RotateCcw, ShieldAlert } from "lucide-react";
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
export function HistoryDangerSection({
|
||||||
|
active,
|
||||||
|
league,
|
||||||
|
season,
|
||||||
|
isAdmin,
|
||||||
|
isDeleteDialogOpen,
|
||||||
|
onDeleteDialogOpenChange,
|
||||||
|
navigationState,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
league: { id: string; name: string };
|
||||||
|
season: { id: string } | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isDeleteDialogOpen: boolean;
|
||||||
|
onDeleteDialogOpenChange: (open: boolean) => void;
|
||||||
|
navigationState: "idle" | "loading" | "submitting";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="history-danger"
|
||||||
|
icon={ShieldAlert}
|
||||||
|
title="History & Danger"
|
||||||
|
description="Audit history is informational. Reset and delete actions are intentionally separated from normal settings."
|
||||||
|
status={<SettingsStatusPill tone="warning">Careful</SettingsStatusPill>}
|
||||||
|
className={cn("border-destructive/40", !active && "hidden")}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Activity className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Audit Log</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">View the full history of commissioner actions for this season.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="sm:w-44" asChild>
|
||||||
|
<Link to={`/leagues/${league.id}/audit-log`}>View Audit Log</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && season && (
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border border-destructive/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<RotateCcw className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Reset Draft</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Delete all picks, queues, and timers. Draft order is preserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="sm:w-44">
|
||||||
|
Reset Draft
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This deletes all draft picks, queues, and timers, then returns the season to pre-draft. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="reset-draft" />
|
||||||
|
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
||||||
|
Reset Draft
|
||||||
|
</AlertDialogAction>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg border border-destructive/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Delete League</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Permanently delete this league and all associated data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={onDeleteDialogOpenChange}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" className="sm:w-44" disabled={navigationState === "submitting"}>
|
||||||
|
Delete League
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This permanently deletes <strong>{league.name}</strong> and all associated data including seasons, teams, and commissioners.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
||||||
|
Delete League
|
||||||
|
</AlertDialogAction>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
app/components/league/settings/LeagueBasicsSection.tsx
Normal file
123
app/components/league/settings/LeagueBasicsSection.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { StepperInput } from "~/components/league/StepperInput";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
export function LeagueBasicsSection({
|
||||||
|
active,
|
||||||
|
season,
|
||||||
|
leagueName,
|
||||||
|
onLeagueNameChange,
|
||||||
|
isPublicDraftBoard,
|
||||||
|
onIsPublicDraftBoardChange,
|
||||||
|
teamCountValue,
|
||||||
|
onTeamCountValueChange,
|
||||||
|
teamsWithOwners,
|
||||||
|
canEditTeamCount,
|
||||||
|
minTeamCount,
|
||||||
|
inviteUrl,
|
||||||
|
copied,
|
||||||
|
onCopyInviteLink,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
season: { inviteCode: string | null } | null;
|
||||||
|
leagueName: string;
|
||||||
|
onLeagueNameChange: (v: string) => void;
|
||||||
|
isPublicDraftBoard: boolean;
|
||||||
|
onIsPublicDraftBoardChange: (v: boolean) => void;
|
||||||
|
teamCountValue: number;
|
||||||
|
onTeamCountValueChange: (v: number) => void;
|
||||||
|
teamsWithOwners: number;
|
||||||
|
canEditTeamCount: boolean;
|
||||||
|
minTeamCount: number;
|
||||||
|
inviteUrl: string;
|
||||||
|
copied: boolean;
|
||||||
|
onCopyInviteLink: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="league-basics"
|
||||||
|
icon={Trophy}
|
||||||
|
title="League Basics"
|
||||||
|
description="High-level league identity and public visibility controls."
|
||||||
|
status={<SettingsStatusPill tone="success">Always editable</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="teamCount" value={teamCountValue} />
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">League Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={leagueName}
|
||||||
|
onChange={(e) => onLeagueNameChange(e.target.value)}
|
||||||
|
placeholder="Enter league name"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor="isPublicDraftBoard"
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isPublicDraftBoard"
|
||||||
|
name="isPublicDraftBoard"
|
||||||
|
checked={isPublicDraftBoard}
|
||||||
|
onChange={(e) => onIsPublicDraftBoardChange(e.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium">Public draft board</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Allow anyone with the link to view the board without logging in.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<Label>Number of Teams</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">Minimum {minTeamCount} based on teams with owners.</p>
|
||||||
|
</div>
|
||||||
|
<StepperInput
|
||||||
|
value={teamCountValue}
|
||||||
|
min={canEditTeamCount ? minTeamCount : teamCountValue}
|
||||||
|
max={canEditTeamCount ? 16 : teamCountValue}
|
||||||
|
onChange={onTeamCountValueChange}
|
||||||
|
decrementLabel="Decrease team count"
|
||||||
|
incrementLabel="Increase team count"
|
||||||
|
/>
|
||||||
|
{!canEditTeamCount && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">Locked after the draft starts.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{season?.inviteCode && (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="font-medium">Invite Link</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{(() => {
|
||||||
|
const open = teamCountValue - teamsWithOwners;
|
||||||
|
return open > 0
|
||||||
|
? `${open} open spot${open === 1 ? "" : "s"} available.`
|
||||||
|
: "No open team spots right now.";
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input readOnly value={inviteUrl} onClick={(e) => e.currentTarget.select()} className="text-sm" />
|
||||||
|
<Button type="button" variant={copied ? "default" : "outline"} onClick={onCopyInviteLink}>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/components/league/settings/LeagueSettingsHeader.tsx
Normal file
22
app/components/league/settings/LeagueSettingsHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
export function LeagueSettingsHeader({
|
||||||
|
leagueId,
|
||||||
|
leagueName,
|
||||||
|
}: {
|
||||||
|
leagueId: string;
|
||||||
|
leagueName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold sm:text-4xl">League Settings</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">{leagueName}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to={`/leagues/${leagueId}`}>Back to League</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/components/league/settings/NotificationsSection.tsx
Normal file
42
app/components/league/settings/NotificationsSection.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
export function NotificationsSection({
|
||||||
|
active,
|
||||||
|
savedDiscordWebhookUrl,
|
||||||
|
discordWebhookUrl,
|
||||||
|
onDiscordWebhookUrlChange,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
savedDiscordWebhookUrl: string | null;
|
||||||
|
discordWebhookUrl: string;
|
||||||
|
onDiscordWebhookUrlChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="notifications"
|
||||||
|
icon={Bell}
|
||||||
|
title="Notifications"
|
||||||
|
description="Send Discord alerts when standings change after scoring events."
|
||||||
|
status={<SettingsStatusPill tone={savedDiscordWebhookUrl ? "success" : "warning"}>{savedDiscordWebhookUrl ? "Configured" : "Optional"}</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
|
||||||
|
<Input
|
||||||
|
id="discordWebhookUrl"
|
||||||
|
name="discordWebhookUrl"
|
||||||
|
type="url"
|
||||||
|
value={discordWebhookUrl}
|
||||||
|
onChange={(e) => onDiscordWebhookUrlChange(e.target.value)}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create a webhook in Discord under Server Settings, Integrations, Webhooks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
app/components/league/settings/PeopleSection.tsx
Normal file
156
app/components/league/settings/PeopleSection.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { Crown, Users } from "lucide-react";
|
||||||
|
import { Form } from "react-router";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
type Team = { id: string; name: string; ownerId: string | null };
|
||||||
|
type Commissioner = { id: string; userId: string; userName: string };
|
||||||
|
type LeagueMember = { id: string; name: string | null };
|
||||||
|
|
||||||
|
export function PeopleSection({
|
||||||
|
active,
|
||||||
|
teams,
|
||||||
|
ownerMap,
|
||||||
|
commissioners,
|
||||||
|
currentUserId,
|
||||||
|
teamCountValue,
|
||||||
|
teamsWithOwners,
|
||||||
|
leagueMembers,
|
||||||
|
isAdmin,
|
||||||
|
navigationState,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
teams: Team[];
|
||||||
|
ownerMap: Record<string, string | null>;
|
||||||
|
commissioners: Commissioner[];
|
||||||
|
currentUserId: string;
|
||||||
|
teamCountValue: number;
|
||||||
|
teamsWithOwners: number;
|
||||||
|
leagueMembers: LeagueMember[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
navigationState: "idle" | "loading" | "submitting";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="people"
|
||||||
|
icon={Users}
|
||||||
|
title="People"
|
||||||
|
description="Manage team owners and commissioners. Team capacity is saved from League Basics."
|
||||||
|
status={<SettingsStatusPill tone="default">{teamsWithOwners}/{teamCountValue} filled</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{teams.map((team) => {
|
||||||
|
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
||||||
|
return (
|
||||||
|
<div key={team.id} className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{team.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{team.ownerId ? `Owner: ${ownerName || "Unknown"}` : "No owner"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{team.ownerId && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="remove-team-owner" />
|
||||||
|
<input type="hidden" name="teamId" value={team.id} />
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
|
||||||
|
Remove Owner
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Form method="post" className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input type="hidden" name="intent" value="assign-team-owner" />
|
||||||
|
<input type="hidden" name="teamId" value={team.id} />
|
||||||
|
<Select name="userId" required>
|
||||||
|
<SelectTrigger className="h-9 sm:w-[190px]">
|
||||||
|
<SelectValue placeholder="Select user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{leagueMembers.map((member) => {
|
||||||
|
const userOwnsTeam = teams.some((t) => t.ownerId === member.id);
|
||||||
|
return (
|
||||||
|
<SelectItem key={member.id} value={member.id} disabled={userOwnsTeam}>
|
||||||
|
{member.name || "Unknown"}
|
||||||
|
{userOwnsTeam && " (already has team)"}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-5">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h3 className="font-semibold">Commissioners</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{commissioners.map((commissioner) => (
|
||||||
|
<div key={commissioner.id} className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{commissioner.userName}
|
||||||
|
{commissioner.userId === currentUserId && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
|
||||||
|
</p>
|
||||||
|
{!teams.some((t) => t.ownerId === commissioner.userId) && (
|
||||||
|
<p className="text-xs text-muted-foreground">No team in current season</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="remove-commissioner" />
|
||||||
|
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={navigationState === "submitting"}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form method="post" className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<input type="hidden" name="intent" value="add-commissioner" />
|
||||||
|
<Select name="userId" required>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Add commissioner from league members" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{leagueMembers.map((member) => {
|
||||||
|
const alreadyCommissioner = commissioners.some((c) => c.userId === member.id);
|
||||||
|
return (
|
||||||
|
<SelectItem key={member.id} value={member.id} disabled={alreadyCommissioner}>
|
||||||
|
{member.name || "Unknown"}
|
||||||
|
{alreadyCommissioner && " (already commissioner)"}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit" variant="outline" disabled={navigationState === "submitting"}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/components/league/settings/ScoringSection.tsx
Normal file
46
app/components/league/settings/ScoringSection.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { TrendingUp } from "lucide-react";
|
||||||
|
import { type ScoringRules } from "~/lib/scoring-types";
|
||||||
|
import { ScoringPresetPicker } from "~/components/league/ScoringPresetPicker";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
export function ScoringSection({
|
||||||
|
active,
|
||||||
|
canEditSports,
|
||||||
|
season,
|
||||||
|
scoringPreset,
|
||||||
|
onScoringPresetChange,
|
||||||
|
scoringRules,
|
||||||
|
onScoringRulesChange,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
canEditSports: boolean;
|
||||||
|
season: { status: string } | null;
|
||||||
|
scoringPreset: "brackt" | "omnifantasy" | "custom";
|
||||||
|
onScoringPresetChange: (v: "brackt" | "omnifantasy" | "custom") => void;
|
||||||
|
scoringRules: ScoringRules;
|
||||||
|
onScoringRulesChange: (v: ScoringRules) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="scoring"
|
||||||
|
icon={TrendingUp}
|
||||||
|
title="Scoring"
|
||||||
|
description="Configure placement scoring for drafted sports. Scoring locks when the draft starts."
|
||||||
|
status={<SettingsStatusPill tone={canEditSports ? "success" : "locked"}>{canEditSports ? "Editable" : "Locked"}</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border p-5 sm:p-6">
|
||||||
|
<ScoringPresetPicker
|
||||||
|
preset={scoringPreset}
|
||||||
|
onPresetChange={onScoringPresetChange}
|
||||||
|
rules={scoringRules}
|
||||||
|
onRulesChange={onScoringRulesChange}
|
||||||
|
disabled={!season || season.status !== "pre_draft"}
|
||||||
|
/>
|
||||||
|
{(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => (
|
||||||
|
<input key={k} type="hidden" name={k} value={v} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/components/league/settings/SettingsMessages.tsx
Normal file
55
app/components/league/settings/SettingsMessages.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
export type SettingsActionData =
|
||||||
|
| {
|
||||||
|
section?: string;
|
||||||
|
error?: string;
|
||||||
|
testSuccess?: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export function SettingsMessage({ actionData }: { actionData: SettingsActionData }) {
|
||||||
|
if (!actionData) return null;
|
||||||
|
if (actionData.section === "draft-order") return null;
|
||||||
|
if (actionData.error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (actionData.testSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
|
||||||
|
Test notification sent to Discord.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (actionData.success) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
|
||||||
|
{actionData.message ?? "Settings updated successfully."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraftOrderMessage({ actionData }: { actionData: SettingsActionData }) {
|
||||||
|
if (!actionData || actionData.section !== "draft-order") return null;
|
||||||
|
if (actionData.error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (actionData.success && actionData.message) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-emerald-500/15 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
|
||||||
|
{actionData.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
127
app/components/league/settings/SettingsNavigation.tsx
Normal file
127
app/components/league/settings/SettingsNavigation.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { LucideProps } from "lucide-react";
|
||||||
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type SettingsNavSection = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsGridSection = SettingsNavSection & {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
subtitle: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
isDanger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsMobileGridNav({
|
||||||
|
sections,
|
||||||
|
completedCount,
|
||||||
|
onSectionChange,
|
||||||
|
}: {
|
||||||
|
sections: readonly SettingsGridSection[];
|
||||||
|
completedCount: number;
|
||||||
|
onSectionChange: (sectionId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 lg:hidden">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Manage
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{completedCount} of {sections.length} set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSectionChange(section.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||||
|
section.isDanger && "border-destructive/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.isComplete && (
|
||||||
|
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<Check className="h-3 w-3" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||||
|
section.isDanger
|
||||||
|
? "bg-destructive/15 text-destructive"
|
||||||
|
: "bg-primary/20 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<section.icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsMobileSectionPill({
|
||||||
|
onShowGrid,
|
||||||
|
}: {
|
||||||
|
onShowGrid: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowGrid}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back to all settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDesktopNav({
|
||||||
|
sections,
|
||||||
|
activeSection,
|
||||||
|
onSectionChange,
|
||||||
|
}: {
|
||||||
|
sections: readonly SettingsGridSection[];
|
||||||
|
activeSection: string;
|
||||||
|
onSectionChange: (sectionId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-6 rounded-xl border bg-card p-3">
|
||||||
|
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Manage
|
||||||
|
</p>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSectionChange(section.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
activeSection === section.id && "bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/components/league/settings/SettingsSaveBar.tsx
Normal file
31
app/components/league/settings/SettingsSaveBar.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
|
export function SettingsSaveBar({
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSubmitting,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<p className="mb-3 text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
You have unsaved settings changes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<Button type="button" variant="outline" className="h-12 sm:w-48" onClick={onReset}>
|
||||||
|
Reset Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="h-12 flex-1" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Saving..." : "Save Settings"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/components/league/settings/SettingsSection.tsx
Normal file
64
app/components/league/settings/SettingsSection.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { ComponentType, ReactNode } from "react";
|
||||||
|
import type { LucideProps } from "lucide-react";
|
||||||
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type StatusTone = "default" | "success" | "warning" | "locked";
|
||||||
|
|
||||||
|
export function SettingsStatusPill({
|
||||||
|
children,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: StatusTone;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold",
|
||||||
|
tone === "success" && "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
tone === "warning" && "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
tone === "locked" && "border-muted-foreground/20 bg-muted text-muted-foreground",
|
||||||
|
tone === "default" && "border-border bg-background text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSection({
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className={cn("scroll-mt-24 space-y-8", className)}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<GradientIcon icon={icon} className="mt-1 h-6 w-6 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status && <div className="sm:pt-1">{status}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/components/league/settings/SortableDraftOrderRow.tsx
Normal file
64
app/components/league/settings/SortableDraftOrderRow.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function SortableDraftOrderRow({
|
||||||
|
teamId,
|
||||||
|
index,
|
||||||
|
teamName,
|
||||||
|
ownerName,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
teamId: string;
|
||||||
|
index: number;
|
||||||
|
teamName: string;
|
||||||
|
ownerName: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: teamId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border bg-card p-4",
|
||||||
|
isDragging && "z-10 shadow-lg ring-1 ring-primary/30",
|
||||||
|
disabled && "opacity-65"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="teamOrder" value={teamId} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted",
|
||||||
|
disabled && "cursor-not-allowed hover:bg-transparent"
|
||||||
|
)}
|
||||||
|
aria-label={`Drag ${teamName}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...(disabled ? {} : attributes)}
|
||||||
|
{...(disabled ? {} : listeners)}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{teamName}</p>
|
||||||
|
{ownerName && <p className="truncate text-xs text-muted-foreground">{ownerName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
app/components/league/settings/SportsSection.tsx
Normal file
91
app/components/league/settings/SportsSection.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { ClipboardList } from "lucide-react";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { SportIcon } from "~/components/league/SportIcon";
|
||||||
|
import { SettingsSection, SettingsStatusPill } from "./SettingsSection";
|
||||||
|
|
||||||
|
type SportsSeason = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
scoringType: string;
|
||||||
|
sport: { id: string; name: string; slug: string; type: string; iconUrl: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRACKT_SLUG = "brackt";
|
||||||
|
|
||||||
|
export function SportsSection({
|
||||||
|
active,
|
||||||
|
canEditSports,
|
||||||
|
selectedSports,
|
||||||
|
displaySportsSeasons,
|
||||||
|
onSportToggle,
|
||||||
|
onClearAll,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
canEditSports: boolean;
|
||||||
|
selectedSports: Set<string>;
|
||||||
|
displaySportsSeasons: SportsSeason[];
|
||||||
|
onSportToggle: (id: string) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="sports"
|
||||||
|
icon={ClipboardList}
|
||||||
|
title="Sports"
|
||||||
|
description="Manage draftable sports seasons for this league. Sports lock when the draft starts."
|
||||||
|
status={<SettingsStatusPill tone={canEditSports ? "success" : "locked"}>{canEditSports ? "Editable" : "Locked"}</SettingsStatusPill>}
|
||||||
|
className={active ? undefined : "hidden"}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label>{selectedSports.size} selected</Label>
|
||||||
|
{canEditSports && selectedSports.size > 0 && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onClearAll}>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{displaySportsSeasons.length > 0 ? (
|
||||||
|
displaySportsSeasons.map((ss) => {
|
||||||
|
const selected = selectedSports.has(ss.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={ss.id}
|
||||||
|
htmlFor={`sport-${ss.id}`}
|
||||||
|
className={cn(
|
||||||
|
"mb-2 flex cursor-pointer items-center gap-3 rounded-md border p-3 text-sm transition-colors last:mb-0",
|
||||||
|
selected ? "border-primary bg-primary/10 text-primary" : "border-border bg-background hover:border-primary/50",
|
||||||
|
!canEditSports && "cursor-not-allowed opacity-65"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`sport-${ss.id}`}
|
||||||
|
name="sportsSeasons"
|
||||||
|
value={ss.id}
|
||||||
|
checked={selected}
|
||||||
|
onCheckedChange={() => onSportToggle(ss.id)}
|
||||||
|
disabled={!canEditSports}
|
||||||
|
/>
|
||||||
|
<SportIcon sport={ss.sport} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{ss.sport.slug === BRACKT_SLUG ? "Brackt" : `${ss.sport.name} - ${ss.name} (${ss.year})`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{ss.scoringType.replace("_", " ")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="p-3 text-sm text-muted-foreground">No sports seasons available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/components/league/settings/UnsavedChangesDialogs.tsx
Normal file
69
app/components/league/settings/UnsavedChangesDialogs.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { Blocker } from "react-router";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export function LeaveSettingsDialog({ blocker }: { blocker: Blocker }) {
|
||||||
|
if (blocker.state !== "blocked") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Leave without saving?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
You have unsaved settings changes. If you leave this page, those changes will be lost.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => blocker.reset?.()}>
|
||||||
|
Stay
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => blocker.proceed?.()}>
|
||||||
|
Leave without saving
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchSettingsSectionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onStay,
|
||||||
|
onDiscard,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onStay: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Switch sections without saving?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
You have unsaved settings changes. Save before switching sections, or discard your edits to continue.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onStay}>
|
||||||
|
Stay
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onDiscard}>
|
||||||
|
Discard changes
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
770
app/routes/leagues/$leagueId.settings.server.ts
Normal file
770
app/routes/leagues/$leagueId.settings.server.ts
Normal file
|
|
@ -0,0 +1,770 @@
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
import { auth } from "~/lib/auth.server";
|
||||||
|
import { logger } from "~/lib/logger";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { deleteLeague, findLeagueById, updateLeague } from "~/models/league";
|
||||||
|
import {
|
||||||
|
countCommissionersByLeagueId,
|
||||||
|
createCommissioner,
|
||||||
|
findCommissionersByLeagueId,
|
||||||
|
hasCommissionerRecord,
|
||||||
|
isCommissioner,
|
||||||
|
removeCommissionerByLeagueAndUser,
|
||||||
|
} from "~/models/commissioner";
|
||||||
|
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
||||||
|
import { findDraftSlotsBySeasonId, randomizeDraftOrder, setDraftOrder } from "~/models/draft-slot";
|
||||||
|
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
||||||
|
import { deleteSeasonTimers } from "~/models/draft-timer";
|
||||||
|
import { findCurrentSeasonWithSports, type NewSeason, updateSeason } from "~/models/season";
|
||||||
|
import { linkMultipleSportsToSeason, unlinkSportFromSeason } from "~/models/season-sport";
|
||||||
|
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
||||||
|
import { claimTeam, deleteTeam, findTeamById, findTeamsBySeasonId, removeTeamOwner, renameTeam } from "~/models/team";
|
||||||
|
import { findUserById, findUsersByIds, getUserDisplayName, isUserAdmin } from "~/models/user";
|
||||||
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
|
import { parseDraftSpeed } from "~/lib/draft-timer";
|
||||||
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
||||||
|
import {
|
||||||
|
applyBracktSportsForSeason,
|
||||||
|
seedOrRepairBracktTemplate,
|
||||||
|
syncPrivateBracktParticipants,
|
||||||
|
} from "~/services/brackt.server";
|
||||||
|
import { generateUniqueTeamNames, prependOwnerToTeamName, stripOwnerFromTeamName } from "~/utils/team-names";
|
||||||
|
import type { Route } from "./+types/$leagueId.settings";
|
||||||
|
|
||||||
|
function isValidHHMM(s: string): boolean {
|
||||||
|
return /^\d{2}:\d{2}$/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
const { params } = args;
|
||||||
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const league = await findLeagueById(leagueId);
|
||||||
|
|
||||||
|
if (!league) {
|
||||||
|
throw new Response("League not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings are limited to league commissioners and site admins. isCommissioner()
|
||||||
|
// includes admins, so league creators without a commissioner record do not get
|
||||||
|
// read-only or edit access here.
|
||||||
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
||||||
|
|
||||||
|
if (!userIsCommissioner) {
|
||||||
|
throw new Response("Forbidden - You must be a commissioner to access settings", {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await seedOrRepairBracktTemplate();
|
||||||
|
|
||||||
|
const [season, draftableSportsSeasons] = await Promise.all([
|
||||||
|
findCurrentSeasonWithSports(leagueId),
|
||||||
|
findDraftableSportsSeasons(),
|
||||||
|
]);
|
||||||
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
||||||
|
// Merge draftable seasons with any already-linked non-draftable seasons so that
|
||||||
|
// previously-added seasons remain visible and checked in the UI even after being disabled.
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
||||||
|
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
||||||
|
.map((s) => s.sportsSeason)
|
||||||
|
.filter((ss) => {
|
||||||
|
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
|
||||||
|
return !draftableIds.has(ss.id) && (fantasySeasonId || ss.draftOff < today || ss.draftOn > today);
|
||||||
|
});
|
||||||
|
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
||||||
|
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
||||||
|
|
||||||
|
const teamsWithOwners = teams.filter((team) => team.ownerId !== null).length;
|
||||||
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
const commissioners = await findCommissionersByLeagueId(leagueId);
|
||||||
|
|
||||||
|
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
||||||
|
const commissionerUserIds = commissioners.map((c) => c.userId);
|
||||||
|
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
||||||
|
const userRows = await findUsersByIds(allUserIds);
|
||||||
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
const commissionerUserData = commissioners.map((c) => {
|
||||||
|
const user = userById.get(c.userId);
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const validOwners = uniqueOwnerIds
|
||||||
|
.map((ownerId) => {
|
||||||
|
const user = userById.get(ownerId);
|
||||||
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
||||||
|
})
|
||||||
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
||||||
|
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
|
||||||
|
|
||||||
|
// People currently attached to the league, either through team ownership or
|
||||||
|
// commissioner membership. Admin assignment should not expose every user.
|
||||||
|
const leagueMembers = allUserIds
|
||||||
|
.map((memberUserId) => {
|
||||||
|
const user = userById.get(memberUserId);
|
||||||
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
||||||
|
})
|
||||||
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
||||||
|
|
||||||
|
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
league,
|
||||||
|
season,
|
||||||
|
teams,
|
||||||
|
teamCount: teams.length,
|
||||||
|
teamsWithOwners,
|
||||||
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & {
|
||||||
|
fantasySeasonId: string | null;
|
||||||
|
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
|
||||||
|
}>,
|
||||||
|
draftSlots,
|
||||||
|
isAdmin,
|
||||||
|
leagueMembers,
|
||||||
|
ownerMap: Object.fromEntries(ownerMap),
|
||||||
|
commissioners: commissionerUserData,
|
||||||
|
currentUserId: userId,
|
||||||
|
commishTimezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
const { params, request } = args;
|
||||||
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const league = await findLeagueById(leagueId);
|
||||||
|
if (!league) {
|
||||||
|
throw new Response("League not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
||||||
|
|
||||||
|
if (!userIsCommissioner) {
|
||||||
|
throw new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
// For league-level settings (name, public draft board), save immediately
|
||||||
|
// before checking season, so they work even if no season exists
|
||||||
|
if (intent === "update") {
|
||||||
|
const name = formData.get("name");
|
||||||
|
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
||||||
|
const discordWebhookUrl = formData.get("discordWebhookUrl");
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "League name is required" };
|
||||||
|
}
|
||||||
|
if (name.trim().length < 3 || name.trim().length > 50) {
|
||||||
|
return { error: "League name must be between 3 and 50 characters" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl =
|
||||||
|
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
|
||||||
|
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
||||||
|
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changedFields: string[] = [];
|
||||||
|
if (name.trim() !== league.name) changedFields.push("name");
|
||||||
|
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
|
||||||
|
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
|
||||||
|
|
||||||
|
await updateLeague(leagueId, {
|
||||||
|
name: name.trim(),
|
||||||
|
isPublicDraftBoard,
|
||||||
|
discordWebhookUrl: webhookUrl || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changedFields.length > 0) {
|
||||||
|
const currentSeason = await findCurrentSeasonWithSports(leagueId);
|
||||||
|
if (currentSeason) {
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: currentSeason.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "league_settings_changed",
|
||||||
|
details: {
|
||||||
|
changedFields,
|
||||||
|
previousValues: {
|
||||||
|
name: league.name,
|
||||||
|
isPublicDraftBoard: league.isPublicDraftBoard,
|
||||||
|
discordWebhookUrl: league.discordWebhookUrl,
|
||||||
|
},
|
||||||
|
newValues: {
|
||||||
|
name: name.trim(),
|
||||||
|
isPublicDraftBoard,
|
||||||
|
discordWebhookUrl: webhookUrl || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating league:", error);
|
||||||
|
return { error: "Failed to update league. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "test-discord-webhook") {
|
||||||
|
const webhookUrl = formData.get("webhookUrl");
|
||||||
|
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
||||||
|
return { error: "Invalid Discord webhook URL" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const testSeason = await findCurrentSeasonWithSports(leagueId);
|
||||||
|
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
|
||||||
|
await sendStandingsUpdateNotification({
|
||||||
|
webhookUrl,
|
||||||
|
seasonName,
|
||||||
|
standings: [
|
||||||
|
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
|
||||||
|
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
|
||||||
|
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
|
||||||
|
],
|
||||||
|
previousStandings: new Map([
|
||||||
|
["1", 125],
|
||||||
|
["2", 125],
|
||||||
|
["3", 100],
|
||||||
|
]),
|
||||||
|
previousRanks: new Map([
|
||||||
|
["1", 2],
|
||||||
|
["2", 1],
|
||||||
|
["3", 3],
|
||||||
|
]),
|
||||||
|
eventName: "Test Notification",
|
||||||
|
scoredMatches: [
|
||||||
|
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { testSuccess: true };
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Discord test webhook failed:", err);
|
||||||
|
return { error: "Failed to send test notification. Check your webhook URL." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = await findCurrentSeasonWithSports(leagueId);
|
||||||
|
if (!season) {
|
||||||
|
if (intent === "update") {
|
||||||
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
||||||
|
}
|
||||||
|
return { error: "No active season found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "set-draft-order") {
|
||||||
|
if (season.status !== "pre_draft") {
|
||||||
|
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
|
const teamIds = formData.getAll("teamOrder") as string[];
|
||||||
|
|
||||||
|
if (teamIds.length !== teams.length) {
|
||||||
|
return { error: "All teams must be included in the draft order", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTeamIds = new Set(teams.map((t) => t.id));
|
||||||
|
for (const teamId of teamIds) {
|
||||||
|
if (!validTeamIds.has(teamId)) {
|
||||||
|
return { error: "Invalid team ID in draft order", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDraftOrder(season.id, teamIds);
|
||||||
|
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "draft_order_set",
|
||||||
|
affectedTeamIds: teamIds,
|
||||||
|
details: {
|
||||||
|
order: teamIds.map((id, i) => ({
|
||||||
|
teamId: id,
|
||||||
|
teamName: teams.find((t) => t.id === id)?.name ?? id,
|
||||||
|
position: i + 1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: "Draft order updated successfully", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "randomize-draft-order") {
|
||||||
|
if (season.status !== "pre_draft") {
|
||||||
|
return { error: "Cannot modify draft order after draft has started", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
|
const teamIds = teams.map((t) => t.id);
|
||||||
|
|
||||||
|
await randomizeDraftOrder(season.id, teamIds);
|
||||||
|
|
||||||
|
const newSlots = await findDraftSlotsBySeasonId(season.id);
|
||||||
|
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
|
||||||
|
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "draft_order_randomized",
|
||||||
|
affectedTeamIds: sortedSlots.map((s) => s.teamId),
|
||||||
|
details: {
|
||||||
|
order: sortedSlots.map((s) => ({
|
||||||
|
teamId: s.teamId,
|
||||||
|
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
|
||||||
|
position: s.draftOrder,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: "Draft order randomized successfully", section: "draft-order" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "remove-team-owner") {
|
||||||
|
const teamId = formData.get("teamId") as string;
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
return { error: "Team ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const team = await findTeamById(teamId);
|
||||||
|
if (team?.ownerId) {
|
||||||
|
const owner = await findUserById(team.ownerId);
|
||||||
|
const username = owner?.username ?? owner?.displayName;
|
||||||
|
if (username) {
|
||||||
|
const strippedName = stripOwnerFromTeamName(team.name, username);
|
||||||
|
if (strippedName !== team.name) {
|
||||||
|
await renameTeam(teamId, strippedName);
|
||||||
|
}
|
||||||
|
await removeTeamOwner(teamId);
|
||||||
|
} else {
|
||||||
|
await removeTeamOwner(teamId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await removeTeamOwner(teamId);
|
||||||
|
}
|
||||||
|
return { success: true, message: "Owner removed successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error removing team owner:", error);
|
||||||
|
return { error: "Failed to remove owner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "assign-team-owner") {
|
||||||
|
const teamId = formData.get("teamId") as string;
|
||||||
|
const assignedUserId = formData.get("userId") as string;
|
||||||
|
|
||||||
|
if (!teamId || !assignedUserId) {
|
||||||
|
return { error: "Team ID and User ID are required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { error: "Only admins can assign team owners" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedUser = await findUserById(assignedUserId);
|
||||||
|
if (!assignedUser) {
|
||||||
|
return { error: "User not found. Please try again." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
|
const isLeagueMember =
|
||||||
|
teams.some((team) => team.ownerId === assignedUserId) ||
|
||||||
|
(await hasCommissionerRecord(leagueId, assignedUserId));
|
||||||
|
if (!isLeagueMember) {
|
||||||
|
return { error: "Only current league members can be assigned to teams" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAlreadyHasTeam = teams.some((team) => team.ownerId === assignedUserId);
|
||||||
|
|
||||||
|
if (userAlreadyHasTeam) {
|
||||||
|
return { error: "This user is already assigned to a team in this league" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTeam = teams.find((team) => team.id === teamId);
|
||||||
|
if (!targetTeam) {
|
||||||
|
return { error: "Team not found" };
|
||||||
|
}
|
||||||
|
if (targetTeam.ownerId) {
|
||||||
|
return { error: "This team already has an owner. Remove the current owner first." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const teamName = prependOwnerToTeamName(
|
||||||
|
targetTeam.name,
|
||||||
|
assignedUser.username ?? assignedUser.displayName ?? "Member"
|
||||||
|
);
|
||||||
|
await claimTeam(teamId, assignedUserId, teamName);
|
||||||
|
|
||||||
|
return { success: true, message: "Owner assigned successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error assigning team owner:", error);
|
||||||
|
return { error: "Failed to assign owner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "reset-draft") {
|
||||||
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { error: "Only admins can reset the draft" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAllDraftPicks(season.id);
|
||||||
|
await clearAllQueuesForSeason(season.id);
|
||||||
|
await deleteSeasonTimers(season.id);
|
||||||
|
|
||||||
|
const previousPickNumber = season.currentPickNumber;
|
||||||
|
|
||||||
|
await updateSeason(season.id, {
|
||||||
|
status: "pre_draft",
|
||||||
|
currentPickNumber: null,
|
||||||
|
draftStartedAt: null,
|
||||||
|
draftPaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "draft_reset",
|
||||||
|
details: { previousPickNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error resetting draft:", error);
|
||||||
|
return { error: "Failed to reset draft. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
await deleteLeague(leagueId);
|
||||||
|
return redirect("/?deleted=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "update") {
|
||||||
|
const teamCount = formData.get("teamCount");
|
||||||
|
const draftDateTime = formData.get("draftDateTime");
|
||||||
|
const draftRounds = formData.get("draftRounds");
|
||||||
|
const draftSpeed = formData.get("draftSpeed");
|
||||||
|
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seasonUpdates: Partial<NewSeason> = {};
|
||||||
|
|
||||||
|
if (typeof draftRounds === "string") {
|
||||||
|
const draftRoundsNum = parseInt(draftRounds, 10);
|
||||||
|
if (!isNaN(draftRoundsNum)) {
|
||||||
|
const sportsCount = season.seasonSports?.length || 0;
|
||||||
|
if (draftRoundsNum < sportsCount) {
|
||||||
|
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
|
||||||
|
}
|
||||||
|
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
|
||||||
|
return { error: "Draft rounds must be between 1 and 50" };
|
||||||
|
}
|
||||||
|
if (draftRoundsNum !== season.draftRounds) {
|
||||||
|
seasonUpdates.draftRounds = draftRoundsNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof draftDateTime === "string") {
|
||||||
|
const newDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
||||||
|
const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null;
|
||||||
|
const changed = newDateTime?.getTime() !== currentDateTime?.getTime();
|
||||||
|
if (changed) {
|
||||||
|
seasonUpdates.draftDateTime = newDateTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftSpeed !== null) {
|
||||||
|
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
|
||||||
|
draftSpeed as string | null,
|
||||||
|
draftTimerMode ?? "chess_clock"
|
||||||
|
);
|
||||||
|
if (draftInitialTime !== season.draftInitialTime) {
|
||||||
|
seasonUpdates.draftInitialTime = draftInitialTime;
|
||||||
|
}
|
||||||
|
if (draftIncrementTime !== season.draftIncrementTime) {
|
||||||
|
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) {
|
||||||
|
seasonUpdates.draftTimerMode = draftTimerMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
|
||||||
|
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
|
||||||
|
if (overnightPauseMode !== season.overnightPauseMode) {
|
||||||
|
seasonUpdates.overnightPauseMode = overnightPauseMode;
|
||||||
|
}
|
||||||
|
if (overnightPauseMode !== "none") {
|
||||||
|
const start = formData.get("overnightPauseStart") as string | null;
|
||||||
|
const end = formData.get("overnightPauseEnd") as string | null;
|
||||||
|
const tz = formData.get("overnightPauseTimezone") as string | null;
|
||||||
|
if (start && isValidHHMM(start) && start !== season.overnightPauseStart) {
|
||||||
|
seasonUpdates.overnightPauseStart = start;
|
||||||
|
}
|
||||||
|
if (end && isValidHHMM(end) && end !== season.overnightPauseEnd) {
|
||||||
|
seasonUpdates.overnightPauseEnd = end;
|
||||||
|
}
|
||||||
|
if (tz && tz.length > 0 && tz !== season.overnightPauseTimezone) {
|
||||||
|
seasonUpdates.overnightPauseTimezone = tz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season.status === "pre_draft") {
|
||||||
|
const scoringFields = [
|
||||||
|
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
||||||
|
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of scoringFields) {
|
||||||
|
const value = formData.get(field);
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const points = parseInt(value, 10);
|
||||||
|
if (!isNaN(points)) {
|
||||||
|
if (points < 0 || points > 1000) {
|
||||||
|
return { error: `${field} must be between 0 and 1000 points` };
|
||||||
|
}
|
||||||
|
if (points !== (season as unknown as Record<string, unknown>)[field]) {
|
||||||
|
(seasonUpdates as Record<string, unknown>)[field] = points;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(seasonUpdates).length > 0) {
|
||||||
|
await updateSeason(season.id, seasonUpdates);
|
||||||
|
|
||||||
|
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
||||||
|
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
|
||||||
|
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
|
||||||
|
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
|
||||||
|
|
||||||
|
const seasonAsMap = season as unknown as Record<string, unknown>;
|
||||||
|
const updatesAsMap = seasonUpdates as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (draftFields.length > 0) {
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "draft_settings_changed",
|
||||||
|
details: {
|
||||||
|
changedFields: draftFields,
|
||||||
|
previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])),
|
||||||
|
newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoringChangedFields.length > 0) {
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "scoring_rules_changed",
|
||||||
|
details: {
|
||||||
|
changedFields: scoringChangedFields,
|
||||||
|
previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])),
|
||||||
|
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season.status === "pre_draft") {
|
||||||
|
const selectedSports = formData.getAll("sportsSeasons").map(String);
|
||||||
|
const currentSportIds = new Set(
|
||||||
|
season.seasonSports?.map((s) => s.sportsSeason.id) || []
|
||||||
|
);
|
||||||
|
const sportsToApply = await applyBracktSportsForSeason(season.id, selectedSports);
|
||||||
|
const newSportIds = new Set(sportsToApply);
|
||||||
|
|
||||||
|
for (const sportId of currentSportIds) {
|
||||||
|
if (!newSportIds.has(sportId)) {
|
||||||
|
await unlinkSportFromSeason(season.id, sportId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sportsToAdd = [];
|
||||||
|
for (const sportId of newSportIds) {
|
||||||
|
if (!currentSportIds.has(sportId)) {
|
||||||
|
sportsToAdd.push({
|
||||||
|
seasonId: season.id,
|
||||||
|
sportsSeasonId: sportId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sportsToAdd.length > 0) {
|
||||||
|
await linkMultipleSportsToSeason(sportsToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id));
|
||||||
|
const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id));
|
||||||
|
if (removedIds.length > 0 || addedIds.length > 0) {
|
||||||
|
const nameMap = new Map(
|
||||||
|
season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? []
|
||||||
|
);
|
||||||
|
const addedSeasons = await findSportsSeasonsByIds(addedIds);
|
||||||
|
const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`]));
|
||||||
|
const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id);
|
||||||
|
await logCommissionerAction({
|
||||||
|
seasonId: season.id,
|
||||||
|
leagueId,
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "sports_changed",
|
||||||
|
details: {
|
||||||
|
added: addedNames,
|
||||||
|
removed: removedIds.map((id) => nameMap.get(id) ?? id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof teamCount === "string") {
|
||||||
|
const newTeamCount = parseInt(teamCount, 10);
|
||||||
|
|
||||||
|
if (!isNaN(newTeamCount)) {
|
||||||
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
|
const currentTeamCount = teams.length;
|
||||||
|
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
|
||||||
|
|
||||||
|
if (newTeamCount < 6 || newTeamCount > 16) {
|
||||||
|
return { error: "Number of teams must be between 6 and 16" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTeamCount < teamsWithOwners) {
|
||||||
|
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTeamCount > currentTeamCount) {
|
||||||
|
const newNames = generateUniqueTeamNames(newTeamCount - currentTeamCount);
|
||||||
|
const teamsToAdd = Array.from(
|
||||||
|
{ length: newTeamCount - currentTeamCount },
|
||||||
|
(_, i) => ({
|
||||||
|
seasonId: season.id,
|
||||||
|
name: newNames[i],
|
||||||
|
ownerId: null as null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSlots = await findDraftSlotsBySeasonId(season.id);
|
||||||
|
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
|
||||||
|
|
||||||
|
const db = database();
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const newTeams = await tx
|
||||||
|
.insert(schema.teams)
|
||||||
|
.values(teamsToAdd)
|
||||||
|
.returning();
|
||||||
|
await tx.insert(schema.draftSlots).values(
|
||||||
|
newTeams.map((team, i) => ({
|
||||||
|
seasonId: season.id,
|
||||||
|
teamId: team.id,
|
||||||
|
draftOrder: maxOrder + i + 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await syncPrivateBracktParticipants(season.id);
|
||||||
|
} else if (newTeamCount < currentTeamCount) {
|
||||||
|
if (season.status !== "pre_draft") {
|
||||||
|
return { error: "Teams cannot be removed after the draft has started" };
|
||||||
|
}
|
||||||
|
const teamsToRemove = teams
|
||||||
|
.filter((t) => t.ownerId === null)
|
||||||
|
.slice(-(currentTeamCount - newTeamCount));
|
||||||
|
|
||||||
|
for (const team of teamsToRemove) {
|
||||||
|
await deleteTeam(team.id);
|
||||||
|
}
|
||||||
|
await syncPrivateBracktParticipants(season.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating season settings:", error);
|
||||||
|
return { error: "Failed to update season settings. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "add-commissioner") {
|
||||||
|
const newCommissionerUserId = formData.get("userId") as string;
|
||||||
|
|
||||||
|
if (!newCommissionerUserId) {
|
||||||
|
return { error: "User is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
|
||||||
|
if (alreadyCommissioner) {
|
||||||
|
return { error: "This user is already a commissioner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCommissioner({ leagueId, userId: newCommissionerUserId });
|
||||||
|
return { success: true, message: "Commissioner added successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error adding commissioner:", error);
|
||||||
|
return { error: "Failed to add commissioner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "remove-commissioner") {
|
||||||
|
const commissionerUserId = formData.get("commissionerUserId") as string;
|
||||||
|
|
||||||
|
if (!commissionerUserId) {
|
||||||
|
return { error: "User is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commissionerUserId === userId) {
|
||||||
|
return { error: "You cannot remove yourself as a commissioner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await countCommissionersByLeagueId(leagueId);
|
||||||
|
if (count <= 1) {
|
||||||
|
return { error: "Cannot remove the last commissioner" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
|
||||||
|
return { success: true, message: "Commissioner removed successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error removing commissioner:", error);
|
||||||
|
return { error: "Failed to remove commissioner. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: "Invalid action" };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { updateLeague } from '~/models/league';
|
import { updateLeague } from '~/models/league';
|
||||||
import { findCurrentSeasonWithSports, updateSeason } from '~/models/season';
|
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', () => ({
|
vi.mock('~/models/league', () => ({
|
||||||
findLeagueById: vi.fn(),
|
findLeagueById: vi.fn(),
|
||||||
updateLeague: vi.fn(),
|
updateLeague: vi.fn(),
|
||||||
|
|
@ -15,6 +15,7 @@ vi.mock('~/models/commissioner', () => ({
|
||||||
createCommissioner: vi.fn(),
|
createCommissioner: vi.fn(),
|
||||||
countCommissionersByLeagueId: vi.fn(),
|
countCommissionersByLeagueId: vi.fn(),
|
||||||
removeCommissionerByLeagueAndUser: vi.fn(),
|
removeCommissionerByLeagueAndUser: vi.fn(),
|
||||||
|
hasCommissionerRecord: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('~/models/season', () => ({
|
vi.mock('~/models/season', () => ({
|
||||||
|
|
@ -27,7 +28,9 @@ vi.mock('~/models/team', () => ({
|
||||||
createManyTeams: vi.fn(),
|
createManyTeams: vi.fn(),
|
||||||
deleteTeam: vi.fn(),
|
deleteTeam: vi.fn(),
|
||||||
removeTeamOwner: vi.fn(),
|
removeTeamOwner: vi.fn(),
|
||||||
assignTeamOwner: vi.fn(),
|
claimTeam: vi.fn(),
|
||||||
|
findTeamById: vi.fn(),
|
||||||
|
renameTeam: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLeague = {
|
const mockLeague = {
|
||||||
|
|
@ -75,6 +78,92 @@ const mockSeason = {
|
||||||
updatedAt: new Date('2025-01-01'),
|
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
|
// Name validation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -100,16 +189,13 @@ describe('Settings Update - Name Validation', () => {
|
||||||
it('should reject a name shorter than 3 characters', () => {
|
it('should reject a name shorter than 3 characters', () => {
|
||||||
const name = 'AB';
|
const name = 'AB';
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
const tooShort = trimmed.length < 3;
|
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
|
||||||
const tooLong = trimmed.length > 50;
|
|
||||||
expect(tooShort || tooLong).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject a name longer than 50 characters', () => {
|
it('should reject a name longer than 50 characters', () => {
|
||||||
const name = 'A'.repeat(51);
|
const name = 'A'.repeat(51);
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
const tooLong = trimmed.length > 50;
|
expect(trimmed.length < 3 || trimmed.length > 50).toBe(true);
|
||||||
expect(tooLong).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a name that is exactly 3 characters', () => {
|
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');
|
const error = new Error('Database connection failed');
|
||||||
vi.mocked(updateLeague).mockRejectedValue(error);
|
vi.mocked(updateLeague).mockRejectedValue(error);
|
||||||
|
|
||||||
// Simulate the try-catch around updateLeague in the action
|
|
||||||
let result: { error: string } | undefined;
|
let result: { error: string } | undefined;
|
||||||
try {
|
try {
|
||||||
await updateLeague('league-1', { name: 'Test League', isPublicDraftBoard: false });
|
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(updateLeague).mockRejectedValue(new Error('DB error'));
|
||||||
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(mockSeason);
|
vi.mocked(findCurrentSeasonWithSports).mockResolvedValue(mockSeason);
|
||||||
|
|
||||||
// Action returns early on league save error; season functions should not run
|
|
||||||
try {
|
try {
|
||||||
await updateLeague('league-1', { name: 'Test', isPublicDraftBoard: false });
|
await updateLeague('league-1', { name: 'Test', isPublicDraftBoard: false });
|
||||||
} catch {
|
} catch {
|
||||||
// early return would happen here
|
// early return happens here in the real action
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(findCurrentSeasonWithSports).not.toHaveBeenCalled();
|
expect(findCurrentSeasonWithSports).not.toHaveBeenCalled();
|
||||||
|
|
@ -233,104 +317,53 @@ describe('Settings Update - Season Save', () => {
|
||||||
|
|
||||||
it('should reject draft rounds below 1', () => {
|
it('should reject draft rounds below 1', () => {
|
||||||
const rounds = 0;
|
const rounds = 0;
|
||||||
const invalid = rounds < 1 || rounds > 50;
|
expect(rounds < 1 || rounds > 50).toBe(true);
|
||||||
expect(invalid).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject draft rounds above 50', () => {
|
it('should reject draft rounds above 50', () => {
|
||||||
const rounds = 51;
|
const rounds = 51;
|
||||||
const invalid = rounds < 1 || rounds > 50;
|
expect(rounds < 1 || rounds > 50).toBe(true);
|
||||||
expect(invalid).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject draft rounds less than the number of sports selected', () => {
|
it('should reject draft rounds less than the number of sports selected', () => {
|
||||||
const sportsCount = 5;
|
const sportsCount = 5;
|
||||||
const draftRounds = 3;
|
const draftRounds = 3;
|
||||||
const tooFew = draftRounds < sportsCount;
|
expect(draftRounds < sportsCount).toBe(true);
|
||||||
expect(tooFew).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map "fast" draft speed to correct timer values', () => {
|
it('should map "fast" chess_clock speed to correct timer values', () => {
|
||||||
const draftSpeed: string = 'fast';
|
const result = parseDraftSpeed('fast', 'chess_clock');
|
||||||
let initialTime: number;
|
expect(result).toEqual({ draftInitialTime: 60, draftIncrementTime: 10 });
|
||||||
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 "standard" draft speed to correct timer values', () => {
|
it('should map "standard" chess_clock speed to correct timer values', () => {
|
||||||
const draftSpeed: string = 'standard';
|
const result = parseDraftSpeed('standard', 'chess_clock');
|
||||||
let initialTime = 120;
|
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
|
||||||
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 "slow" draft speed to correct timer values', () => {
|
it('should map "slow" chess_clock speed to correct timer values', () => {
|
||||||
const draftSpeed: string = 'slow';
|
const result = parseDraftSpeed('slow', 'chess_clock');
|
||||||
let initialTime = 120;
|
expect(result).toEqual({ draftInitialTime: 28800, draftIncrementTime: 3600 });
|
||||||
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 "very-slow" draft speed to correct timer values', () => {
|
it('should map "very-slow" chess_clock speed to correct timer values', () => {
|
||||||
const draftSpeed: string = 'very-slow';
|
const result = parseDraftSpeed('very-slow', 'chess_clock');
|
||||||
let initialTime = 120;
|
expect(result).toEqual({ draftInitialTime: 43200, draftIncrementTime: 3600 });
|
||||||
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 fall back to standard speed for an unknown draft speed value', () => {
|
it('should fall back to standard speed for an unknown chess_clock speed value', () => {
|
||||||
const draftSpeed: string = 'turbo-ultra';
|
const result = parseDraftSpeed('turbo-ultra', 'chess_clock');
|
||||||
let initialTime = 120;
|
expect(result).toEqual({ draftInitialTime: 120, draftIncrementTime: 15 });
|
||||||
let incrementTime = 15;
|
});
|
||||||
|
|
||||||
if (draftSpeed === 'fast') { initialTime = 60; incrementTime = 10; }
|
it('should use raw seconds for standard timer mode', () => {
|
||||||
else if (draftSpeed === 'slow') { initialTime = 28800; incrementTime = 3600; }
|
const result = parseDraftSpeed('90', 'standard');
|
||||||
else if (draftSpeed === 'very-slow') { initialTime = 43200; incrementTime = 3600; }
|
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
expect(initialTime).toBe(120);
|
it('should fall back to 90s when standard mode speed is not a number', () => {
|
||||||
expect(incrementTime).toBe(15);
|
const result = parseDraftSpeed(null, 'standard');
|
||||||
|
expect(result).toEqual({ draftInitialTime: 90, draftIncrementTime: 90 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a season-specific error message when updateSeason throws', async () => {
|
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', () => {
|
it('should only update scoring fields when season status is pre_draft', () => {
|
||||||
const preDraftStatus: string = 'pre_draft';
|
expect(canUpdateScoring('pre_draft')).toBe(true);
|
||||||
const draftStatus: string = 'draft';
|
expect(canUpdateScoring('draft')).toBe(false);
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate scoring points are between 0 and 1000', () => {
|
it('should validate scoring points are between 0 and 1000', () => {
|
||||||
const validPoints = 500;
|
expect(isValidScoringPoints(500)).toBe(true);
|
||||||
const tooLow = -1;
|
expect(isValidScoringPoints(-1)).toBe(false);
|
||||||
const tooHigh = 1001;
|
expect(isValidScoringPoints(1001)).toBe(false);
|
||||||
|
|
||||||
expect(validPoints >= 0 && validPoints <= 1000).toBe(true);
|
|
||||||
expect(tooLow >= 0 && tooLow <= 1000).toBe(false);
|
|
||||||
expect(tooHigh >= 0 && tooHigh <= 1000).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -393,29 +414,23 @@ describe('Settings Update - Team Count Changes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject team count below 6', () => {
|
it('should reject team count below 6', () => {
|
||||||
const newTeamCount = 5;
|
expect(isValidTeamCount(5)).toBe(false);
|
||||||
const invalid = newTeamCount < 6 || newTeamCount > 16;
|
|
||||||
expect(invalid).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject team count above 16', () => {
|
it('should reject team count above 16', () => {
|
||||||
const newTeamCount = 17;
|
expect(isValidTeamCount(17)).toBe(false);
|
||||||
const invalid = newTeamCount < 6 || newTeamCount > 16;
|
|
||||||
expect(invalid).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject team count below the number of teams with owners', () => {
|
it('should reject team count below the number of teams with owners', () => {
|
||||||
const teamsWithOwners = 8;
|
const teamsWithOwners = 8;
|
||||||
const newTeamCount = 6;
|
const newTeamCount = 6;
|
||||||
const belowOwnerCount = newTeamCount < teamsWithOwners;
|
expect(newTeamCount < teamsWithOwners).toBe(true);
|
||||||
expect(belowOwnerCount).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a valid team count that is >= teams with owners', () => {
|
it('should allow a valid team count that is >= teams with owners', () => {
|
||||||
const teamsWithOwners = 6;
|
const teamsWithOwners = 6;
|
||||||
const newTeamCount = 10;
|
const newTeamCount = 10;
|
||||||
const valid = newTeamCount >= 6 && newTeamCount <= 16 && newTeamCount >= teamsWithOwners;
|
expect(isValidTeamCount(newTeamCount) && newTeamCount >= teamsWithOwners).toBe(true);
|
||||||
expect(valid).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create new teams when increasing team count', async () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue