brackt/app/components/league/DraftSpeedPicker.tsx
Claude 0a17b9154a
Address code review: single source of truth for presets, useEffect guard, tests
- Extract CHESS_CLOCK_PRESETS (pure data) into draft-timer.ts as the
  canonical source of truth; DraftSpeedPicker.tsx now spreads those
  values into CHESS_PRESETS rather than duplicating the numbers
- getInitialDraftSpeed moved to draft-timer.ts and rewritten to use
  CHESS_CLOCK_PRESETS.find() — no more hardcoded bankSec/incrSec values
  in three separate places
- parseDraftSpeed switch replaced with CHESS_CLOCK_PRESETS.find() and
  an explicit fallback comment; eliminates the silent default-handles-
  "standard" fragility called out in review
- useEffect that re-marks settings dirty now guards against non-settings
  errors (draft-order, commissioner actions) which carry a `section`
  field — previously any error actionData would spuriously set
  hasUnsavedSettingsChanges=true
- Add parseDraftSpeed and getInitialDraftSpeed test suites to
  draft-timer.test.ts, including parametrised preset coverage via
  it.each(CHESS_CLOCK_PRESETS)

https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
2026-05-15 20:43:58 +00:00

282 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";
import { CHESS_CLOCK_PRESETS } from "~/lib/draft-timer";
// ─── 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;
}[] = [
{ ...CHESS_CLOCK_PRESETS[0], label: "Fast", icon: Zap, desc: "~1 hour" },
{ ...CHESS_CLOCK_PRESETS[1], label: "Standard", icon: Timer, desc: "~90 minutes" },
{ ...CHESS_CLOCK_PRESETS[2], label: "Slow", icon: Coffee, desc: "~1 week" },
{ ...CHESS_CLOCK_PRESETS[3], label: "Very Slow", icon: Snail, desc: "~2 weeks" },
];
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>
);
}