* 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>
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|