Extract reusable league wizard components; wire settings page (#350)

* Extract reusable league wizard components; wire settings page (#103)

Extracts 9 domain components from new.tsx and $leagueId.settings.tsx into
app/components/league/ (each with a Storybook story), consolidates wizard
form-building into wizard-state.ts, and updates the settings page to use
the shared components instead of hand-rolled duplicates. new.tsx shrinks
from ~2000 → ~1280 lines.

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

* Fix sports-season test: add participants to mock data

findDraftableSportsSeasons now returns participantCount, which requires
participants in the mock db response.

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

* Fix sports-season test: loosen makeMockDb type to Record<string, unknown>[]

typeof mockSeasons became too strict after adding participants to the mock
data, breaking inline arrays in other tests that don't include participants.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-28 22:24:13 -07:00 committed by GitHub
parent dbc23f14ce
commit e201ecd28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2521 additions and 700 deletions

View file

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DraftSpeedPicker } from "./DraftSpeedPicker";
const meta: Meta<typeof DraftSpeedPicker> = {
title: "League/DraftSpeedPicker",
component: DraftSpeedPicker,
parameters: { layout: "padded" },
args: { onChange: () => {} },
};
export default meta;
type Story = StoryObj<typeof DraftSpeedPicker>;
export const StandardPreset: Story = {
args: { timerMode: "standard", value: "90" },
};
export const StandardCustom: Story = {
args: { timerMode: "standard", value: "300" },
};
export const ChessClockStandard: Story = {
args: { timerMode: "chess_clock", value: "standard" },
};
export const ChessClockSlow: Story = {
args: { timerMode: "chess_clock", value: "slow" },
};
export const ChessClockCustom: Story = {
args: { timerMode: "chess_clock", value: "custom:7200:900" },
};
export const Disabled: Story = {
args: { timerMode: "chess_clock", value: "standard", disabled: true },
};

View file

@ -0,0 +1,281 @@
import { Coffee, Snail, Timer, Zap, type LucideIcon } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { cn } from "~/lib/utils";
// ─── Types & constants ────────────────────────────────────────────────────────
type TimeUnit = "sec" | "min" | "hr";
export const STANDARD_PRESETS: { value: string; label: string }[] = [
{ value: "15", label: "15 sec" },
{ value: "30", label: "30 sec" },
{ value: "60", label: "1 min" },
{ value: "90", label: "90 sec" },
{ value: "120", label: "2 min" },
{ value: "900", label: "15 min" },
{ value: "3600", label: "1 hr" },
{ value: "7200", label: "2 hr" },
{ value: "14400", label: "4 hr" },
{ value: "28800", label: "8 hr" },
{ value: "43200", label: "12 hr" },
];
export const CHESS_PRESETS: {
value: string;
label: string;
icon: LucideIcon;
desc: string;
bankSec: number;
incrSec: number;
}[] = [
{ value: "fast", label: "Fast", icon: Zap, desc: "~1 hour", bankSec: 60, incrSec: 10 },
{ value: "standard", label: "Standard", icon: Timer, desc: "~90 minutes", bankSec: 120, incrSec: 15 },
{ value: "slow", label: "Slow", icon: Coffee, desc: "~1 week", bankSec: 28800, incrSec: 3600 },
{ value: "very-slow", label: "Very Slow", icon: Snail, desc: "~2 weeks", bankSec: 43200, incrSec: 3600 },
];
export function isSlowPreset(s: string): boolean {
return s === "slow" || s === "very-slow";
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function secToDisplay(sec: number): [number, TimeUnit] {
if (sec >= 3600 && sec % 3600 === 0) return [sec / 3600, "hr"];
if (sec >= 60 && sec % 60 === 0) return [sec / 60, "min"];
return [sec, "sec"];
}
function displayToSec(amount: string, unit: TimeUnit): number {
const n = parseFloat(amount) || 0;
if (unit === "hr") return Math.round(n * 3600);
if (unit === "min") return Math.round(n * 60);
return Math.round(n);
}
export function formatSec(sec: number): string {
if (sec >= 3600 && sec % 3600 === 0) return `${sec / 3600} hr`;
if (sec >= 60 && sec % 60 === 0) return `${sec / 60} min`;
return `${sec} sec`;
}
export function formatDraftSpeed(speed: string, mode: "chess_clock" | "standard"): string {
if (mode === "standard") {
const s = parseInt(speed, 10);
if (s >= 3600) return `${s / 3600} hr`;
if (s >= 60) return `${s / 60} min`;
return `${s} sec`;
}
if (speed.startsWith("custom:")) {
const [, b, i] = speed.split(":");
return `Custom (${formatSec(parseInt(b, 10))} bank + ${formatSec(parseInt(i, 10))} / pick)`;
}
const map: Record<string, string> = {
fast: "Fast (1 min + 10 sec)",
standard: "Standard (2 min + 15 sec)",
slow: "Slow (8 hr + 1 hr)",
"very-slow": "Very Slow (12 hr + 1 hr)",
};
return map[speed] ?? speed;
}
// ─── Component ────────────────────────────────────────────────────────────────
export interface DraftSpeedPickerProps {
timerMode: "chess_clock" | "standard";
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}
export function DraftSpeedPicker({ timerMode, value, onChange, disabled }: DraftSpeedPickerProps) {
const isStdPreset = STANDARD_PRESETS.some((p) => p.value === value);
const isChessPreset = CHESS_PRESETS.some((p) => p.value === value);
const isCustom = timerMode === "standard" ? !isStdPreset : !isChessPreset;
// Derive display values from value prop so they always stay in sync.
const stdCustomSec = timerMode === "standard" && isCustom ? (parseInt(value, 10) || 90) : 90;
const [stdCustomAmt, stdCustomUnit] = secToDisplay(stdCustomSec);
const chessCustomParts = value.startsWith("custom:") ? value.split(":") : [];
const bankSec = chessCustomParts.length === 3 ? (parseInt(chessCustomParts[1], 10) || 120) : 120;
const incrSec = chessCustomParts.length === 3 ? (parseInt(chessCustomParts[2], 10) || 15) : 15;
const [bankAmt, bankUnit] = secToDisplay(bankSec);
const [incrAmt, incrUnit] = secToDisplay(incrSec);
if (timerMode === "standard") {
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
{STANDARD_PRESETS.map((p) => (
<button
key={p.value}
type="button"
disabled={disabled}
onClick={() => onChange(p.value)}
className={cn(
"py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
value === p.value ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{p.label}
</button>
))}
<button
type="button"
disabled={disabled}
onClick={() => onChange(isCustom ? value : "90")}
className={cn(
"py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
isCustom ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
Custom
</button>
</div>
{isCustom && (
<div className="flex items-center gap-2 pt-1">
<Input
type="number"
min={1}
value={stdCustomAmt}
disabled={disabled}
onChange={(e) => onChange(String(displayToSec(e.target.value, stdCustomUnit) || 90))}
className="w-24"
/>
<UnitSelect
value={stdCustomUnit}
onChange={(u) => onChange(String(displayToSec(String(stdCustomAmt), u) || 90))}
disabled={disabled}
/>
<span className="text-sm text-muted-foreground">per pick</span>
</div>
)}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex flex-col gap-2">
{CHESS_PRESETS.map((p) => {
const selected = value === p.value;
return (
<button
key={p.value}
type="button"
disabled={disabled}
onClick={() => onChange(p.value)}
className={cn(
"w-full flex items-center justify-between px-4 py-3 rounded-lg border-2 transition-all",
selected ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex items-center gap-3">
<GradientIcon
icon={p.icon}
className="h-5 w-5 shrink-0"
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
/>
<div className="text-left">
<p className="font-semibold text-sm">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.desc}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs uppercase tracking-wider text-muted-foreground">Bank</p>
<p className="text-base font-bold">{formatSec(p.bankSec)}</p>
</div>
<div className="w-px h-8 bg-border" />
<div className="text-right">
<p className="text-xs uppercase tracking-wider text-muted-foreground">+/Pick</p>
<p className="text-base font-bold text-primary">{formatSec(p.incrSec)}</p>
</div>
</div>
</button>
);
})}
<button
type="button"
disabled={disabled}
onClick={() => onChange(isCustom ? value : `custom:${bankSec}:${incrSec}`)}
className={cn(
"w-full py-2.5 rounded-lg border-2 text-sm font-semibold transition-all",
isCustom ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
Custom
</button>
</div>
{isCustom && (
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="space-y-1">
<Label className="text-xs">Time Bank</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
value={bankAmt}
disabled={disabled}
onChange={(e) => onChange(`custom:${displayToSec(e.target.value, bankUnit) || 120}:${incrSec}`)}
className="w-20"
/>
<UnitSelect
value={bankUnit}
onChange={(u) => onChange(`custom:${displayToSec(String(bankAmt), u) || 120}:${incrSec}`)}
disabled={disabled}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Per-pick Increment</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
value={incrAmt}
disabled={disabled}
onChange={(e) => onChange(`custom:${bankSec}:${displayToSec(e.target.value, incrUnit) || 15}`)}
className="w-20"
/>
<UnitSelect
value={incrUnit}
onChange={(u) => onChange(`custom:${bankSec}:${displayToSec(String(incrAmt), u) || 15}`)}
disabled={disabled}
/>
</div>
</div>
</div>
)}
</div>
);
}
function UnitSelect({ value, onChange, disabled }: { value: TimeUnit; onChange: (v: TimeUnit) => void; disabled?: boolean }) {
return (
<Select value={value} onValueChange={(v) => onChange(v as TimeUnit)} disabled={disabled}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sec">sec</SelectItem>
<SelectItem value="min">min</SelectItem>
<SelectItem value="hr">hr</SelectItem>
</SelectContent>
</Select>
);
}

View file

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OvernightPauseSettings } from "./OvernightPauseSettings";
const meta: Meta<typeof OvernightPauseSettings> = {
title: "League/OvernightPauseSettings",
component: OvernightPauseSettings,
parameters: { layout: "padded" },
args: {
onModeChange: () => {},
onStartChange: () => {},
onEndChange: () => {},
onTimezoneChange: () => {},
start: "23:00",
end: "07:00",
timezone: "America/Chicago",
commishTimezone: "America/Chicago",
},
};
export default meta;
type Story = StoryObj<typeof OvernightPauseSettings>;
export const Hidden: Story = {
args: { show: false, mode: "none" },
};
export const ModeNone: Story = {
args: { show: true, mode: "none" },
};
export const ModeLeague: Story = {
args: { show: true, mode: "league" },
};
export const ModePerUser: Story = {
args: { show: true, mode: "per_user" },
};
export const ModePerUserNoCommishTimezone: Story = {
args: { show: true, mode: "per_user", commishTimezone: null },
};
export const Disabled: Story = {
args: { show: true, mode: "league", disabled: true },
};

View file

@ -0,0 +1,121 @@
import { Ban, Globe, Moon, Sun, Users } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { TimezoneSelect } from "~/components/league/TimezoneSelect";
import { cn } from "~/lib/utils";
export interface OvernightPauseSettingsProps {
show: boolean;
mode: "none" | "league" | "per_user";
onModeChange: (v: "none" | "league" | "per_user") => void;
start: string;
onStartChange: (v: string) => void;
end: string;
onEndChange: (v: string) => void;
timezone: string;
onTimezoneChange: (v: string) => void;
commishTimezone?: string | null;
disabled?: boolean;
}
const PAUSE_MODES = [
{ value: "none" as const, icon: Ban, label: "No Pause", sub: "Timer always runs" },
{ value: "league" as const, icon: Globe, label: "League", sub: "Same pause for everyone" },
{ value: "per_user" as const, icon: Users, label: "Per Player", sub: "Pauses in each player's timezone" },
];
export function OvernightPauseSettings({
show,
mode,
onModeChange,
start,
onStartChange,
end,
onEndChange,
timezone,
onTimezoneChange,
commishTimezone,
disabled,
}: OvernightPauseSettingsProps) {
if (!show) return null;
return (
<div className="space-y-4">
<Label>Overnight Pause</Label>
<div className="grid grid-cols-3 gap-2">
{PAUSE_MODES.map(({ value, icon: Icon, label, sub }) => {
const selected = mode === value;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => onModeChange(value)}
className={cn(
"flex flex-col items-center gap-1 py-3 px-2 rounded-lg border-2 transition-all",
selected ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<GradientIcon
icon={Icon}
className="h-5 w-5"
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
/>
<span className="text-xs font-semibold">{label}</span>
<span className="text-xs text-muted-foreground text-center leading-tight">{sub}</span>
</button>
);
})}
</div>
{mode !== "none" && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Moon className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
<Label className="text-xs">Draft pauses</Label>
</div>
<Input
type="time"
value={start}
disabled={disabled}
onChange={(e) => onStartChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Sun className="h-3.5 w-3.5 text-yellow-400 shrink-0" />
<Label className="text-xs">Draft resumes</Label>
</div>
<Input
type="time"
value={end}
disabled={disabled}
onChange={(e) => onEndChange(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-sm">
{mode === "per_user" ? "Default Timezone" : "Timezone"}
</Label>
{mode === "per_user" && (
<p className="text-xs text-muted-foreground">
Each player&apos;s profile timezone is used. Players without one fall back to the timezone below.
{!commishTimezone && (
<> You haven&apos;t set your timezone yet <a href="/profile" className="font-medium underline underline-offset-2">set it in your profile</a>.</>
)}
</p>
)}
<TimezoneSelect value={timezone} onChange={onTimezoneChange} disabled={disabled} />
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Trophy } from "lucide-react";
import { ReviewSection } from "./ReviewSection";
const meta: Meta<typeof ReviewSection> = {
title: "League/ReviewSection",
component: ReviewSection,
parameters: { layout: "centered" },
args: {
icon: Trophy,
title: "Sports",
onEdit: () => {},
},
};
export default meta;
type Story = StoryObj<typeof ReviewSection>;
export const Default: Story = {
args: {
children: (
<ul className="space-y-1 text-sm">
<li>NBA 2025</li>
<li>NFL 2025</li>
</ul>
),
},
};

View file

@ -0,0 +1,28 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "~/components/ui/button";
import { GradientIcon } from "~/components/ui/GradientIcon";
export interface ReviewSectionProps {
icon: LucideIcon;
title: string;
onEdit: () => void;
children: ReactNode;
}
export function ReviewSection({ icon, title, onEdit, children }: ReviewSectionProps) {
return (
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<GradientIcon icon={icon} className="h-4 w-4" />
{title}
</h3>
<Button type="button" variant="outline" size="sm" onClick={onEdit} className="h-7 text-xs px-3">
Edit
</Button>
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "./ScoringPresetPicker";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
const meta: Meta<typeof ScoringPresetPicker> = {
title: "League/ScoringPresetPicker",
component: ScoringPresetPicker,
parameters: { layout: "padded" },
args: { onPresetChange: () => {}, onRulesChange: () => {} },
};
export default meta;
type Story = StoryObj<typeof ScoringPresetPicker>;
export const BracktDefault: Story = {
args: { preset: "brackt", rules: { ...DEFAULT_SCORING_RULES } },
};
export const OmnifantasyDefault: Story = {
args: { preset: "omnifantasy", rules: { ...OMNIFANTASY_SCORING } },
};
export const Custom: Story = {
args: {
preset: "custom",
rules: { pointsFor1st: 120, pointsFor2nd: 60, pointsFor3rd: 40, pointsFor4th: 30, pointsFor5th: 20, pointsFor6th: 15, pointsFor7th: 10, pointsFor8th: 5 },
},
};
export const Disabled: Story = {
args: { preset: "brackt", rules: { ...DEFAULT_SCORING_RULES }, disabled: true },
};

View file

@ -0,0 +1,108 @@
import { cn } from "~/lib/utils";
import { DEFAULT_SCORING_RULES, type ScoringRules } from "~/lib/scoring-types";
export const OMNIFANTASY_SCORING: ScoringRules = {
pointsFor1st: 80,
pointsFor2nd: 50,
pointsFor3rd: 30,
pointsFor4th: 30,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 20,
pointsFor8th: 20,
};
export const PLACEMENT_LABELS = [
{ key: "pointsFor1st" as const, label: "1st", color: "text-yellow-500" },
{ key: "pointsFor2nd" as const, label: "2nd", color: "text-slate-400" },
{ key: "pointsFor3rd" as const, label: "3rd", color: "text-orange-400" },
{ key: "pointsFor4th" as const, label: "4th", color: "text-sky-400" },
{ key: "pointsFor5th" as const, label: "5th", color: "text-green-500" },
{ key: "pointsFor6th" as const, label: "6th", color: "text-green-500" },
{ key: "pointsFor7th" as const, label: "7th", color: "text-emerald-400" },
{ key: "pointsFor8th" as const, label: "8th", color: "text-emerald-400" },
];
export interface ScoringPresetPickerProps {
preset: "brackt" | "omnifantasy" | "custom";
onPresetChange: (v: "brackt" | "omnifantasy" | "custom") => void;
rules: ScoringRules;
onRulesChange: (r: ScoringRules) => void;
disabled?: boolean;
}
export function ScoringPresetPicker({
preset,
onPresetChange,
rules,
onRulesChange,
disabled,
}: ScoringPresetPickerProps) {
const maxPoints = Math.max(...Object.values(rules));
function applyPreset(p: "brackt" | "omnifantasy") {
onPresetChange(p);
onRulesChange(p === "brackt" ? { ...DEFAULT_SCORING_RULES } : { ...OMNIFANTASY_SCORING });
}
function handleFieldChange(key: keyof ScoringRules, raw: string) {
const val = parseInt(raw, 10);
if (!isNaN(val) && val >= 0 && val <= 1000) {
onPresetChange("custom");
onRulesChange({ ...rules, [key]: val });
}
}
return (
<div className="space-y-4">
{/* Preset tabs */}
<div className="flex rounded-lg border border-border overflow-hidden">
{(["brackt", "omnifantasy", "custom"] as const).map((p) => (
<button
key={p}
type="button"
disabled={disabled}
onClick={() => p !== "custom" ? applyPreset(p) : onPresetChange("custom")}
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors",
preset === p
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{p === "brackt" ? "Brackt Default" : p === "omnifantasy" ? "Omnifantasy Default" : "Custom"}
</button>
))}
</div>
{/* Placement fields */}
<div className="space-y-3">
{PLACEMENT_LABELS.map(({ key, label, color }) => {
const val = rules[key];
const barWidth = maxPoints > 0 ? (val / maxPoints) * 100 : 0;
return (
<div key={key} className="flex items-center gap-3">
<span className={cn("text-sm font-medium w-8 shrink-0", color)}>{label}</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-200"
style={{ width: `${barWidth}%` }}
/>
</div>
<input
type="number"
value={val}
disabled={disabled}
onChange={(e) => handleFieldChange(key, e.target.value)}
min={0}
max={1000}
className="w-14 h-8 text-center text-sm font-semibold border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none disabled:opacity-50"
/>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SportIcon } from "./SportIcon";
const meta: Meta<typeof SportIcon> = {
title: "League/SportIcon",
component: SportIcon,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof SportIcon>;
// Icons are uploaded at runtime via the admin panel and are not committed to the
// repo, so we use a data-URI placeholder here instead of a real file path.
const PLACEHOLDER_SVG =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='10' fill='%234f46e5'/%3E%3C/svg%3E";
export const WithIcon: Story = {
args: {
sport: { name: "NBA", iconUrl: PLACEHOLDER_SVG },
},
};
export const Fallback: Story = {
args: {
sport: { name: "Unknown Sport", iconUrl: null },
},
};
export const SmallWithIcon: Story = {
args: {
sport: { name: "NFL", iconUrl: PLACEHOLDER_SVG },
size: "sm",
},
};
export const SmallFallback: Story = {
args: {
sport: { name: "Unknown Sport", iconUrl: null },
size: "sm",
},
};

View file

@ -0,0 +1,20 @@
export interface SportIconProps {
sport: { name: string; iconUrl: string | null };
size?: "sm" | "md";
}
export function SportIcon({ sport, size = "md" }: SportIconProps) {
const cls = size === "sm" ? "w-4 h-4" : "w-5 h-5";
if (sport.iconUrl) {
const isAbsolute = sport.iconUrl.startsWith("data:") || sport.iconUrl.startsWith("http") || sport.iconUrl.startsWith("/");
const src = isAbsolute ? sport.iconUrl : `/sports-icons/${sport.iconUrl}`;
return (
<img
src={src}
alt={sport.name}
className={`${cls} object-contain shrink-0`}
/>
);
}
return <span className={`${cls} rounded-sm bg-muted shrink-0 inline-block`} aria-hidden />;
}

View file

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StepperInput } from "./StepperInput";
const meta: Meta<typeof StepperInput> = {
title: "League/StepperInput",
component: StepperInput,
parameters: { layout: "centered" },
args: {
min: 6,
max: 16,
onChange: () => {},
},
};
export default meta;
type Story = StoryObj<typeof StepperInput>;
export const Middle: Story = {
args: { value: 10 },
};
export const AtMinimum: Story = {
args: { value: 6 },
};
export const AtMaximum: Story = {
args: { value: 16 },
};

View file

@ -0,0 +1,41 @@
export interface StepperInputProps {
value: number;
min: number;
max: number;
onChange: (v: number) => void;
decrementLabel?: string;
incrementLabel?: string;
}
export function StepperInput({
value,
min,
max,
onChange,
decrementLabel = "Decrease",
incrementLabel = "Increase",
}: StepperInputProps) {
return (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
className="w-9 h-9 rounded-md border border-input flex items-center justify-center text-lg font-medium hover:bg-accent transition-colors"
aria-label={decrementLabel}
disabled={value <= min}
>
</button>
<span className="w-10 text-center text-lg font-semibold tabular-nums">{value}</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
className="w-9 h-9 rounded-md border border-input flex items-center justify-center text-lg font-medium hover:bg-accent transition-colors"
aria-label={incrementLabel}
disabled={value >= max}
>
+
</button>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TimerModeSelector } from "./TimerModeSelector";
const meta: Meta<typeof TimerModeSelector> = {
title: "League/TimerModeSelector",
component: TimerModeSelector,
parameters: { layout: "padded" },
args: { onChange: () => {} },
};
export default meta;
type Story = StoryObj<typeof TimerModeSelector>;
export const ChessClockSelected: Story = {
args: { value: "chess_clock" },
};
export const StandardSelected: Story = {
args: { value: "standard" },
};
export const Disabled: Story = {
args: { value: "chess_clock", disabled: true },
};

View file

@ -0,0 +1,59 @@
import { ChessPawn, Hourglass } from "lucide-react";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { cn } from "~/lib/utils";
export interface TimerModeSelectorProps {
value: "chess_clock" | "standard";
onChange: (v: "chess_clock" | "standard") => void;
disabled?: boolean;
}
const MODES = [
{
value: "chess_clock" as const,
label: "Chess Clock",
icon: ChessPawn,
desc: "Time bank + bonus per pick; bank early to save time for tough decisions",
},
{
value: "standard" as const,
label: "Standard",
icon: Hourglass,
desc: "Traditional fixed timer; each pick gets the same amount of time, no banking or carry-over",
},
];
export function TimerModeSelector({ value, onChange, disabled }: TimerModeSelectorProps) {
return (
<div className="grid grid-cols-2 gap-3">
{MODES.map((mode) => {
const selected = value === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => onChange(mode.value)}
disabled={disabled}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
selected ? "border-primary" : "border-border hover:border-primary/60",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<GradientIcon
icon={mode.icon}
className="h-7 w-7"
style={selected ? undefined : { stroke: "var(--muted-foreground)" }}
/>
<span className={cn("text-sm font-semibold", !selected && "text-muted-foreground")}>
{mode.label}
</span>
<span className="text-xs text-muted-foreground text-center leading-tight">
{mode.desc}
</span>
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WizardAuthForm } from "./WizardAuthForm";
const meta: Meta<typeof WizardAuthForm> = {
title: "League/WizardAuthForm",
component: WizardAuthForm,
parameters: { layout: "centered" },
decorators: [
(Story) => (
<div className="w-[420px]">
<Story />
</div>
),
],
args: {
setAuthTab: () => {},
displayName: "",
setDisplayName: () => {},
authEmail: "",
setAuthEmail: () => {},
authPassword: "",
setAuthPassword: () => {},
userTimezone: "America/New_York",
setUserTimezone: () => {},
onSocialLogin: () => {},
submitting: false,
},
};
export default meta;
type Story = StoryObj<typeof WizardAuthForm>;
export const CreateTab: Story = {
args: { authTab: "create" },
};
export const LoginTab: Story = {
args: { authTab: "login" },
};
export const Submitting: Story = {
args: { authTab: "create", submitting: true },
};

View file

@ -0,0 +1,157 @@
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { TimezoneSelect } from "~/components/league/TimezoneSelect";
import { cn } from "~/lib/utils";
export interface WizardAuthFormProps {
authTab: "create" | "login";
setAuthTab: (v: "create" | "login") => void;
displayName: string;
setDisplayName: (v: string) => void;
authEmail: string;
setAuthEmail: (v: string) => void;
authPassword: string;
setAuthPassword: (v: string) => void;
userTimezone: string;
setUserTimezone: (v: string) => void;
onSocialLogin: (provider: "google" | "discord") => void;
submitting: boolean;
}
export function WizardAuthForm({
authTab,
setAuthTab,
displayName,
setDisplayName,
authEmail,
setAuthEmail,
authPassword,
setAuthPassword,
userTimezone,
setUserTimezone,
onSocialLogin,
submitting,
}: WizardAuthFormProps) {
return (
<div className="border-t pt-4">
<h3 className="text-base font-semibold mb-3">Your Account</h3>
<div className="flex rounded-lg border border-border overflow-hidden mb-4">
<button
type="button"
onClick={() => setAuthTab("create")}
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors",
authTab === "create" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
)}
>
Create Account
</button>
<button
type="button"
onClick={() => setAuthTab("login")}
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors",
authTab === "login" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
)}
>
Log In
</button>
</div>
<div className="space-y-2 mb-4">
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-2"
onClick={() => onSocialLogin("discord")}
disabled={submitting}
>
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.043.032.055a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
{authTab === "create" ? "Create Account & League with Discord" : "Log in with Discord & Create League"}
</Button>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-2"
onClick={() => onSocialLogin("google")}
disabled={submitting}
>
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
{authTab === "create" ? "Create Account & League with Google" : "Log in with Google & Create League"}
</Button>
</div>
<div className="flex items-center gap-2 mb-4">
<div className="flex-1 border-t border-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="flex-1 border-t border-border" />
</div>
<div className="space-y-3">
{authTab === "create" && (
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
type="text"
placeholder="Your name in the league"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="authEmail">Email</Label>
<Input
id="authEmail"
type="email"
placeholder="you@example.com"
value={authEmail}
onChange={(e) => setAuthEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="authPassword">Password</Label>
<Input
id="authPassword"
type="password"
placeholder={authTab === "create" ? "Choose a strong password" : "Your password"}
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
autoComplete={authTab === "create" ? "new-password" : "current-password"}
minLength={authTab === "create" ? 8 : undefined}
/>
</div>
{authTab === "create" && (
<div className="space-y-2">
<Label htmlFor="userTimezone">Timezone</Label>
<TimezoneSelect
value={userTimezone}
onChange={setUserTimezone}
name="userTimezone"
/>
</div>
)}
</div>
{authTab === "create" && (
<p className="text-xs text-muted-foreground mt-2">
By creating an account you agree to the{" "}
<a href="/terms" className="underline">Terms of Service</a> and{" "}
<a href="/privacy" className="underline">Privacy Policy</a>.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WizardStepper } from "./WizardStepper";
const meta: Meta<typeof WizardStepper> = {
title: "League/WizardStepper",
component: WizardStepper,
parameters: { layout: "padded" },
args: {
steps: ["Basics", "Sports", "Draft", "Scoring", "Review"],
onStepClick: () => {},
},
};
export default meta;
type Story = StoryObj<typeof WizardStepper>;
export const Step1: Story = {
args: { currentStep: 1 },
};
export const Step3: Story = {
args: { currentStep: 3 },
};
export const LastStep: Story = {
args: { currentStep: 5 },
};

View file

@ -0,0 +1,63 @@
import { Fragment } from "react";
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
export interface WizardStepperProps {
currentStep: number;
steps: string[];
onStepClick: (n: number) => void;
}
export function WizardStepper({ currentStep, steps, onStepClick }: WizardStepperProps) {
return (
<div className="mb-6">
<div className="flex items-center">
{steps.map((label, i) => {
const n = i + 1;
const done = n < currentStep;
const active = n === currentStep;
return (
<Fragment key={label}>
{i > 0 && (
<div className={cn("flex-1 h-0.5 transition-colors", done ? "bg-primary" : "bg-border")} />
)}
<button
type="button"
onClick={() => done && onStepClick(n)}
className={cn(
"shrink-0 w-9 h-9 rounded-full border-2 flex items-center justify-center text-sm font-semibold bg-background transition-colors",
done && "bg-primary border-primary text-primary-foreground",
active && !done && "border-primary text-primary",
!done && !active && "border-border text-muted-foreground",
done ? "cursor-pointer" : "cursor-default"
)}
>
{done ? <Check className="h-4 w-4" /> : n}
</button>
</Fragment>
);
})}
</div>
<div className="flex mt-1.5">
{steps.map((label, i) => {
const n = i + 1;
const done = n < currentStep;
const active = n === currentStep;
return (
<Fragment key={label}>
{i > 0 && <div className="flex-1" />}
<div className="shrink-0 w-9 flex justify-center">
<span className={cn(
"text-xs hidden sm:block whitespace-nowrap",
active ? "text-primary font-medium" : done ? "text-foreground" : "text-muted-foreground"
)}>
{label}
</span>
</div>
</Fragment>
);
})}
</div>
</div>
);
}

View file

@ -10,6 +10,16 @@
* - Between turns: all other teams' banks are left untouched. * - Between turns: all other teams' banks are left untouched.
*/ */
/**
* Formats a HH:MM time string as 12-hour time with AM/PM.
* e.g. "23:00" "11:00 PM", "07:30" "7:30 AM"
*/
export function formatTime12h(t: string): string {
const [h, m] = t.split(":").map(Number);
const ap = (h || 0) >= 12 ? "PM" : "AM";
return `${(h || 0) % 12 || 12}:${String(m || 0).padStart(2, "0")} ${ap}`;
}
/** /**
* Formats a seconds value for display. * Formats a seconds value for display.
* undefined "--:--" * undefined "--:--"
@ -57,6 +67,12 @@ export function parseDraftSpeed(
return { draftInitialTime: time, draftIncrementTime: time }; return { draftInitialTime: time, draftIncrementTime: time };
} }
if (draftSpeed?.startsWith("custom:")) {
const [, bankStr, incrStr] = draftSpeed.split(":");
const bank = parseInt(bankStr ?? "", 10);
const incr = parseInt(incrStr ?? "", 10);
if (!isNaN(bank) && !isNaN(incr)) return { draftInitialTime: bank, draftIncrementTime: incr };
}
switch (draftSpeed) { switch (draftSpeed) {
case "fast": case "fast":
return { draftInitialTime: 60, draftIncrementTime: 10 }; return { draftInitialTime: 60, draftIncrementTime: 10 };

71
app/lib/wizard-state.ts Normal file
View file

@ -0,0 +1,71 @@
import type { ScoringRules } from "~/lib/scoring-types";
export const WIZARD_SS_KEY = "brackt_new_league_wizard";
export type WizardSnapshot = {
leagueName: string;
teamCount: number;
joinAsPlayer: boolean;
selectedTemplate: string;
selectedSports: string[];
draftRounds: number;
draftDate: string;
draftTime: string;
timerMode: "chess_clock" | "standard";
draftSpeed: string;
overnightMode: "none" | "league" | "per_user";
overnightStart: string;
overnightEnd: string;
overnightTimezone: string;
scoringPreset: "brackt" | "omnifantasy" | "custom";
scoringRules: ScoringRules;
userTimezone: string;
};
type SS = { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void };
function ss(): SS | null {
const g = globalThis as Record<string, unknown>;
return (typeof g["sessionStorage"] !== "undefined" ? g["sessionStorage"] : null) as SS | null;
}
export function saveWizardState(snapshot: WizardSnapshot) {
try { ss()?.setItem(WIZARD_SS_KEY, JSON.stringify(snapshot)); } catch {}
}
export function loadWizardState(): WizardSnapshot | null {
try {
const raw = ss()?.getItem(WIZARD_SS_KEY);
return raw ? (JSON.parse(raw) as WizardSnapshot) : null;
} catch { return null; }
}
export function clearWizardState() {
try { ss()?.removeItem(WIZARD_SS_KEY); } catch {}
}
export function buildFormDataFromSnapshot(s: WizardSnapshot): FormData {
const fd = new FormData();
fd.append("name", s.leagueName);
fd.append("teamCount", s.teamCount.toString());
fd.append("templateId", s.selectedTemplate);
fd.append("draftRounds", s.draftRounds.toString());
if (s.draftDate && s.draftTime) {
fd.append("draftDateTime", new Date(`${s.draftDate}T${s.draftTime}`).toISOString());
}
fd.append("draftTimerMode", s.timerMode);
fd.append("draftSpeed", s.draftSpeed);
fd.append("overnightPauseMode", s.overnightMode);
if (s.overnightMode !== "none") {
fd.append("overnightPauseStart", s.overnightStart);
fd.append("overnightPauseEnd", s.overnightEnd);
fd.append("overnightPauseTimezone", s.overnightTimezone);
}
(["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"] as const
).forEach((k) => fd.append(k, s.scoringRules[k].toString()));
for (const id of s.selectedSports) fd.append("sportsSeasons", id);
if (s.joinAsPlayer) fd.append("joinAsPlayer", "on");
if (s.userTimezone) fd.append("userTimezone", s.userTimezone);
return fd;
}

View file

@ -36,12 +36,12 @@ const pastStart = "2020-01-01";
const pastEnd = "2020-12-31"; const pastEnd = "2020-12-31";
const mockSeasons = [ const mockSeasons = [
{ id: "season-1", name: "2025 NBA Playoffs", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "NBA" } }, { id: "season-1", name: "2025 NBA Playoffs", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "NBA" }, participants: [] },
{ id: "season-2", name: "2025 F1 Season", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "F1" } }, { id: "season-2", name: "2025 F1 Season", draftOn: today, draftOff: future, year: 2025, status: "upcoming", sport: { name: "F1" }, participants: [] },
{ id: "season-3", name: "2024 Old Golf", draftOn: pastStart, draftOff: pastEnd, year: 2024, status: "completed", sport: { name: "Golf" } }, { id: "season-3", name: "2024 Old Golf", draftOn: pastStart, draftOff: pastEnd, year: 2024, status: "completed", sport: { name: "Golf" }, participants: [] },
]; ];
function makeMockDb(seasons: typeof mockSeasons) { function makeMockDb(seasons: Record<string, unknown>[]) {
return { return {
query: { query: {
sportsSeasons: { sportsSeasons: {

View file

@ -122,10 +122,10 @@ export async function findAllSportsSeasons(): Promise<SportsSeasonListItem[]> {
}); });
} }
export async function findDraftableSportsSeasons(): Promise<SportsSeason[]> { export async function findDraftableSportsSeasons() {
const db = database(); const db = database();
const today = sql`CURRENT_DATE`; const today = sql`CURRENT_DATE`;
return await db.query.sportsSeasons.findMany({ const seasons = await db.query.sportsSeasons.findMany({
where: (ss, { and }) => where: (ss, { and }) =>
and( and(
lte(ss.draftOn, today), lte(ss.draftOn, today),
@ -134,8 +134,13 @@ export async function findDraftableSportsSeasons(): Promise<SportsSeason[]> {
orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)], orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)],
with: { with: {
sport: true, sport: true,
participants: { columns: { id: true } },
}, },
}); });
return seasons.map(({ participants, ...s }) => ({
...s,
participantCount: participants.length,
}));
} }
export async function updateSportsSeason( export async function updateSportsSeason(

View file

@ -4,6 +4,7 @@ export default [
index("routes/home.tsx"), index("routes/home.tsx"),
route("i/:inviteCode", "routes/i.$inviteCode.tsx"), route("i/:inviteCode", "routes/i.$inviteCode.tsx"),
route("leagues/new", "routes/leagues/new.tsx"), route("leagues/new", "routes/leagues/new.tsx"),
route("leagues/creating", "routes/leagues/creating.tsx"),
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"), route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"), route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
route("leagues/:leagueId/audit-log", "routes/leagues/$leagueId.audit-log.tsx"), route("leagues/:leagueId/audit-log", "routes/leagues/$leagueId.audit-log.tsx"),

View file

@ -27,6 +27,7 @@ import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer"; import { deleteSeasonTimers } from "~/models/draft-timer";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer"; import { parseDraftSpeed } from "~/lib/draft-timer";
import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import type { Route } from "./+types/$leagueId.settings"; import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
@ -65,7 +66,10 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor"; import { TimerModeSelector } from "~/components/league/TimerModeSelector";
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker";
import { toast } from "sonner"; import { toast } from "sonner";
function isValidHHMM(s: string): boolean { function isValidHHMM(s: string): boolean {
@ -315,42 +319,6 @@ export async function action(args: Route.ActionArgs) {
return { error: "No active season found" }; return { error: "No active season found" };
} }
if (intent === "update-sports") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" };
}
const selectedSports = formData.getAll("sportsSeasons");
const currentSportIds = new Set(
season.seasonSports?.map((s) => s.sportsSeason.id) || []
);
const newSportIds = new Set(selectedSports as string[]);
// Remove sports that are no longer selected
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
// Add newly selected sports
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId as string,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
return { success: true };
}
if (intent === "set-draft-order") { if (intent === "set-draft-order") {
if (season.status !== "pre_draft") { if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" }; return { error: "Cannot modify draft order after draft has started" };
@ -857,6 +825,43 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none"); const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none");
const [overnightStart, setOvernightStart] = useState(season?.overnightPauseStart ?? "23:00");
const [overnightEnd, setOvernightEnd] = useState(season?.overnightPauseEnd ?? "07:00");
const [overnightTimezone, setOvernightTimezone] = useState(season?.overnightPauseTimezone ?? commishTimezone ?? "");
const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => {
const r: ScoringRules = {
pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st,
pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd,
pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd,
pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th,
pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th,
pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th,
pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th,
pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th,
};
if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "brackt";
if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "omnifantasy";
return "custom";
});
const [scoringRules, setScoringRules] = useState<ScoringRules>({
pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st,
pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd,
pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd,
pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th,
pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th,
pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th,
pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th,
pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th,
});
const initTimerMode = season?.draftTimerMode ?? "chess_clock";
const [draftSpeed, setDraftSpeed] = useState(() => {
if (initTimerMode === "standard") return season?.draftIncrementTime?.toString() ?? "90";
if (season?.draftInitialTime === 60 && season?.draftIncrementTime === 10) return "fast";
if (season?.draftInitialTime === 120 && season?.draftIncrementTime === 15) return "standard";
if (season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600) return "slow";
if (season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600) return "very-slow";
return "standard";
});
const [selectedSports, setSelectedSports] = useState<Set<string>>( const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || []) new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
); );
@ -1194,76 +1199,15 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label> <Label>Timer Mode</Label>
<select <TimerModeSelector value={timerMode} onChange={setTimerMode} disabled={!canEditDraftRounds} />
id="draftTimerMode" <input type="hidden" name="draftTimerMode" value={timerMode} />
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
disabled={!canEditDraftRounds}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label> <Label>Draft Speed</Label>
<select <DraftSpeedPicker timerMode={timerMode} value={draftSpeed} onChange={setDraftSpeed} disabled={!canEditDraftRounds} />
id="draftSpeed" <input type="hidden" name="draftSpeed" value={draftSpeed} />
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={
timerMode === "standard"
? (season?.draftIncrementTime?.toString() ?? "90")
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
? "fast"
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
? "standard"
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
? "slow"
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
? "very-slow"
: "standard"
}
key={timerMode}
disabled={!canEditDraftRounds}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
{timerMode === "standard"
? "Time per pick — resets to this value after each selection"
: "Initial time bank + increment added after each pick"}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -1277,142 +1221,48 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
Autodraft will still fire for teams that have it enabled. Autodraft will still fire for teams that have it enabled.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent>
<div className="space-y-2"> <OvernightPauseSettings
<Label htmlFor="overnightPauseMode">Pause Mode</Label> show={true}
<select mode={overnightMode}
id="overnightPauseMode" onModeChange={setOvernightMode}
name="overnightPauseMode" start={overnightStart}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" onStartChange={setOvernightStart}
value={overnightMode} end={overnightEnd}
onChange={(e) => setOvernightMode(e.target.value as "none" | "league" | "per_user")} onEndChange={setOvernightEnd}
disabled={!canEditDraftRounds} timezone={overnightTimezone}
> onTimezoneChange={setOvernightTimezone}
<option value="none">No pause timer always runs</option> commishTimezone={commishTimezone}
<option value="league">League-wide one timezone for everyone</option> disabled={!canEditDraftRounds}
<option value="per_user">Per-user each player&apos;s own timezone</option> />
</select> <input type="hidden" name="overnightPauseMode" value={overnightMode} />
<p className="text-sm text-muted-foreground"> <input type="hidden" name="overnightPauseStart" value={overnightStart} />
Per-user mode: each player sets their timezone in their profile. League-wide mode: one timezone applies to all teams. <input type="hidden" name="overnightPauseEnd" value={overnightEnd} />
</p> <input type="hidden" name="overnightPauseTimezone" value={overnightTimezone} />
{overnightMode === "per_user" && (
<div className={`rounded-md border px-3 py-2.5 text-sm space-y-1 ${commishTimezone ? "border-border bg-muted/40 text-muted-foreground" : "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"}`}>
<p>Each player&apos;s profile timezone is used to determine their overnight window. Players without one set fall back to the timezone below.</p>
{!commishTimezone && (
<p>
You haven&apos;t set your timezone yet {" "}
<a href="/profile" className="font-medium underline underline-offset-2">
set it in your profile
</a>
.
</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="overnightPauseStart">Pause starts at</Label>
<input
id="overnightPauseStart"
name="overnightPauseStart"
type="time"
defaultValue={season?.overnightPauseStart ?? "23:00"}
disabled={!canEditDraftRounds}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseEnd">Pause ends at</Label>
<input
id="overnightPauseEnd"
name="overnightPauseEnd"
type="time"
defaultValue={season?.overnightPauseEnd ?? "07:00"}
disabled={!canEditDraftRounds}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="overnightPauseTimezone">
Timezone
<span className="text-muted-foreground font-normal ml-1">
(league mode) or fallback for users without one set (per-user mode)
</span>
</Label>
<select
id="overnightPauseTimezone"
name="overnightPauseTimezone"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={season?.overnightPauseTimezone ?? commishTimezone ?? ""}
disabled={!canEditDraftRounds}
>
<option value="">Select a timezone</option>
<optgroup label="North America">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Phoenix">Mountain Time Arizona (no DST)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
<option value="America/Toronto">Eastern Time Toronto</option>
<option value="America/Vancouver">Pacific Time Vancouver</option>
<option value="America/Winnipeg">Central Time Winnipeg</option>
<option value="America/Halifax">Atlantic Time Halifax</option>
<option value="America/Mexico_City">Central Time Mexico City</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo">Brasília Time (BRT)</option>
<option value="America/Argentina/Buenos_Aires">Argentina Time (ART)</option>
<option value="America/Bogota">Colombia Time (COT)</option>
<option value="America/Santiago">Chile Time (CLT)</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London">GMT / British Time (GMT/BST)</option>
<option value="Europe/Dublin">Irish Time (GMT/IST)</option>
<option value="Europe/Paris">Central European Time (CET/CEST)</option>
<option value="Europe/Berlin">Central European Time Berlin</option>
<option value="Europe/Moscow">Moscow Time (MSK)</option>
</optgroup>
<optgroup label="Asia / Middle East">
<option value="Asia/Dubai">Gulf Standard Time (GST)</option>
<option value="Asia/Kolkata">India Standard Time (IST)</option>
<option value="Asia/Singapore">Singapore Time (SGT)</option>
<option value="Asia/Shanghai">China Standard Time (CST)</option>
<option value="Asia/Tokyo">Japan Standard Time (JST)</option>
<option value="Asia/Seoul">Korea Standard Time (KST)</option>
</optgroup>
<optgroup label="Oceania">
<option value="Australia/Sydney">Australian Eastern Time (AEST/AEDT)</option>
<option value="Australia/Perth">Australian Western Time (AWST)</option>
<option value="Pacific/Auckland">New Zealand Time (NZST/NZDT)</option>
</optgroup>
<optgroup label="UTC">
<option value="UTC">UTC (Coordinated Universal Time)</option>
</optgroup>
</select>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Scoring Rules */} {/* Scoring Rules */}
<ScoringRulesEditor <Card>
scoringRules={season ? { <CardHeader>
pointsFor1st: season.pointsFor1st, <CardTitle>Scoring Rules</CardTitle>
pointsFor2nd: season.pointsFor2nd, <CardDescription>
pointsFor3rd: season.pointsFor3rd, Points awarded for each finishing position in each sports season.
pointsFor4th: season.pointsFor4th, </CardDescription>
pointsFor5th: season.pointsFor5th, </CardHeader>
pointsFor6th: season.pointsFor6th, <CardContent>
pointsFor7th: season.pointsFor7th, <ScoringPresetPicker
pointsFor8th: season.pointsFor8th, preset={scoringPreset}
} : undefined} onPresetChange={setScoringPreset}
disabled={!season || season.status !== "pre_draft"} rules={scoringRules}
/> onRulesChange={setScoringRules}
disabled={!season || season.status !== "pre_draft"}
/>
{(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))}
</CardContent>
</Card>
{/* Sports Seasons */} {/* Sports Seasons */}
<Card> <Card>

View file

@ -103,11 +103,10 @@ export async function loader(args: Route.LoaderArgs) {
recentScoreEvents: enrichedScoreEvents, recentScoreEvents: enrichedScoreEvents,
}; };
} catch (error) { } catch (error) {
if (error instanceof Response) {
throw error;
}
logger.error("Error loading standings:", error); logger.error("Error loading standings:", error);
logger.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error; throw error;
} }
} }

View file

@ -0,0 +1,67 @@
import { useEffect } from "react";
import { redirect, useFetcher } from "react-router";
import { Loader2 } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { loadWizardState, clearWizardState, buildFormDataFromSnapshot } from "~/lib/wizard-state";
import type { Route } from "./+types/creating";
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session?.user) return redirect("/leagues/new");
return null;
}
export function meta(): Route.MetaDescriptors {
return [{ title: "Creating League… - Brackt" }];
}
export default function CreatingLeague() {
const fetcher = useFetcher();
useEffect(() => {
const saved = loadWizardState();
if (!saved) {
window.location.replace("/leagues/new");
return;
}
fetcher.submit(buildFormDataFromSnapshot(saved), { method: "post", action: "/leagues/new" });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Clear saved state only after a confirmed failure so the user can retry if
// the server returns an error (a successful submit navigates away immediately).
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data !== null && fetcher.data !== undefined) {
clearWizardState();
}
}, [fetcher.state, fetcher.data]);
const submitError =
fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data
? String((fetcher.data as { error: unknown }).error)
: null;
if (submitError) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4 max-w-sm px-4">
<p className="text-destructive font-medium">{submitError}</p>
<a href="/leagues/new" className="text-sm underline text-muted-foreground">
Go back and try again
</a>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="h-10 w-10 animate-spin mx-auto text-primary" />
<div>
<p className="text-lg font-semibold">Creating your league</p>
<p className="text-sm text-muted-foreground mt-1">Logged in successfully. Just a moment.</p>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff