* Fix custom chess-clock timer detection and save settings blocker
- Extract getInitialDraftSpeed() helper so both useState init and
resetSettingsFormState use the same logic; unrecognized presets
now produce a "custom:{bank}:{incr}" string instead of falling
back to "standard", so the custom section auto-expands correctly
(e.g. 8 hr bank + 30 min increment is no longer shown as Standard)
- resetSettingsFormState was not resetting draftSpeed at all; fixed
- Clear hasUnsavedSettingsChanges in the Form's onSubmit so the
useBlocker check is false at the moment the save navigation starts,
preventing the "Leave without saving?" dialog from firing on save
- Replace the cleared-on-success useEffect with one that re-marks
dirty when the action returns an error, so the warning reappears
after a failed save
https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
* 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
* Fix lint: use !== null/undefined instead of != null
oxlint enforces eqeqeq; the null check in getInitialDraftSpeed used
!= which triggered two errors.
https://claude.ai/code/session_01DgHNkvJXGizx41CE2tkVS1
---------
Co-authored-by: Claude <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|