@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,379 @@
1
+ import type {
2
+ ClockColor,
3
+ ClockTimes,
4
+ ClockStatus,
5
+ PeriodState,
6
+ TimeControlConfig,
7
+ NormalizedTimeControl,
8
+ } from "../types";
9
+ import { parseTimeControlConfig, getInitialTimes } from "../utils/timeControl";
10
+ import {
11
+ getInitialActivePlayer,
12
+ getInitialStatus,
13
+ } from "../utils/timingMethods";
14
+
15
+ // ============================================================================
16
+ // State
17
+ // ============================================================================
18
+
19
+ export interface ClockState {
20
+ /** The actual stored time values for each player. Updates when time is deducted (after delays) or added via increments. */
21
+ times: ClockTimes;
22
+ /** The starting time values for each player, used for reset operations. */
23
+ initialTimes: ClockTimes;
24
+ /** Current clock status - whether it's idle, running, paused, or finished. */
25
+ status: ClockStatus;
26
+ /** The player whose clock is currently counting down. Null when clock is idle. */
27
+ activePlayer: ClockColor | null;
28
+ /** Which player timed out, if any. Set when the clock reaches finished status. */
29
+ timeout: ClockColor | null;
30
+ /** Number of times the clock has switched between players. Used for move counting in some time controls. */
31
+ switchCount: number;
32
+ /** Multi-period state tracking (only present for multi-period time controls) */
33
+ periodState?: PeriodState;
34
+ /** The normalized time control configuration (kept in sync with resets) */
35
+ config: NormalizedTimeControl;
36
+ /**
37
+ * Timestamp (ms since epoch) when the current player's move started.
38
+ * Used to calculate elapsed time as `Date.now() - moveStartTime`.
39
+ * Null when the clock hasn't started yet or is paused.
40
+ */
41
+ moveStartTime: number | null;
42
+ /**
43
+ * When the clock is paused, this stores the elapsed time (in ms) at the
44
+ * moment of pause. This allows resuming without "losing" the paused time.
45
+ */
46
+ elapsedAtPause: number;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Actions
51
+ // ============================================================================
52
+
53
+ export type ClockAction =
54
+ | { type: "START"; payload?: { now?: number } }
55
+ | { type: "PAUSE"; payload?: { now?: number } }
56
+ | { type: "RESUME"; payload?: { now?: number } }
57
+ | { type: "SWITCH"; payload: { newTimes?: ClockTimes; now?: number } }
58
+ | { type: "TIMEOUT"; payload: { player: ClockColor } }
59
+ | { type: "RESET"; payload: TimeControlConfig & { now?: number } }
60
+ | {
61
+ type: "ADD_TIME";
62
+ payload: { player: ClockColor; milliseconds: number; now?: number };
63
+ }
64
+ | {
65
+ type: "SET_TIME";
66
+ payload: { player: ClockColor; milliseconds: number; now?: number };
67
+ };
68
+
69
+ // ============================================================================
70
+ // Helpers
71
+ // ============================================================================
72
+
73
+ /**
74
+ * After a switch increments period moves, check if any player has completed
75
+ * their required moves for the current period and should advance to the next.
76
+ * Returns updated times and periodState, or the originals if no advancement.
77
+ */
78
+ function maybeAdvancePeriod(
79
+ times: ClockTimes,
80
+ periodState: PeriodState,
81
+ ): { times: ClockTimes; periodState: PeriodState } {
82
+ let newTimes = times;
83
+ let newPeriodState = periodState;
84
+
85
+ (["white", "black"] as const).forEach((player) => {
86
+ const playerPeriodIndex = newPeriodState.periodIndex[player];
87
+
88
+ if (
89
+ playerPeriodIndex < 0 ||
90
+ playerPeriodIndex >= newPeriodState.periods.length
91
+ ) {
92
+ return;
93
+ }
94
+
95
+ const currentPeriod = newPeriodState.periods[playerPeriodIndex];
96
+ if (!currentPeriod?.moves) return; // Sudden death period, no transition
97
+
98
+ const movesRequired = currentPeriod.moves;
99
+ const playerMoves = newPeriodState.periodMoves[player];
100
+
101
+ if (playerMoves >= movesRequired) {
102
+ const nextPeriodIndex = playerPeriodIndex + 1;
103
+
104
+ if (nextPeriodIndex >= newPeriodState.periods.length) {
105
+ return; // Already at final period
106
+ }
107
+
108
+ const nextPeriod = newPeriodState.periods[nextPeriodIndex];
109
+ if (!nextPeriod) return;
110
+
111
+ const addedTime = nextPeriod.baseTime;
112
+
113
+ newPeriodState = {
114
+ ...newPeriodState,
115
+ periodIndex: {
116
+ ...newPeriodState.periodIndex,
117
+ [player]: nextPeriodIndex,
118
+ },
119
+ periodMoves: {
120
+ ...newPeriodState.periodMoves,
121
+ [player]: 0,
122
+ },
123
+ };
124
+
125
+ newTimes = {
126
+ ...newTimes,
127
+ [player]: newTimes[player] + addedTime,
128
+ };
129
+ }
130
+ });
131
+
132
+ return { times: newTimes, periodState: newPeriodState };
133
+ }
134
+
135
+ // ============================================================================
136
+ // Reducer
137
+ // ============================================================================
138
+
139
+ export function clockReducer(
140
+ state: ClockState,
141
+ action: ClockAction,
142
+ ): ClockState {
143
+ switch (action.type) {
144
+ case "START": {
145
+ if (state.status === "finished") return state;
146
+ // Don't interrupt delayed mode - it transitions to running after both players move
147
+ if (state.status === "delayed") {
148
+ return state; // No change in delayed mode
149
+ }
150
+ const now = action.payload?.now ?? Date.now();
151
+ return {
152
+ ...state,
153
+ status: "running",
154
+ activePlayer: state.activePlayer ?? "white",
155
+ // Initialize move start time if not already set
156
+ moveStartTime: state.moveStartTime ?? now,
157
+ };
158
+ }
159
+
160
+ case "PAUSE": {
161
+ if (state.status !== "running") return state;
162
+ const now = action.payload?.now ?? Date.now();
163
+ // Store elapsed time at pause moment
164
+ const elapsedAtPause =
165
+ state.moveStartTime !== null ? now - state.moveStartTime : 0;
166
+ return {
167
+ ...state,
168
+ status: "paused",
169
+ elapsedAtPause,
170
+ moveStartTime: null,
171
+ };
172
+ }
173
+
174
+ case "RESUME": {
175
+ if (state.status !== "paused") return state;
176
+ const now = action.payload?.now ?? Date.now();
177
+ // Reset start time based on stored elapsed to resume seamlessly
178
+ return {
179
+ ...state,
180
+ status: "running",
181
+ moveStartTime: now - state.elapsedAtPause,
182
+ elapsedAtPause: 0,
183
+ };
184
+ }
185
+
186
+ case "SWITCH": {
187
+ if (state.status === "finished") return state;
188
+ // Track period moves for multi-period time controls
189
+ let newPeriodState = state.periodState;
190
+ const movedPlayer: ClockColor | null = state.activePlayer;
191
+ const now = action.payload.now ?? Date.now();
192
+
193
+ // Handle delayed start mode
194
+ if (state.status === "delayed") {
195
+ const newCount = state.switchCount + 1;
196
+ const newPlayer: ClockColor = newCount % 2 === 1 ? "black" : "white";
197
+
198
+ // In delayed mode, track which player is making the move
199
+ // First switch: white moves, second switch: black moves
200
+ const delayedMovePlayer: ClockColor =
201
+ newCount % 2 === 1 ? "white" : "black";
202
+
203
+ // Track period moves even during delayed mode
204
+ if (state.periodState) {
205
+ newPeriodState = {
206
+ ...state.periodState,
207
+ periodMoves: {
208
+ ...state.periodState.periodMoves,
209
+ [delayedMovePlayer]:
210
+ state.periodState.periodMoves[delayedMovePlayer] + 1,
211
+ },
212
+ };
213
+ }
214
+
215
+ // Check for period advancement
216
+ if (newPeriodState) {
217
+ const advanced = maybeAdvancePeriod(state.times, newPeriodState);
218
+ newPeriodState = advanced.periodState;
219
+ }
220
+
221
+ const newStateStatus = newCount >= 2 ? "running" : "delayed";
222
+
223
+ return {
224
+ ...state,
225
+ activePlayer: newPlayer,
226
+ switchCount: newCount,
227
+ status: newStateStatus,
228
+ periodState: newPeriodState,
229
+ // Start timing when transitioning to running
230
+ moveStartTime:
231
+ newStateStatus === "running" ? now : state.moveStartTime,
232
+ };
233
+ }
234
+
235
+ // Normal switch handling
236
+ if (movedPlayer === null) return state;
237
+ const { newTimes } = action.payload;
238
+ const newPlayer = movedPlayer === "white" ? "black" : "white";
239
+
240
+ // Track period moves for multi-period time controls
241
+ if (state.periodState) {
242
+ newPeriodState = {
243
+ ...state.periodState,
244
+ periodMoves: {
245
+ ...state.periodState.periodMoves,
246
+ [movedPlayer]: state.periodState.periodMoves[movedPlayer] + 1,
247
+ },
248
+ };
249
+ }
250
+
251
+ let resolvedTimes = newTimes ?? state.times;
252
+
253
+ // Check for period advancement
254
+ if (newPeriodState) {
255
+ const advanced = maybeAdvancePeriod(resolvedTimes, newPeriodState);
256
+ resolvedTimes = advanced.times;
257
+ newPeriodState = advanced.periodState;
258
+ }
259
+
260
+ const newStatus = state.status === "idle" ? "running" : state.status;
261
+
262
+ return {
263
+ ...state,
264
+ activePlayer: newPlayer,
265
+ times: resolvedTimes,
266
+ switchCount: state.switchCount + 1,
267
+ periodState: newPeriodState,
268
+ status: newStatus,
269
+ // Reset timing for the new player
270
+ moveStartTime: newStatus === "running" ? now : null,
271
+ elapsedAtPause: 0,
272
+ };
273
+ }
274
+
275
+ case "TIMEOUT": {
276
+ const { player } = action.payload;
277
+ return {
278
+ ...state,
279
+ status: "finished",
280
+ timeout: player,
281
+ times: { ...state.times, [player]: 0 },
282
+ };
283
+ }
284
+
285
+ case "RESET": {
286
+ const config = parseTimeControlConfig(action.payload);
287
+ const initialTimes = getInitialTimes(config);
288
+ const now = action.payload.now;
289
+
290
+ // Compute period state for multi-period time controls
291
+ const periodState: PeriodState | undefined = config.periods
292
+ ? {
293
+ periodIndex: { white: 0, black: 0 },
294
+ periodMoves: { white: 0, black: 0 },
295
+ periods: config.periods,
296
+ }
297
+ : undefined;
298
+
299
+ return createInitialClockState(
300
+ initialTimes,
301
+ getInitialStatus(config.clockStart),
302
+ getInitialActivePlayer(config.clockStart),
303
+ config,
304
+ periodState,
305
+ now,
306
+ );
307
+ }
308
+
309
+ case "ADD_TIME": {
310
+ const { player, milliseconds } = action.payload;
311
+ const now = action.payload.now ?? Date.now();
312
+ const newTimes = {
313
+ ...state.times,
314
+ [player]: state.times[player] + milliseconds,
315
+ };
316
+ // Reset timing so display interpolation restarts from the new base time.
317
+ // When paused and modifying the active player's time, reset elapsedAtPause
318
+ // so RESUME doesn't use a stale offset that ignores the time change.
319
+ const resetElapsed =
320
+ state.status === "paused" && player === state.activePlayer;
321
+ return {
322
+ ...state,
323
+ times: newTimes,
324
+ moveStartTime: state.status === "running" ? now : null,
325
+ ...(resetElapsed && { elapsedAtPause: 0 }),
326
+ };
327
+ }
328
+
329
+ case "SET_TIME": {
330
+ const { player, milliseconds } = action.payload;
331
+ const now = action.payload.now ?? Date.now();
332
+ const newTimes = {
333
+ ...state.times,
334
+ [player]: Math.max(0, milliseconds),
335
+ };
336
+ // Reset timing so display interpolation restarts from the new base time.
337
+ // When paused and modifying the active player's time, reset elapsedAtPause
338
+ // so RESUME doesn't use a stale offset that ignores the time change.
339
+ const resetElapsed =
340
+ state.status === "paused" && player === state.activePlayer;
341
+ return {
342
+ ...state,
343
+ times: newTimes,
344
+ moveStartTime: state.status === "running" ? now : null,
345
+ ...(resetElapsed && { elapsedAtPause: 0 }),
346
+ };
347
+ }
348
+
349
+ default:
350
+ return state;
351
+ }
352
+ }
353
+
354
+ // ============================================================================
355
+ // Initial State Factory
356
+ // ============================================================================
357
+
358
+ export function createInitialClockState(
359
+ initialTimes: ClockTimes,
360
+ status: ClockStatus,
361
+ activePlayer: ClockColor | null,
362
+ config: NormalizedTimeControl,
363
+ periodState?: PeriodState,
364
+ now?: number,
365
+ ): ClockState {
366
+ return {
367
+ times: initialTimes,
368
+ initialTimes,
369
+ status,
370
+ activePlayer,
371
+ timeout: null,
372
+ switchCount: 0,
373
+ periodState,
374
+ config,
375
+ // If starting immediately, initialize the move start time
376
+ moveStartTime: status === "running" ? (now ?? Date.now()) : null,
377
+ elapsedAtPause: 0,
378
+ };
379
+ }