@react-chess-tools/react-chess-clock 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +697 -0
- package/dist/index.cjs +1014 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +528 -0
- package/dist/index.d.ts +528 -0
- package/dist/index.js +969 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/components/ChessClock/ChessClock.stories.tsx +782 -0
- package/src/components/ChessClock/index.ts +44 -0
- package/src/components/ChessClock/parts/Display.tsx +69 -0
- package/src/components/ChessClock/parts/PlayPause.tsx +190 -0
- package/src/components/ChessClock/parts/Reset.tsx +90 -0
- package/src/components/ChessClock/parts/Root.tsx +37 -0
- package/src/components/ChessClock/parts/Switch.tsx +84 -0
- package/src/components/ChessClock/parts/__tests__/Display.test.tsx +149 -0
- package/src/components/ChessClock/parts/__tests__/PlayPause.test.tsx +411 -0
- package/src/components/ChessClock/parts/__tests__/Reset.test.tsx +160 -0
- package/src/components/ChessClock/parts/__tests__/Root.test.tsx +49 -0
- package/src/components/ChessClock/parts/__tests__/Switch.test.tsx +204 -0
- package/src/hooks/__tests__/clockReducer.test.ts +985 -0
- package/src/hooks/__tests__/useChessClock.test.tsx +1080 -0
- package/src/hooks/clockReducer.ts +379 -0
- package/src/hooks/useChessClock.ts +406 -0
- package/src/hooks/useChessClockContext.ts +35 -0
- package/src/index.ts +65 -0
- package/src/types.ts +217 -0
- package/src/utils/__tests__/calculateSwitchTime.test.ts +150 -0
- package/src/utils/__tests__/formatTime.test.ts +83 -0
- package/src/utils/__tests__/timeControl.test.ts +414 -0
- package/src/utils/__tests__/timingMethods.test.ts +170 -0
- package/src/utils/calculateSwitchTime.ts +37 -0
- package/src/utils/formatTime.ts +59 -0
- package/src/utils/presets.ts +47 -0
- package/src/utils/timeControl.ts +205 -0
- package/src/utils/timingMethods.ts +103 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preset time controls for common chess formats
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { presets } from "@react-chess-tools/react-chess-clock";
|
|
7
|
+
*
|
|
8
|
+
* <ChessClock.Root timeControl={{ time: presets.blitz5_3 }}>
|
|
9
|
+
* <ChessClock.Display color="white" />
|
|
10
|
+
* <ChessClock.Display color="black" />
|
|
11
|
+
* </ChessClock.Root>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export const presets = {
|
|
15
|
+
// Bullet (< 3 minutes)
|
|
16
|
+
bullet1_0: { baseTime: 60, increment: 0 },
|
|
17
|
+
bullet1_1: { baseTime: 60, increment: 1 },
|
|
18
|
+
bullet2_1: { baseTime: 120, increment: 1 },
|
|
19
|
+
|
|
20
|
+
// Blitz (3-10 minutes)
|
|
21
|
+
blitz3_0: { baseTime: 180, increment: 0 },
|
|
22
|
+
blitz3_2: { baseTime: 180, increment: 2 },
|
|
23
|
+
blitz5_0: { baseTime: 300, increment: 0 },
|
|
24
|
+
blitz5_3: { baseTime: 300, increment: 3 },
|
|
25
|
+
|
|
26
|
+
// Rapid (10-60 minutes)
|
|
27
|
+
rapid10_0: { baseTime: 600, increment: 0 },
|
|
28
|
+
rapid10_5: { baseTime: 600, increment: 5 },
|
|
29
|
+
rapid15_10: { baseTime: 900, increment: 10 },
|
|
30
|
+
|
|
31
|
+
// Classical (≥ 60 minutes)
|
|
32
|
+
classical30_0: { baseTime: 1800, increment: 0 },
|
|
33
|
+
classical90_30: { baseTime: 5400, increment: 30 },
|
|
34
|
+
|
|
35
|
+
// Tournament (multi-period)
|
|
36
|
+
fideClassical: [
|
|
37
|
+
{ baseTime: 5400, increment: 30, moves: 40 },
|
|
38
|
+
{ baseTime: 1800, increment: 30, moves: 20 },
|
|
39
|
+
{ baseTime: 900, increment: 30 },
|
|
40
|
+
],
|
|
41
|
+
|
|
42
|
+
uscfClassical: [
|
|
43
|
+
{ baseTime: 7200, moves: 40 },
|
|
44
|
+
{ baseTime: 3600, moves: 20 },
|
|
45
|
+
{ baseTime: 1800 },
|
|
46
|
+
],
|
|
47
|
+
} as const;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NormalizedTimeControl,
|
|
3
|
+
TimeControl,
|
|
4
|
+
TimeControlConfig,
|
|
5
|
+
TimeControlInput,
|
|
6
|
+
TimeControlPhase,
|
|
7
|
+
TimeControlString,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regex to match time control strings like "5+3", "10", or "0.5"
|
|
12
|
+
*/
|
|
13
|
+
const TIME_CONTROL_REGEX = /^(\d+(?:\.\d+)?)(?:\+(\d+(?:\.\d+)?))?$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regex to match a single period in multi-period time control
|
|
17
|
+
* Matches: "40/90+30", "sd/30+30", "SD/30+30", "G/30+30", "40/90"
|
|
18
|
+
* Group 1: moves count (e.g., "40" in "40/90")
|
|
19
|
+
* Group 2: time in minutes (e.g., "90" in "40/90+30")
|
|
20
|
+
* Group 3: increment in seconds (e.g., "30" in "40/90+30")
|
|
21
|
+
*/
|
|
22
|
+
const PERIOD_REGEX =
|
|
23
|
+
/^(?:(?:(\d+)|sd|g)\/)?(\d+(?:\.\d+)?)(?:\+(\d+(?:\.\d+)?))?$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a time control string into a TimeControl object
|
|
27
|
+
* @param input - Time control string (e.g., "5+3", "10")
|
|
28
|
+
* @returns Parsed time control with baseTime in seconds
|
|
29
|
+
* @throws Error if input is invalid
|
|
30
|
+
*/
|
|
31
|
+
export function parseTimeControlString(input: TimeControlString): TimeControl {
|
|
32
|
+
const match = input.match(TIME_CONTROL_REGEX);
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Invalid time control string: "${input}". Expected format: "5+3" (minutes + increment) or "10" (minutes only)`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const minutes = parseFloat(match[1]);
|
|
40
|
+
const increment = match[2] ? parseFloat(match[2]) : 0;
|
|
41
|
+
|
|
42
|
+
// Convert minutes to seconds
|
|
43
|
+
return {
|
|
44
|
+
baseTime: Math.round(minutes * 60),
|
|
45
|
+
increment: Math.round(increment),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a single period string into a TimeControlPhase object
|
|
51
|
+
* @param periodStr - Period string (e.g., "40/90+30", "sd/30+30", "G/30")
|
|
52
|
+
* @returns Parsed period with baseTime in seconds
|
|
53
|
+
* @throws Error if input is invalid
|
|
54
|
+
*/
|
|
55
|
+
function parsePeriod(periodStr: string): TimeControlPhase {
|
|
56
|
+
const trimmed = periodStr.trim();
|
|
57
|
+
const match = trimmed.match(PERIOD_REGEX);
|
|
58
|
+
|
|
59
|
+
if (!match) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Invalid period format: "${trimmed}". Expected format: "40/90+30" or "sd/30+30"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [, movesStr, timeStr, incrementStr] = match;
|
|
66
|
+
const moves = movesStr ? parseInt(movesStr, 10) : undefined;
|
|
67
|
+
const minutes = parseFloat(timeStr);
|
|
68
|
+
const increment = incrementStr ? parseFloat(incrementStr) : 0;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
baseTime: Math.round(minutes * 60),
|
|
72
|
+
increment: increment > 0 ? Math.round(increment) : undefined,
|
|
73
|
+
moves,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a multi-period time control string into an array of TimeControlPhase
|
|
79
|
+
* Format: "40/90+30,sd/30+30" or "40/120+30,20/60+30,g/15+30"
|
|
80
|
+
*
|
|
81
|
+
* Period format: [moves/]time[+increment] or [SD|G/]time[+increment]
|
|
82
|
+
* - moves: Number of moves required for this period (e.g., "40" in "40/90+30")
|
|
83
|
+
* - time: Time in minutes for this period (e.g., "90" in "40/90+30")
|
|
84
|
+
* - increment: Increment in seconds after each move (e.g., "30" in "40/90+30")
|
|
85
|
+
* - SD or G prefix: Sudden death period (no moves requirement, overrides any moves prefix)
|
|
86
|
+
*
|
|
87
|
+
* @param input - Multi-period time control string
|
|
88
|
+
* @returns Array of TimeControlPhase with times in seconds
|
|
89
|
+
* @throws Error if input is invalid
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* parseMultiPeriodTimeControl("40/90+30,sd/30+30")
|
|
94
|
+
* // Returns: [
|
|
95
|
+
* // { baseTime: 5400, increment: 30, moves: 40 },
|
|
96
|
+
* // { baseTime: 1800, increment: 30 }
|
|
97
|
+
* // ]
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function parseMultiPeriodTimeControl(input: string): TimeControlPhase[] {
|
|
101
|
+
const parts = input.split(/\s*,\s*/);
|
|
102
|
+
|
|
103
|
+
// Note: input.split() always returns at least one element, so this is safe
|
|
104
|
+
return parts.map((part) => parsePeriod(part));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalize any time control input into a NormalizedTimeControl
|
|
109
|
+
* @param input - Time control string, object, or array of periods
|
|
110
|
+
* @param timingMethod - Optional timing method (defaults to "fischer")
|
|
111
|
+
* @param clockStart - Optional clock start mode (defaults to "delayed")
|
|
112
|
+
* @returns Normalized time control with times in milliseconds
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeTimeControl(
|
|
115
|
+
input: TimeControlInput,
|
|
116
|
+
timingMethod: NormalizedTimeControl["timingMethod"],
|
|
117
|
+
clockStart: NormalizedTimeControl["clockStart"],
|
|
118
|
+
): NormalizedTimeControl {
|
|
119
|
+
// Handle multi-period time control
|
|
120
|
+
if (Array.isArray(input)) {
|
|
121
|
+
if (input.length === 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"Multi-period time control must have at least one period",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Clone the input to avoid mutating the caller's data
|
|
128
|
+
// If the last period has a moves requirement, remove it (final period should be sudden death)
|
|
129
|
+
// Convert all period times from seconds to milliseconds for internal consistency
|
|
130
|
+
const periods: TimeControlPhase[] = input.map((period) => ({
|
|
131
|
+
...period,
|
|
132
|
+
baseTime: period.baseTime * 1000,
|
|
133
|
+
increment:
|
|
134
|
+
period.increment !== undefined ? period.increment * 1000 : undefined,
|
|
135
|
+
delay: period.delay !== undefined ? period.delay * 1000 : undefined,
|
|
136
|
+
}));
|
|
137
|
+
const lastPeriod = periods[periods.length - 1];
|
|
138
|
+
if (lastPeriod.moves !== undefined) {
|
|
139
|
+
lastPeriod.moves = undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const firstPeriod = periods[0];
|
|
143
|
+
return {
|
|
144
|
+
baseTime: firstPeriod.baseTime,
|
|
145
|
+
increment: firstPeriod.increment ?? 0,
|
|
146
|
+
delay: firstPeriod.delay ?? 0,
|
|
147
|
+
timingMethod,
|
|
148
|
+
clockStart,
|
|
149
|
+
periods,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Handle single period time control
|
|
154
|
+
let parsed: TimeControl;
|
|
155
|
+
|
|
156
|
+
if (typeof input === "string") {
|
|
157
|
+
parsed = parseTimeControlString(input);
|
|
158
|
+
} else {
|
|
159
|
+
parsed = input;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
baseTime: parsed.baseTime * 1000, // Convert to milliseconds
|
|
164
|
+
increment: (parsed.increment ?? 0) * 1000, // Convert to milliseconds
|
|
165
|
+
delay: (parsed.delay ?? 0) * 1000, // Convert to milliseconds
|
|
166
|
+
timingMethod,
|
|
167
|
+
clockStart,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse complete TimeControlConfig into NormalizedTimeControl
|
|
173
|
+
* @param config - Time control configuration
|
|
174
|
+
* @returns Fully normalized time control
|
|
175
|
+
*/
|
|
176
|
+
export function parseTimeControlConfig(
|
|
177
|
+
config: TimeControlConfig,
|
|
178
|
+
): NormalizedTimeControl {
|
|
179
|
+
const normalized = normalizeTimeControl(
|
|
180
|
+
config.time,
|
|
181
|
+
config.timingMethod ?? "fischer",
|
|
182
|
+
config.clockStart ?? "delayed",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
...normalized,
|
|
187
|
+
whiteTimeOverride: config.whiteTime ? config.whiteTime * 1000 : undefined,
|
|
188
|
+
blackTimeOverride: config.blackTime ? config.blackTime * 1000 : undefined,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get initial times from a normalized time control
|
|
194
|
+
* @param config - Normalized time control
|
|
195
|
+
* @returns Initial times for white and black in milliseconds
|
|
196
|
+
*/
|
|
197
|
+
export function getInitialTimes(config: NormalizedTimeControl): {
|
|
198
|
+
white: number;
|
|
199
|
+
black: number;
|
|
200
|
+
} {
|
|
201
|
+
return {
|
|
202
|
+
white: config.whiteTimeOverride ?? config.baseTime,
|
|
203
|
+
black: config.blackTimeOverride ?? config.baseTime,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { NormalizedTimeControl, TimingMethod } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apply Fischer increment
|
|
5
|
+
* Adds the full increment amount after each move
|
|
6
|
+
* @param currentTime - Current time in milliseconds
|
|
7
|
+
* @param increment - Increment in milliseconds
|
|
8
|
+
* @returns New time with increment added
|
|
9
|
+
*/
|
|
10
|
+
export function applyFischerIncrement(
|
|
11
|
+
currentTime: number,
|
|
12
|
+
increment: number,
|
|
13
|
+
): number {
|
|
14
|
+
return currentTime + increment;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculate time to decrement for simple delay method
|
|
19
|
+
* Clock doesn't decrement during the delay period
|
|
20
|
+
* @param timeSpent - Time spent in current move in milliseconds
|
|
21
|
+
* @param delay - Delay in milliseconds
|
|
22
|
+
* @returns Actual time to decrement (0 if within delay period)
|
|
23
|
+
*/
|
|
24
|
+
export function applySimpleDelay(timeSpent: number, delay: number): number {
|
|
25
|
+
if (timeSpent <= delay) {
|
|
26
|
+
return 0; // Still within delay period, no decrement
|
|
27
|
+
}
|
|
28
|
+
return timeSpent - delay; // Decrement actual time spent after delay
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Calculate Bronstein delay adjustment
|
|
33
|
+
* Adds back the actual time used, up to the delay amount
|
|
34
|
+
* @param currentTime - Current time in milliseconds
|
|
35
|
+
* @param timeSpent - Time spent in current move in milliseconds
|
|
36
|
+
* @param delay - Delay in milliseconds
|
|
37
|
+
* @returns New time with Bronstein adjustment
|
|
38
|
+
*/
|
|
39
|
+
export function applyBronsteinDelay(
|
|
40
|
+
currentTime: number,
|
|
41
|
+
timeSpent: number,
|
|
42
|
+
delay: number,
|
|
43
|
+
): number {
|
|
44
|
+
const addBack = Math.min(timeSpent, delay);
|
|
45
|
+
return currentTime + addBack;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calculate the time adjustment when switching players
|
|
50
|
+
* @param timingMethod - The timing method to use
|
|
51
|
+
* @param currentTime - Current time in milliseconds
|
|
52
|
+
* @param timeSpent - Time spent in current move in milliseconds
|
|
53
|
+
* @param config - Normalized time control config
|
|
54
|
+
* @returns New time in milliseconds
|
|
55
|
+
*/
|
|
56
|
+
export function calculateSwitchAdjustment(
|
|
57
|
+
timingMethod: TimingMethod,
|
|
58
|
+
currentTime: number,
|
|
59
|
+
timeSpent: number,
|
|
60
|
+
config: NormalizedTimeControl,
|
|
61
|
+
): number {
|
|
62
|
+
switch (timingMethod) {
|
|
63
|
+
case "fischer":
|
|
64
|
+
return applyFischerIncrement(currentTime, config.increment);
|
|
65
|
+
|
|
66
|
+
case "delay":
|
|
67
|
+
// For delay, the adjustment happens during ticking, not on switch
|
|
68
|
+
// Just return current time (delay is handled in tick calculation)
|
|
69
|
+
return currentTime;
|
|
70
|
+
|
|
71
|
+
case "bronstein":
|
|
72
|
+
return applyBronsteinDelay(currentTime, timeSpent, config.delay);
|
|
73
|
+
|
|
74
|
+
default:
|
|
75
|
+
return currentTime;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Calculate initial active player based on clock start mode
|
|
81
|
+
* @param clockStart - Clock start mode
|
|
82
|
+
* @returns Initial active player or null
|
|
83
|
+
*/
|
|
84
|
+
export function getInitialActivePlayer(
|
|
85
|
+
clockStart: "delayed" | "immediate" | "manual",
|
|
86
|
+
): "white" | null {
|
|
87
|
+
// For "immediate", white starts immediately
|
|
88
|
+
// For "delayed" and "manual", no active player until first move/start
|
|
89
|
+
return clockStart === "immediate" ? "white" : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get initial status based on clock start mode
|
|
94
|
+
* @param clockStart - Clock start mode
|
|
95
|
+
* @returns Initial clock status
|
|
96
|
+
*/
|
|
97
|
+
export function getInitialStatus(
|
|
98
|
+
clockStart: "delayed" | "immediate" | "manual",
|
|
99
|
+
): "idle" | "delayed" | "running" {
|
|
100
|
+
if (clockStart === "immediate") return "running";
|
|
101
|
+
if (clockStart === "delayed") return "delayed";
|
|
102
|
+
return "idle";
|
|
103
|
+
}
|