@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +697 -0
  3. package/dist/index.cjs +1014 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +528 -0
  6. package/dist/index.d.ts +528 -0
  7. package/dist/index.js +969 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +63 -0
  10. package/src/components/ChessClock/ChessClock.stories.tsx +782 -0
  11. package/src/components/ChessClock/index.ts +44 -0
  12. package/src/components/ChessClock/parts/Display.tsx +69 -0
  13. package/src/components/ChessClock/parts/PlayPause.tsx +190 -0
  14. package/src/components/ChessClock/parts/Reset.tsx +90 -0
  15. package/src/components/ChessClock/parts/Root.tsx +37 -0
  16. package/src/components/ChessClock/parts/Switch.tsx +84 -0
  17. package/src/components/ChessClock/parts/__tests__/Display.test.tsx +149 -0
  18. package/src/components/ChessClock/parts/__tests__/PlayPause.test.tsx +411 -0
  19. package/src/components/ChessClock/parts/__tests__/Reset.test.tsx +160 -0
  20. package/src/components/ChessClock/parts/__tests__/Root.test.tsx +49 -0
  21. package/src/components/ChessClock/parts/__tests__/Switch.test.tsx +204 -0
  22. package/src/hooks/__tests__/clockReducer.test.ts +985 -0
  23. package/src/hooks/__tests__/useChessClock.test.tsx +1080 -0
  24. package/src/hooks/clockReducer.ts +379 -0
  25. package/src/hooks/useChessClock.ts +406 -0
  26. package/src/hooks/useChessClockContext.ts +35 -0
  27. package/src/index.ts +65 -0
  28. package/src/types.ts +217 -0
  29. package/src/utils/__tests__/calculateSwitchTime.test.ts +150 -0
  30. package/src/utils/__tests__/formatTime.test.ts +83 -0
  31. package/src/utils/__tests__/timeControl.test.ts +414 -0
  32. package/src/utils/__tests__/timingMethods.test.ts +170 -0
  33. package/src/utils/calculateSwitchTime.ts +37 -0
  34. package/src/utils/formatTime.ts +59 -0
  35. package/src/utils/presets.ts +47 -0
  36. package/src/utils/timeControl.ts +205 -0
  37. 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
+ }