@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,414 @@
1
+ import {
2
+ parseTimeControlString,
3
+ normalizeTimeControl,
4
+ parseTimeControlConfig,
5
+ getInitialTimes,
6
+ parseMultiPeriodTimeControl,
7
+ } from "../timeControl";
8
+
9
+ describe("parseTimeControlString", () => {
10
+ it("should parse simple time control (no increment)", () => {
11
+ const result = parseTimeControlString("10");
12
+ expect(result).toEqual({
13
+ baseTime: 600, // 10 minutes in seconds
14
+ increment: 0,
15
+ });
16
+ });
17
+
18
+ it("should parse time control with increment", () => {
19
+ const result = parseTimeControlString("5+3");
20
+ expect(result).toEqual({
21
+ baseTime: 300,
22
+ increment: 3,
23
+ });
24
+ });
25
+
26
+ it("should parse various time controls", () => {
27
+ expect(parseTimeControlString("1+0")).toEqual({
28
+ baseTime: 60,
29
+ increment: 0,
30
+ });
31
+ expect(parseTimeControlString("1+1")).toEqual({
32
+ baseTime: 60,
33
+ increment: 1,
34
+ });
35
+ expect(parseTimeControlString("3+2")).toEqual({
36
+ baseTime: 180,
37
+ increment: 2,
38
+ });
39
+ expect(parseTimeControlString("10+5")).toEqual({
40
+ baseTime: 600,
41
+ increment: 5,
42
+ });
43
+ });
44
+
45
+ it("should throw error for invalid format", () => {
46
+ // @ts-expect-error - parseTimeControlString throws for invalid input
47
+ expect(() => parseTimeControlString("invalid")).toThrow(
48
+ "Invalid time control",
49
+ );
50
+ // @ts-expect-error - parseTimeControlString throws for invalid input
51
+ expect(() => parseTimeControlString("5-3")).toThrow("Invalid time control");
52
+ // @ts-expect-error - parseTimeControlString throws for invalid input
53
+ expect(() => parseTimeControlString("")).toThrow("Invalid time control");
54
+ });
55
+ });
56
+
57
+ describe("normalizeTimeControl", () => {
58
+ it("should normalize string time control to milliseconds", () => {
59
+ const result = normalizeTimeControl("5+3", "fischer", "delayed");
60
+ expect(result).toEqual({
61
+ baseTime: 300_000, // 5 minutes in ms
62
+ increment: 3_000, // 3 seconds in ms
63
+ delay: 0,
64
+ timingMethod: "fischer",
65
+ clockStart: "delayed",
66
+ });
67
+ });
68
+
69
+ it("should normalize object time control to milliseconds", () => {
70
+ const result = normalizeTimeControl(
71
+ {
72
+ baseTime: 300, // 5 minutes
73
+ increment: 3,
74
+ },
75
+ "fischer",
76
+ "delayed",
77
+ );
78
+ expect(result).toEqual({
79
+ baseTime: 300_000,
80
+ increment: 3_000,
81
+ delay: 0,
82
+ timingMethod: "fischer",
83
+ clockStart: "delayed",
84
+ });
85
+ });
86
+
87
+ it("should include delay if specified", () => {
88
+ const result = normalizeTimeControl(
89
+ {
90
+ baseTime: 300,
91
+ increment: 0,
92
+ delay: 5, // 5 second delay
93
+ },
94
+ "fischer",
95
+ "delayed",
96
+ );
97
+ expect(result.delay).toBe(5_000); // 5 seconds in ms
98
+ });
99
+ });
100
+
101
+ describe("parseTimeControlConfig", () => {
102
+ it("should parse config with string time control", () => {
103
+ const result = parseTimeControlConfig({
104
+ time: "5+3",
105
+ });
106
+ expect(result.baseTime).toBe(300_000);
107
+ expect(result.increment).toBe(3_000);
108
+ expect(result.timingMethod).toBe("fischer");
109
+ expect(result.clockStart).toBe("delayed");
110
+ });
111
+
112
+ it("should parse config with timing method", () => {
113
+ const result = parseTimeControlConfig({
114
+ time: "5+0",
115
+ timingMethod: "delay",
116
+ });
117
+ expect(result.timingMethod).toBe("delay");
118
+ });
119
+
120
+ it("should parse config with clock start mode", () => {
121
+ const result = parseTimeControlConfig({
122
+ time: "5+0",
123
+ clockStart: "immediate",
124
+ });
125
+ expect(result.clockStart).toBe("immediate");
126
+ });
127
+
128
+ it("should parse config with time odds", () => {
129
+ const result = parseTimeControlConfig({
130
+ time: "5+0",
131
+ whiteTime: 300, // 5 minutes
132
+ blackTime: 240, // 4 minutes
133
+ });
134
+ expect(result.whiteTimeOverride).toBe(300_000);
135
+ expect(result.blackTimeOverride).toBe(240_000);
136
+ });
137
+
138
+ it("should parse config with all options", () => {
139
+ const result = parseTimeControlConfig({
140
+ time: "10+5",
141
+ timingMethod: "bronstein",
142
+ clockStart: "manual",
143
+ whiteTime: 600,
144
+ blackTime: 600,
145
+ });
146
+ expect(result.baseTime).toBe(600_000);
147
+ expect(result.increment).toBe(5_000);
148
+ expect(result.timingMethod).toBe("bronstein");
149
+ expect(result.clockStart).toBe("manual");
150
+ expect(result.whiteTimeOverride).toBe(600_000);
151
+ expect(result.blackTimeOverride).toBe(600_000);
152
+ });
153
+ });
154
+
155
+ describe("getInitialTimes", () => {
156
+ it("should return base time for both players without overrides", () => {
157
+ const config = normalizeTimeControl("5+3", "fischer", "delayed");
158
+ const result = getInitialTimes(config);
159
+ expect(result).toEqual({
160
+ white: 300_000,
161
+ black: 300_000,
162
+ });
163
+ });
164
+
165
+ it("should apply time odds when overrides are present", () => {
166
+ const config = parseTimeControlConfig({
167
+ time: "5+0",
168
+ whiteTime: 300, // 5 minutes
169
+ blackTime: 180, // 3 minutes
170
+ });
171
+ const result = getInitialTimes(config);
172
+ expect(result).toEqual({
173
+ white: 300_000,
174
+ black: 180_000,
175
+ });
176
+ });
177
+
178
+ it("should apply only white override", () => {
179
+ const config = parseTimeControlConfig({
180
+ time: "5+0",
181
+ whiteTime: 300,
182
+ });
183
+ const result = getInitialTimes(config);
184
+ expect(result).toEqual({
185
+ white: 300_000,
186
+ black: 300_000, // Falls back to base time
187
+ });
188
+ });
189
+
190
+ it("should apply only black override", () => {
191
+ const config = parseTimeControlConfig({
192
+ time: "5+0",
193
+ blackTime: 180,
194
+ });
195
+ const result = getInitialTimes(config);
196
+ expect(result).toEqual({
197
+ white: 300_000, // Falls back to base time
198
+ black: 180_000,
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("parseMultiPeriodTimeControl", () => {
204
+ describe("valid formats", () => {
205
+ it("should parse simple two-period time control", () => {
206
+ const result = parseMultiPeriodTimeControl("40/90+30,sd/30+30");
207
+ expect(result).toEqual([
208
+ { baseTime: 5400, increment: 30, moves: 40 },
209
+ { baseTime: 1800, increment: 30 },
210
+ ]);
211
+ });
212
+
213
+ it("should parse three-period time control", () => {
214
+ const result = parseMultiPeriodTimeControl("40/90+30,20/60+30,g/15+30");
215
+ expect(result).toEqual([
216
+ { baseTime: 5400, increment: 30, moves: 40 },
217
+ { baseTime: 3600, increment: 30, moves: 20 },
218
+ { baseTime: 900, increment: 30 },
219
+ ]);
220
+ });
221
+
222
+ it("should parse with SD prefix (uppercase)", () => {
223
+ const result = parseMultiPeriodTimeControl("40/90,SD/30");
224
+ expect(result).toMatchObject([
225
+ { baseTime: 5400, moves: 40 },
226
+ { baseTime: 1800 },
227
+ ]);
228
+ });
229
+
230
+ it("should parse with sd prefix (lowercase)", () => {
231
+ const result = parseMultiPeriodTimeControl("40/90,sd/30");
232
+ expect(result).toMatchObject([
233
+ { baseTime: 5400, moves: 40 },
234
+ { baseTime: 1800 },
235
+ ]);
236
+ });
237
+
238
+ it("should parse with G prefix", () => {
239
+ const result = parseMultiPeriodTimeControl("40/90,G/30");
240
+ expect(result).toMatchObject([
241
+ { baseTime: 5400, moves: 40 },
242
+ { baseTime: 1800 },
243
+ ]);
244
+ });
245
+
246
+ it("should parse with g prefix (lowercase)", () => {
247
+ const result = parseMultiPeriodTimeControl("40/90,g/30");
248
+ expect(result).toMatchObject([
249
+ { baseTime: 5400, moves: 40 },
250
+ { baseTime: 1800 },
251
+ ]);
252
+ });
253
+
254
+ it("should handle whitespace around commas", () => {
255
+ const result = parseMultiPeriodTimeControl("40/90+30 , sd/30+30");
256
+ expect(result).toEqual([
257
+ { baseTime: 5400, increment: 30, moves: 40 },
258
+ { baseTime: 1800, increment: 30 },
259
+ ]);
260
+ });
261
+
262
+ it("should parse period without increment", () => {
263
+ const result = parseMultiPeriodTimeControl("40/90,sd/30");
264
+ expect(result).toMatchObject([
265
+ { baseTime: 5400, moves: 40 },
266
+ { baseTime: 1800 },
267
+ ]);
268
+ });
269
+
270
+ it("should parse period with decimal minutes", () => {
271
+ const result = parseMultiPeriodTimeControl("40/90.5,sd/30");
272
+ expect(result).toMatchObject([
273
+ { baseTime: 5430, moves: 40 },
274
+ { baseTime: 1800 },
275
+ ]);
276
+ });
277
+
278
+ it("should parse decimal increment", () => {
279
+ const result = parseMultiPeriodTimeControl("40/90+2.5,sd/30");
280
+ expect(result).toMatchObject([
281
+ { baseTime: 5400, increment: 3, moves: 40 }, // Note: 2.5 gets rounded to 3
282
+ { baseTime: 1800 },
283
+ ]);
284
+ });
285
+
286
+ it("should parse simple sudden death format without moves prefix", () => {
287
+ const result = parseMultiPeriodTimeControl("sd/30+10");
288
+ expect(result).toEqual([{ baseTime: 1800, increment: 10 }]);
289
+ });
290
+
291
+ it("should parse G/ format without moves prefix", () => {
292
+ const result = parseMultiPeriodTimeControl("G/30+10");
293
+ expect(result).toEqual([{ baseTime: 1800, increment: 10 }]);
294
+ });
295
+ });
296
+
297
+ describe("edge cases", () => {
298
+ it("should handle single period (sudden death only)", () => {
299
+ const result = parseMultiPeriodTimeControl("sd/30+10");
300
+ expect(result).toHaveLength(1);
301
+ expect(result[0]).toEqual({ baseTime: 1800, increment: 10 });
302
+ });
303
+
304
+ it("should handle single period with moves", () => {
305
+ const result = parseMultiPeriodTimeControl("40/90+30");
306
+ expect(result).toHaveLength(1);
307
+ expect(result[0]).toEqual({ baseTime: 5400, increment: 30, moves: 40 });
308
+ });
309
+
310
+ it("should handle very large move counts", () => {
311
+ const result = parseMultiPeriodTimeControl("100/120,sd/30");
312
+ expect(result).toEqual([
313
+ { baseTime: 7200, moves: 100 },
314
+ { baseTime: 1800 },
315
+ ]);
316
+ });
317
+
318
+ it("should handle small move counts", () => {
319
+ const result = parseMultiPeriodTimeControl("5/10+3,sd/5");
320
+ expect(result).toEqual([
321
+ { baseTime: 600, increment: 3, moves: 5 },
322
+ { baseTime: 300 },
323
+ ]);
324
+ });
325
+
326
+ it("should handle multiple consecutive sudden death markers", () => {
327
+ const result = parseMultiPeriodTimeControl("sd/30+10,sd/15+5");
328
+ expect(result).toEqual([
329
+ { baseTime: 1800, increment: 10 },
330
+ { baseTime: 900, increment: 5 },
331
+ ]);
332
+ });
333
+ });
334
+
335
+ describe("error handling", () => {
336
+ it("should throw on empty string", () => {
337
+ expect(() => parseMultiPeriodTimeControl("")).toThrow();
338
+ });
339
+
340
+ it("should throw on invalid period format", () => {
341
+ expect(() => parseMultiPeriodTimeControl("invalid,sd/30")).toThrow(
342
+ "Invalid period format",
343
+ );
344
+ });
345
+
346
+ it("should throw on period with negative time", () => {
347
+ expect(() => parseMultiPeriodTimeControl("40/-10,sd/30")).toThrow();
348
+ });
349
+
350
+ it("should throw on empty period between commas", () => {
351
+ expect(() => parseMultiPeriodTimeControl("40/90,,sd/30")).toThrow();
352
+ });
353
+ });
354
+ });
355
+
356
+ describe("normalizeTimeControl with multi-period", () => {
357
+ it("should normalize multi-period array to milliseconds", () => {
358
+ const input = [
359
+ { baseTime: 5400, increment: 30, moves: 40 },
360
+ { baseTime: 1800, increment: 30 },
361
+ ];
362
+
363
+ const result = normalizeTimeControl(input, "fischer", "delayed");
364
+
365
+ expect(result).toEqual({
366
+ baseTime: 5_400_000, // First period base time in ms
367
+ increment: 30_000, // First period increment in ms
368
+ delay: 0,
369
+ timingMethod: "fischer",
370
+ clockStart: "delayed",
371
+ periods: [
372
+ { baseTime: 5_400_000, increment: 30_000, moves: 40 },
373
+ { baseTime: 1_800_000, increment: 30_000 },
374
+ ],
375
+ });
376
+ });
377
+
378
+ it("should preserve periods in normalized output with ms values", () => {
379
+ const input = [
380
+ { baseTime: 5400, increment: 30, moves: 40 },
381
+ { baseTime: 3600, increment: 30, moves: 20 },
382
+ { baseTime: 900, increment: 30 },
383
+ ];
384
+
385
+ const result = normalizeTimeControl(input, "fischer", "delayed");
386
+
387
+ expect(result.periods).toEqual([
388
+ { baseTime: 5_400_000, increment: 30_000, moves: 40 },
389
+ { baseTime: 3_600_000, increment: 30_000, moves: 20 },
390
+ { baseTime: 900_000, increment: 30_000 },
391
+ ]);
392
+ });
393
+
394
+ it("should throw on empty array", () => {
395
+ expect(() => normalizeTimeControl([], "fischer", "delayed")).toThrow(
396
+ "Multi-period time control must have at least one period",
397
+ );
398
+ });
399
+
400
+ it("should handle single period array", () => {
401
+ const input = [{ baseTime: 300, increment: 3 }];
402
+
403
+ const result = normalizeTimeControl(input, "fischer", "delayed");
404
+
405
+ expect(result).toEqual({
406
+ baseTime: 300_000,
407
+ increment: 3_000,
408
+ delay: 0,
409
+ timingMethod: "fischer",
410
+ clockStart: "delayed",
411
+ periods: [{ baseTime: 300_000, increment: 3_000 }],
412
+ });
413
+ });
414
+ });
@@ -0,0 +1,170 @@
1
+ import {
2
+ applyFischerIncrement,
3
+ applySimpleDelay,
4
+ applyBronsteinDelay,
5
+ calculateSwitchAdjustment,
6
+ getInitialActivePlayer,
7
+ getInitialStatus,
8
+ } from "../timingMethods";
9
+
10
+ describe("applyFischerIncrement", () => {
11
+ it("should add increment to current time", () => {
12
+ expect(applyFischerIncrement(300_000, 3_000)).toBe(303_000);
13
+ expect(applyFischerIncrement(60_000, 1_000)).toBe(61_000);
14
+ expect(applyFischerIncrement(120_000, 2_000)).toBe(122_000);
15
+ });
16
+
17
+ it("should handle zero increment", () => {
18
+ expect(applyFischerIncrement(300_000, 0)).toBe(300_000);
19
+ });
20
+
21
+ it("should handle low time values", () => {
22
+ expect(applyFischerIncrement(100, 3_000)).toBe(3_100);
23
+ expect(applyFischerIncrement(0, 3_000)).toBe(3_000);
24
+ });
25
+ });
26
+
27
+ describe("applySimpleDelay", () => {
28
+ it("should return 0 when time spent is within delay period", () => {
29
+ const delay = 5_000; // 5 seconds
30
+ expect(applySimpleDelay(1_000, delay)).toBe(0);
31
+ expect(applySimpleDelay(3_000, delay)).toBe(0);
32
+ expect(applySimpleDelay(5_000, delay)).toBe(0);
33
+ });
34
+
35
+ it("should return time spent minus delay when over delay period", () => {
36
+ const delay = 5_000; // 5 seconds
37
+ expect(applySimpleDelay(6_000, delay)).toBe(1_000);
38
+ expect(applySimpleDelay(10_000, delay)).toBe(5_000);
39
+ expect(applySimpleDelay(15_500, delay)).toBe(10_500);
40
+ });
41
+
42
+ it("should handle zero delay", () => {
43
+ expect(applySimpleDelay(5_000, 0)).toBe(5_000);
44
+ expect(applySimpleDelay(0, 0)).toBe(0);
45
+ });
46
+ });
47
+
48
+ describe("applyBronsteinDelay", () => {
49
+ it("should add back full time spent when within delay", () => {
50
+ const delay = 5_000; // 5 seconds
51
+ const currentTime = 300_000;
52
+
53
+ // Spent 3 seconds, add back 3 seconds
54
+ expect(applyBronsteinDelay(currentTime, 3_000, delay)).toBe(303_000);
55
+
56
+ // Spent exactly delay, add back delay
57
+ expect(applyBronsteinDelay(currentTime, 5_000, delay)).toBe(305_000);
58
+ });
59
+
60
+ it("should add back only delay amount when over delay", () => {
61
+ const delay = 5_000; // 5 seconds
62
+ const currentTime = 300_000;
63
+
64
+ // Spent 10 seconds, add back only 5 (delay amount)
65
+ expect(applyBronsteinDelay(currentTime, 10_000, delay)).toBe(305_000);
66
+
67
+ // Spent 15 seconds, add back only 5
68
+ expect(applyBronsteinDelay(currentTime, 15_000, delay)).toBe(305_000);
69
+ });
70
+
71
+ it("should handle zero delay", () => {
72
+ expect(applyBronsteinDelay(300_000, 5_000, 0)).toBe(300_000);
73
+ });
74
+
75
+ it("should handle low time spent", () => {
76
+ expect(applyBronsteinDelay(300_000, 500, 5_000)).toBe(300_500);
77
+ expect(applyBronsteinDelay(300_000, 0, 5_000)).toBe(300_000);
78
+ });
79
+ });
80
+
81
+ describe("calculateSwitchAdjustment", () => {
82
+ const baseConfig = {
83
+ baseTime: 300_000,
84
+ increment: 3_000,
85
+ delay: 5_000,
86
+ timingMethod: "fischer" as const,
87
+ clockStart: "delayed" as const,
88
+ };
89
+
90
+ describe("Fischer timing method", () => {
91
+ it("should add increment after switch", () => {
92
+ const result = calculateSwitchAdjustment("fischer", 295_000, 5_000, {
93
+ ...baseConfig,
94
+ timingMethod: "fischer",
95
+ });
96
+ expect(result).toBe(298_000); // 295000 + 3000
97
+ });
98
+
99
+ it("should handle zero increment", () => {
100
+ const config = { ...baseConfig, increment: 0 };
101
+ const result = calculateSwitchAdjustment(
102
+ "fischer",
103
+ 295_000,
104
+ 5_000,
105
+ config,
106
+ );
107
+ expect(result).toBe(295_000); // No increment
108
+ });
109
+ });
110
+
111
+ describe("Delay timing method", () => {
112
+ it("should not adjust time (handled in tick)", () => {
113
+ const result = calculateSwitchAdjustment("delay", 295_000, 5_000, {
114
+ ...baseConfig,
115
+ timingMethod: "delay",
116
+ });
117
+ expect(result).toBe(295_000); // No change on switch
118
+ });
119
+ });
120
+
121
+ describe("Bronstein timing method", () => {
122
+ it("should add back time spent up to delay", () => {
123
+ // Spent 3 seconds, delay is 5 seconds
124
+ const result = calculateSwitchAdjustment("bronstein", 297_000, 3_000, {
125
+ ...baseConfig,
126
+ timingMethod: "bronstein",
127
+ delay: 5_000,
128
+ });
129
+ expect(result).toBe(300_000); // 297000 + 3000
130
+ });
131
+
132
+ it("should add back full delay when over delay", () => {
133
+ // Spent 10 seconds, delay is 5 seconds
134
+ const result = calculateSwitchAdjustment("bronstein", 290_000, 10_000, {
135
+ ...baseConfig,
136
+ timingMethod: "bronstein",
137
+ delay: 5_000,
138
+ });
139
+ expect(result).toBe(295_000); // 290000 + 5000
140
+ });
141
+ });
142
+ });
143
+
144
+ describe("getInitialActivePlayer", () => {
145
+ it("should return white for immediate start", () => {
146
+ expect(getInitialActivePlayer("immediate")).toBe("white");
147
+ });
148
+
149
+ it("should return null for delayed start", () => {
150
+ expect(getInitialActivePlayer("delayed")).toBeNull();
151
+ });
152
+
153
+ it("should return null for manual start", () => {
154
+ expect(getInitialActivePlayer("manual")).toBeNull();
155
+ });
156
+ });
157
+
158
+ describe("getInitialStatus", () => {
159
+ it("should return running for immediate start", () => {
160
+ expect(getInitialStatus("immediate")).toBe("running");
161
+ });
162
+
163
+ it("should return delayed for delayed start", () => {
164
+ expect(getInitialStatus("delayed")).toBe("delayed");
165
+ });
166
+
167
+ it("should return idle for manual start", () => {
168
+ expect(getInitialStatus("manual")).toBe("idle");
169
+ });
170
+ });
@@ -0,0 +1,37 @@
1
+ import type { NormalizedTimeControl } from "../types";
2
+ import { calculateSwitchAdjustment } from "./timingMethods";
3
+
4
+ /**
5
+ * Calculate the new time for a player after their move ends.
6
+ *
7
+ * This combines:
8
+ * 1. Delay method logic (time doesn't decrement during delay period)
9
+ * 2. Timing method adjustments (Fischer increment, Bronstein)
10
+ *
11
+ * @param currentTime - Current time in milliseconds for the player
12
+ * @param timeSpent - Time spent on the move in milliseconds
13
+ * @param config - Normalized time control configuration
14
+ * @returns New time in milliseconds for the player
15
+ */
16
+ export function calculateSwitchTime(
17
+ currentTime: number,
18
+ timeSpent: number,
19
+ config: NormalizedTimeControl,
20
+ ): number {
21
+ // Apply delay method logic: reduce effective elapsed by delay amount
22
+ let effectiveElapsed = timeSpent;
23
+ if (config.timingMethod === "delay") {
24
+ effectiveElapsed = Math.max(0, timeSpent - config.delay);
25
+ }
26
+
27
+ // Decrement time, ensuring it doesn't go below zero
28
+ const newTime = Math.max(0, currentTime - effectiveElapsed);
29
+
30
+ // Apply timing method adjustments (Fischer increment, Bronstein)
31
+ return calculateSwitchAdjustment(
32
+ config.timingMethod,
33
+ newTime,
34
+ timeSpent,
35
+ config,
36
+ );
37
+ }
@@ -0,0 +1,59 @@
1
+ import type { TimeFormat } from "../types";
2
+
3
+ /**
4
+ * Threshold for auto-format switching (in seconds)
5
+ * Below this, show seconds with decimal; above, show mm:ss
6
+ */
7
+ const AUTO_FORMAT_THRESHOLD = 20;
8
+
9
+ /**
10
+ * Format milliseconds into a display string
11
+ * @param milliseconds - Time in milliseconds
12
+ * @param format - Format type
13
+ * @returns Formatted time string
14
+ */
15
+ export function formatClockTime(
16
+ milliseconds: number,
17
+ format: TimeFormat = "auto",
18
+ ): string {
19
+ // Clamp to zero (no negative times)
20
+ const clampedMs = Math.max(0, milliseconds);
21
+
22
+ // Auto format: switch to ss.d when time is low
23
+ if (format === "auto") {
24
+ // Use milliseconds for comparison to avoid rounding issues
25
+ format = clampedMs < AUTO_FORMAT_THRESHOLD * 1000 ? "ss.d" : "mm:ss";
26
+ }
27
+
28
+ const totalSeconds = Math.ceil(clampedMs / 1000);
29
+
30
+ switch (format) {
31
+ case "ss.d": {
32
+ // Show seconds with one decimal place
33
+ const secondsWithDecimal = clampedMs / 1000;
34
+ return secondsWithDecimal.toFixed(1);
35
+ }
36
+
37
+ case "mm:ss": {
38
+ const totalMinutes = Math.floor(totalSeconds / 60);
39
+ const seconds = totalSeconds % 60;
40
+ // Show hours if time exceeds 60 minutes
41
+ if (totalMinutes >= 60) {
42
+ const hours = Math.floor(totalMinutes / 60);
43
+ const minutes = totalMinutes % 60;
44
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
45
+ }
46
+ return `${totalMinutes}:${seconds.toString().padStart(2, "0")}`;
47
+ }
48
+
49
+ case "hh:mm:ss": {
50
+ const hours = Math.floor(totalSeconds / 3600);
51
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
52
+ const seconds = totalSeconds % 60;
53
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
54
+ }
55
+
56
+ default:
57
+ return String(totalSeconds);
58
+ }
59
+ }