@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,406 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useReducer,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import type {
10
+ ClockColor,
11
+ ClockInfo,
12
+ ClockMethods,
13
+ ClockTimes,
14
+ PeriodState,
15
+ TimeControlConfig,
16
+ TimeControlInput,
17
+ UseChessClockReturn,
18
+ } from "../types";
19
+ import { parseTimeControlConfig, getInitialTimes } from "../utils/timeControl";
20
+ import { formatClockTime } from "../utils/formatTime";
21
+ import { calculateSwitchTime } from "../utils/calculateSwitchTime";
22
+ import {
23
+ getInitialActivePlayer,
24
+ getInitialStatus,
25
+ } from "../utils/timingMethods";
26
+ import { clockReducer, createInitialClockState } from "./clockReducer";
27
+
28
+ /** Default config used internally when clock is disabled */
29
+ const DISABLED_CLOCK_CONFIG: TimeControlConfig = {
30
+ time: { baseTime: 0 },
31
+ };
32
+
33
+ /**
34
+ * Serializes time-relevant config for change detection.
35
+ * Only includes properties that should trigger a clock reset.
36
+ * Callbacks are intentionally excluded - they use the ref pattern.
37
+ */
38
+ function serializeTimeRelevantConfig(config: TimeControlConfig): string {
39
+ return JSON.stringify({
40
+ time: config.time,
41
+ timingMethod: config.timingMethod,
42
+ clockStart: config.clockStart,
43
+ whiteTime: config.whiteTime,
44
+ blackTime: config.blackTime,
45
+ });
46
+ }
47
+
48
+ function calculateDisplayTime(
49
+ baseTime: number,
50
+ moveStartTime: number | null,
51
+ elapsedAtPause: number,
52
+ timingMethod: string,
53
+ delay: number,
54
+ ): number {
55
+ // When paused, use the elapsed time stored at pause moment
56
+ if (moveStartTime === null) {
57
+ let effectiveElapsed = elapsedAtPause;
58
+ if (timingMethod === "delay") {
59
+ effectiveElapsed = Math.max(0, elapsedAtPause - delay);
60
+ }
61
+ return Math.max(0, baseTime - effectiveElapsed);
62
+ }
63
+
64
+ const now = Date.now();
65
+ const rawElapsed = now - moveStartTime;
66
+
67
+ // Apply delay method: time doesn't decrement during delay period
68
+ let effectiveElapsed = rawElapsed;
69
+ if (timingMethod === "delay") {
70
+ effectiveElapsed = Math.max(0, rawElapsed - delay);
71
+ }
72
+
73
+ return Math.max(0, baseTime - effectiveElapsed);
74
+ }
75
+
76
+ /**
77
+ * Main hook for chess clock state management.
78
+ * Provides timing functionality for chess games with various timing methods.
79
+ *
80
+ * For server-authoritative clocks, use `methods.setTime()` to sync server times.
81
+ * The clock will restart its display interpolation from the new value on each call.
82
+ *
83
+ * **Auto-reset behavior:** The clock automatically resets when time-relevant
84
+ * options change (`time`, `timingMethod`, `clockStart`, `whiteTime`, `blackTime`).
85
+ * Callbacks (`onTimeout`, `onSwitch`, `onTimeUpdate`) do not trigger a reset
86
+ * and can be changed without affecting clock state.
87
+ *
88
+ * @param options - Clock configuration options
89
+ * @returns Clock state, info, and methods
90
+ */
91
+ export function useChessClock(options: TimeControlConfig): UseChessClockReturn {
92
+ // Initialize reducer with computed initial state.
93
+ // All parsing happens inside the initializer function to ensure it only runs once
94
+ // (on mount), avoiding wasteful re-computation when options is passed inline
95
+ // as a new object reference on every render.
96
+ const [state, dispatch] = useReducer(clockReducer, null, () => {
97
+ const initialConfig = parseTimeControlConfig(options);
98
+ const initialTimesValue = getInitialTimes(initialConfig);
99
+
100
+ // Initialize period state for multi-period time controls
101
+ const initialPeriodState: PeriodState | undefined =
102
+ initialConfig.periods && initialConfig.periods.length > 1
103
+ ? {
104
+ periodIndex: { white: 0, black: 0 },
105
+ periodMoves: { white: 0, black: 0 },
106
+ periods: initialConfig.periods,
107
+ }
108
+ : undefined;
109
+
110
+ return createInitialClockState(
111
+ initialTimesValue,
112
+ getInitialStatus(initialConfig.clockStart),
113
+ getInitialActivePlayer(initialConfig.clockStart),
114
+ initialConfig,
115
+ initialPeriodState,
116
+ );
117
+ });
118
+
119
+ // Options ref for callbacks (avoid stale closures)
120
+ const optionsRef = useRef(options);
121
+ optionsRef.current = options;
122
+
123
+ // State ref for callbacks (avoid stale closures)
124
+ const stateRef = useRef(state);
125
+ stateRef.current = state;
126
+
127
+ // ============================================================================
128
+ // AUTO-RESET ON OPTIONS CHANGE
129
+ // ============================================================================
130
+
131
+ // Auto-reset when time-relevant options change (not callbacks)
132
+ const configKey = serializeTimeRelevantConfig(options);
133
+ const prevConfigRef = useRef<string>(configKey);
134
+
135
+ useEffect(() => {
136
+ if (prevConfigRef.current !== configKey) {
137
+ prevConfigRef.current = configKey;
138
+ dispatch({ type: "RESET", payload: { ...options, now: Date.now() } });
139
+ }
140
+ }, [configKey, options]);
141
+
142
+ // ============================================================================
143
+ // DISPLAY STATE
144
+ // ============================================================================
145
+
146
+ // Tick counter for triggering re-renders when clock is running
147
+ const [tick, forceUpdate] = useState(0);
148
+
149
+ // Display times derived during render (no useMemo — state values change frequently)
150
+ const displayTimes: ClockTimes =
151
+ state.activePlayer === null
152
+ ? state.times
153
+ : {
154
+ ...state.times,
155
+ [state.activePlayer]: calculateDisplayTime(
156
+ state.times[state.activePlayer],
157
+ state.moveStartTime,
158
+ state.elapsedAtPause,
159
+ state.config.timingMethod,
160
+ state.config.delay,
161
+ ),
162
+ };
163
+
164
+ // Derive primitive for timeout detection (prevents effect running on every render)
165
+ const activePlayerTimedOut =
166
+ state.status === "running" &&
167
+ state.activePlayer !== null &&
168
+ displayTimes[state.activePlayer] <= 0;
169
+
170
+ // ============================================================================
171
+ // DISPLAY UPDATE LOOP (100ms interval)
172
+ // ============================================================================
173
+
174
+ useEffect(() => {
175
+ if (state.status !== "running" || state.activePlayer === null) {
176
+ return;
177
+ }
178
+
179
+ const intervalId = setInterval(() => forceUpdate((c) => c + 1), 100);
180
+
181
+ return () => clearInterval(intervalId);
182
+ }, [state.status, state.activePlayer]);
183
+
184
+ // Notify consumer of time updates (only on clock ticks, not on every render)
185
+ useEffect(() => {
186
+ if (state.status === "running" && state.activePlayer !== null) {
187
+ optionsRef.current.onTimeUpdate?.(displayTimes);
188
+ }
189
+ }, [tick]);
190
+
191
+ // Timeout Detection
192
+ useEffect(() => {
193
+ if (activePlayerTimedOut && state.activePlayer !== null) {
194
+ dispatch({ type: "TIMEOUT", payload: { player: state.activePlayer } });
195
+ optionsRef.current.onTimeout?.(state.activePlayer);
196
+ }
197
+ }, [activePlayerTimedOut, state.activePlayer]);
198
+
199
+ // Active Player Change Callback
200
+ const previousActivePlayerRef = useRef(state.activePlayer);
201
+ useEffect(() => {
202
+ if (
203
+ state.activePlayer !== previousActivePlayerRef.current &&
204
+ state.activePlayer !== null
205
+ ) {
206
+ optionsRef.current.onSwitch?.(state.activePlayer);
207
+ }
208
+ previousActivePlayerRef.current = state.activePlayer;
209
+ }, [state.activePlayer]);
210
+
211
+ // ============================================================================
212
+ // COMPUTED INFO
213
+ // ============================================================================
214
+
215
+ const info = useMemo<ClockInfo>(
216
+ () => ({
217
+ isRunning: state.status === "running",
218
+ isPaused: state.status === "paused",
219
+ isFinished: state.status === "finished",
220
+ isWhiteActive: state.activePlayer === "white",
221
+ isBlackActive: state.activePlayer === "black",
222
+ hasTimeout: state.timeout !== null,
223
+ // Time odds is based on initial configuration, not current remaining time
224
+ hasTimeOdds: state.initialTimes.white !== state.initialTimes.black,
225
+ }),
226
+ [
227
+ state.status,
228
+ state.activePlayer,
229
+ state.timeout,
230
+ state.initialTimes.white,
231
+ state.initialTimes.black,
232
+ ],
233
+ );
234
+
235
+ // ============================================================================
236
+ // METHODS
237
+ // ============================================================================
238
+
239
+ const start = useCallback(() => {
240
+ dispatch({ type: "START", payload: { now: Date.now() } });
241
+ }, []);
242
+
243
+ const pause = useCallback(() => {
244
+ dispatch({ type: "PAUSE", payload: { now: Date.now() } });
245
+ }, []);
246
+
247
+ const resume = useCallback(() => {
248
+ dispatch({ type: "RESUME", payload: { now: Date.now() } });
249
+ }, []);
250
+
251
+ const switchPlayer = useCallback(() => {
252
+ const currentState = stateRef.current;
253
+ const now = Date.now();
254
+
255
+ // Calculate time adjustment for current player (not in delayed mode)
256
+ let newTimes: ClockTimes | undefined;
257
+ if (
258
+ currentState.status !== "delayed" &&
259
+ currentState.activePlayer &&
260
+ currentState.moveStartTime !== null
261
+ ) {
262
+ const timeSpent = now - currentState.moveStartTime;
263
+ const currentTime = currentState.times[currentState.activePlayer];
264
+
265
+ const newTime = calculateSwitchTime(
266
+ currentTime,
267
+ timeSpent,
268
+ currentState.config,
269
+ );
270
+
271
+ newTimes = {
272
+ ...currentState.times,
273
+ [currentState.activePlayer]: newTime,
274
+ };
275
+ }
276
+
277
+ dispatch({ type: "SWITCH", payload: { newTimes, now } });
278
+ }, []);
279
+
280
+ const reset = useCallback((newTimeControl?: TimeControlInput) => {
281
+ // Build the full config for the reducer
282
+ const currentOptions = optionsRef.current;
283
+ const resetConfig: TimeControlConfig = newTimeControl
284
+ ? { ...currentOptions, time: newTimeControl }
285
+ : currentOptions;
286
+
287
+ // The reducer will parse the config, update state.config, and reset timing state
288
+ dispatch({ type: "RESET", payload: { ...resetConfig, now: Date.now() } });
289
+ }, []);
290
+
291
+ const addTime = useCallback((player: ClockColor, milliseconds: number) => {
292
+ dispatch({
293
+ type: "ADD_TIME",
294
+ payload: { player, milliseconds, now: Date.now() },
295
+ });
296
+ // Note: when adding time while clock is running, we update the base time
297
+ // but moveStartTime stays the same, so the display interpolates correctly
298
+ }, []);
299
+
300
+ const setTime = useCallback((player: ClockColor, milliseconds: number) => {
301
+ dispatch({
302
+ type: "SET_TIME",
303
+ payload: { player, milliseconds, now: Date.now() },
304
+ });
305
+ // Note: when setting time while clock is running, we update the base time
306
+ // but moveStartTime stays the same, so the display interpolates correctly
307
+ }, []);
308
+
309
+ // Memoize methods
310
+ const methods = useMemo<ClockMethods>(
311
+ () => ({
312
+ start,
313
+ pause,
314
+ resume,
315
+ switch: switchPlayer,
316
+ reset,
317
+ addTime,
318
+ setTime,
319
+ }),
320
+ [start, pause, resume, switchPlayer, reset, addTime, setTime],
321
+ );
322
+
323
+ // Computed period information
324
+ const periodInfo = useMemo(() => {
325
+ if (!state.periodState) {
326
+ // Single period: return defaults
327
+ return {
328
+ currentPeriodIndex: { white: 0, black: 0 },
329
+ totalPeriods: 1,
330
+ currentPeriod: {
331
+ white: {
332
+ baseTime: state.config.baseTime / 1000,
333
+ increment: state.config.increment / 1000,
334
+ delay: state.config.delay / 1000,
335
+ },
336
+ black: {
337
+ baseTime: state.config.baseTime / 1000,
338
+ increment: state.config.increment / 1000,
339
+ delay: state.config.delay / 1000,
340
+ },
341
+ },
342
+ periodMoves: { white: 0, black: 0 },
343
+ };
344
+ }
345
+
346
+ // Get current period for each player (with bounds checking)
347
+ // Convert from internal milliseconds back to seconds for public API
348
+ const periods = state.periodState.periods;
349
+ const getCurrentPeriod = (periodIndex: number) => {
350
+ const period =
351
+ periodIndex >= 0 && periodIndex < periods.length
352
+ ? periods[periodIndex]
353
+ : periods[0];
354
+ return {
355
+ ...period,
356
+ baseTime: period.baseTime / 1000,
357
+ increment:
358
+ period.increment !== undefined ? period.increment / 1000 : undefined,
359
+ delay: period.delay !== undefined ? period.delay / 1000 : undefined,
360
+ };
361
+ };
362
+
363
+ return {
364
+ currentPeriodIndex: state.periodState.periodIndex,
365
+ totalPeriods: periods.length,
366
+ currentPeriod: {
367
+ white: getCurrentPeriod(state.periodState.periodIndex.white),
368
+ black: getCurrentPeriod(state.periodState.periodIndex.black),
369
+ },
370
+ periodMoves: state.periodState.periodMoves,
371
+ };
372
+ }, [state.periodState, state.config]);
373
+
374
+ return {
375
+ times: displayTimes,
376
+ initialTimes: state.initialTimes,
377
+ status: state.status,
378
+ activePlayer: state.activePlayer,
379
+ timeout: state.timeout,
380
+ timingMethod: state.config.timingMethod,
381
+ info,
382
+ ...periodInfo,
383
+ methods,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Optional chess clock hook for cases where clock may not be needed
389
+ * Maintains hook order by always calling the implementation internally
390
+ *
391
+ * @param options - Clock configuration options, or undefined to disable
392
+ * @returns Clock state, info, and methods, or null if disabled
393
+ */
394
+ export function useOptionalChessClock(
395
+ options?: TimeControlConfig,
396
+ ): ReturnType<typeof useChessClock> | null {
397
+ // Always call useChessClock to maintain hook order
398
+ const result = useChessClock(options ?? DISABLED_CLOCK_CONFIG);
399
+
400
+ return options === undefined ? null : result;
401
+ }
402
+
403
+ /**
404
+ * Export the format function for convenience
405
+ */
406
+ export { formatClockTime };
@@ -0,0 +1,35 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { UseChessClockReturn } from "../types";
3
+
4
+ /**
5
+ * Context for chess clock state
6
+ * Provided by ChessClock.Root and consumed by child components
7
+ */
8
+ export const ChessClockContext = createContext<UseChessClockReturn | null>(
9
+ null,
10
+ );
11
+
12
+ /**
13
+ * Hook to access chess clock context from child components
14
+ * Must be used within a ChessClock.Root provider
15
+ *
16
+ * @throws Error if used outside of ChessClock.Root
17
+ * @returns Chess clock state and methods
18
+ */
19
+ export function useChessClockContext(): UseChessClockReturn {
20
+ const context = useContext(ChessClockContext);
21
+
22
+ if (!context) {
23
+ throw new Error(
24
+ "useChessClockContext must be used within a ChessClock.Root component. " +
25
+ "Make sure your component is wrapped with <ChessClock.Root>.",
26
+ );
27
+ }
28
+
29
+ return context;
30
+ }
31
+
32
+ /**
33
+ * Type alias for the context value type
34
+ */
35
+ export type ChessClockContextType = UseChessClockReturn;
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ // ============================================================================
2
+ // Components
3
+ // ============================================================================
4
+ export { ChessClock } from "./components/ChessClock";
5
+ export { Display } from "./components/ChessClock/parts/Display";
6
+ export { Switch } from "./components/ChessClock/parts/Switch";
7
+ export { PlayPause } from "./components/ChessClock/parts/PlayPause";
8
+ export { Reset } from "./components/ChessClock/parts/Reset";
9
+
10
+ // ============================================================================
11
+ // Hooks
12
+ // ============================================================================
13
+ export { useChessClock, useOptionalChessClock } from "./hooks/useChessClock";
14
+ export {
15
+ useChessClockContext,
16
+ ChessClockContext,
17
+ } from "./hooks/useChessClockContext";
18
+
19
+ // ============================================================================
20
+ // Utilities
21
+ // ============================================================================
22
+ export { formatClockTime } from "./utils/formatTime";
23
+ export {
24
+ parseTimeControlString,
25
+ normalizeTimeControl,
26
+ parseTimeControlConfig,
27
+ getInitialTimes,
28
+ parseMultiPeriodTimeControl,
29
+ } from "./utils/timeControl";
30
+ export { presets } from "./utils/presets";
31
+
32
+ // ============================================================================
33
+ // Types
34
+ // ============================================================================
35
+ export type {
36
+ // Time control types
37
+ TimeControlString,
38
+ TimeControl,
39
+ TimeControlPhase,
40
+ TimeControlInput,
41
+ TimingMethod,
42
+ ClockStartMode,
43
+ TimeControlConfig,
44
+ NormalizedTimeControl,
45
+ PeriodState,
46
+ // State types
47
+ ClockStatus,
48
+ ClockColor,
49
+ ClockTimes,
50
+ ClockInfo,
51
+ ClockMethods,
52
+ UseChessClockReturn,
53
+ // Utility types
54
+ TimeFormat,
55
+ TimingMethodResult,
56
+ } from "./types";
57
+
58
+ // Component prop types are exported via the ChessClock component
59
+ export type {
60
+ ChessClockRootProps,
61
+ ChessClockDisplayProps,
62
+ ChessClockControlProps,
63
+ ChessClockPlayPauseProps,
64
+ ChessClockResetProps,
65
+ } from "./components/ChessClock";