@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,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
|
+
}
|