Refactor league settings into per-section components (#379)
* Refactor league settings into per-section components (#347) Extract all settings sections from the monolithic 1009-line route file into individual components under app/components/league/settings/. Route file drops to ~300 lines. Separates draft-order dirty state from general settings dirty state, deduplicates section-change handling, and fixes several bugs found during review (typo in pointsFor5th, wrong mock in tests, lint violations). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix Stop hook loop: suppress output on typecheck success The Stop hook was producing stdout on every run, causing Claude Code to feed it back as context and rewake the model each turn. Now emits a systemMessage JSON only on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Typescript fix * Remove type asserting --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
049ec8a596
commit
05fe1493a3
19 changed files with 2694 additions and 1805 deletions
|
|
@ -18,7 +18,8 @@
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "npm run typecheck 2>&1"
|
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||||
|
"timeout": 60
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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