• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mints-components / hooks / 17027346011

17 Aug 2025 11:43PM UTC coverage: 89.648% (+1.8%) from 87.892%
17027346011

push

github

mintsweet
feat: add a test for export

224 of 280 branches covered (80.0%)

Branch coverage included in aggregate %.

668 of 715 relevant lines covered (93.43%)

11.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.74
/src/use-timer/use-timer.ts
1
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2✔
2

3
/** Count direction */
4
export type TimerMode = 'countdown' | 'countup';
5

6
/** Persistence adapter so we can swap storage (localStorage, memory, etc.) */
7
export interface PersistAdapter<T = unknown> {
8
  get: () => T | null;
9
  set: (value: T) => void;
10
  remove: () => void;
11
  /** Optional cross-tab sync. Return an unsubscribe function. */
12
  subscribe?: (cb: (value: T | null) => void) => () => void;
13
}
14

15
type PersistShape = {
16
  mode: TimerMode;
17
  endAt?: number; // absolute ms timestamp (for countdown)
18
  startAt?: number; // absolute ms timestamp (for countup)
19
  durationMs?: number;
20
};
21

22
export type UseTimerOptions = {
23
  /** Direction; default 'countdown' */
24
  mode?: TimerMode;
25
  /** Default duration for countdown; also used to compute `progress` */
26
  durationMs?: number;
27
  /** For countdown: start toward an absolute end (overrides duration on start) */
28
  endAtMs?: number;
29
  /** For countup: start from an absolute start time */
30
  startAtMs?: number;
31
  /** Start running on mount if no persisted state is restored */
32
  autoStart?: boolean;
33
  /** Tick resolution; default 1000ms */
34
  intervalMs?: number;
35
  /** When provided, the timer persists and restores itself */
36
  persist?: {
37
    key: string;
38
    adapter?: PersistAdapter<string>; // stores the JSON string
39
    crossTab?: boolean; // listen to storage events if supported
40
  };
41
  /** Called once when countdown reaches 0 */
42
  onEnd?: () => void;
43
  /** Called every tick with the current value in ms (remaining for countdown, elapsed for countup) */
44
  onTick?: (valueMs: number) => void;
45
  /** For countup: stop automatically when elapsed >= durationMs (if provided) */
46
  stopAtDuration?: boolean;
47
};
48

49
/** Return shape is intentionally unit-agnostic (ms). Higher-level wrappers can present seconds, mm:ss, etc. */
50
export type UseTimerReturn = {
51
  /** Remaining for countdown, elapsed for countup (>= 0) */
52
  valueMs: number;
53
  /** 0..1 if duration is known; otherwise undefined */
54
  progress?: number;
55
  /** Whether it’s currently ticking */
56
  running: boolean;
57
  /** True when countdown hit 0 or countup passed duration (with stopAtDuration) */
58
  ended: boolean;
59
  /** Start or restart the timer; you can override endAt/startAt/duration per run */
60
  start: (opts?: {
61
    durationMs?: number;
62
    endAtMs?: number;
63
    startAtMs?: number;
64
  }) => void;
65
  /** Pause (keep current value) */
66
  pause: () => void;
67
  /** Reset to initial value without running (countdown -> duration, countup -> 0) */
68
  reset: (opts?: { durationMs?: number }) => void;
69
};
70

71
const defaultNow = () => Date.now();
2✔
72

73
/** A handy localStorage adapter with optional cross-tab subscription */
74
export function createLocalStorageAdapter(key: string): PersistAdapter<string> {
2✔
75
  const safe = typeof window !== 'undefined' && !!window.localStorage;
7✔
76
  return {
7✔
77
    get: () => {
7✔
78
      if (!safe) return null;
6!
79
      try {
6✔
80
        const v = window.localStorage.getItem(key);
6✔
81
        return v ?? null;
6✔
82
      } catch {
6!
83
        return null;
×
84
      }
×
85
    },
6✔
86
    set: (value: string) => {
7✔
87
      if (!safe) return;
3!
88
      try {
3✔
89
        window.localStorage.setItem(key, value);
3✔
90
      } catch {
3!
91
        /* ignore */
92
      }
×
93
    },
3✔
94
    remove: () => {
7✔
95
      if (!safe) return;
3!
96
      try {
3✔
97
        window.localStorage.removeItem(key);
3✔
98
      } catch {
3!
99
        /* ignore */
100
      }
×
101
    },
3✔
102
    subscribe: (cb: (value: string | null) => void) => {
7✔
103
      if (!safe) return () => {};
3!
104
      const handler = (e: StorageEvent) => {
3✔
105
        if (e.storageArea === window.localStorage && e.key === key) {
1✔
106
          cb(e.newValue ?? null);
1!
107
        }
1✔
108
      };
1✔
109
      window.addEventListener('storage', handler);
3✔
110
      return () => window.removeEventListener('storage', handler);
3✔
111
    },
3✔
112
  };
7✔
113
}
7✔
114

115
export function useTimer({
2✔
116
  mode = 'countdown',
64✔
117
  durationMs,
64✔
118
  endAtMs,
64✔
119
  startAtMs,
64✔
120
  autoStart = false,
64✔
121
  intervalMs = 1000,
64✔
122
  persist,
64✔
123
  onEnd,
64✔
124
  onTick,
64✔
125
  stopAtDuration = false,
64✔
126
}: UseTimerOptions): UseTimerReturn {
64✔
127
  // Internal anchors
128
  const endAtRef = useRef<number | null>(endAtMs ?? null);
64✔
129
  const startAtRef = useRef<number | null>(startAtMs ?? null);
64✔
130
  const baseDurationRef = useRef<number | undefined>(durationMs);
64✔
131
  const endedRef = useRef(false);
64✔
132

133
  // State
134
  const [valueMs, setValueMs] = useState<number>(() => {
64✔
135
    if (mode === 'countdown') {
14✔
136
      if (endAtMs != null) return Math.max(0, endAtMs - defaultNow());
12!
137
      return Math.max(0, durationMs ?? 0);
12!
138
    } else {
14✔
139
      if (startAtMs != null) return Math.max(0, defaultNow() - startAtMs);
2!
140
      return 0;
2✔
141
    }
2✔
142
  });
64✔
143
  const [running, setRunning] = useState<boolean>(false);
64✔
144

145
  // Derived progress
146
  const progress = useMemo(() => {
64✔
147
    const d = baseDurationRef.current;
38✔
148
    if (!d || d <= 0) return undefined;
38!
149
    if (mode === 'countdown') return Math.min(1, Math.max(0, 1 - valueMs / d));
38✔
150
    // countup
151
    return Math.min(1, Math.max(0, valueMs / d));
5✔
152
  }, [mode, valueMs]);
64✔
153

154
  const ended = useMemo(() => {
64✔
155
    if (mode === 'countdown') return valueMs === 0;
38✔
156
    if (!stopAtDuration || !baseDurationRef.current) return false;
38✔
157
    return valueMs >= baseDurationRef.current;
3✔
158
  }, [mode, valueMs, stopAtDuration]);
64✔
159

160
  // Persistence helpers
161
  const persistAdapter: PersistAdapter<string> | null = useMemo(() => {
64✔
162
    if (!persist?.key) return null;
14✔
163
    return persist.adapter ?? createLocalStorageAdapter(persist.key);
6✔
164
  }, [persist?.adapter, persist?.key]);
64✔
165

166
  const writePersist = useCallback(() => {
64✔
167
    if (!persistAdapter) return;
12✔
168
    const payload: PersistShape = {
2✔
169
      mode,
2✔
170
      durationMs: baseDurationRef.current,
2✔
171
      endAt: endAtRef.current ?? undefined,
12!
172
      startAt: startAtRef.current ?? undefined,
12✔
173
    };
12✔
174
    try {
12✔
175
      persistAdapter.set(JSON.stringify(payload));
12✔
176
    } catch {
12!
177
      /* ignore */
178
    }
×
179
  }, [persistAdapter, mode]);
64✔
180

181
  const clearPersist = useCallback(() => {
64✔
182
    persistAdapter?.remove();
10✔
183
  }, [persistAdapter]);
64✔
184

185
  const restorePersist = useCallback(() => {
64✔
186
    if (!persistAdapter) return false;
14✔
187
    const raw = persistAdapter.get();
6✔
188
    if (!raw) return false;
14✔
189
    try {
3✔
190
      const data = JSON.parse(raw) as PersistShape;
3✔
191
      if (data.mode !== mode) return false;
14!
192

193
      baseDurationRef.current = data.durationMs;
3✔
194
      endedRef.current = false;
3✔
195

196
      if (mode === 'countdown' && data.endAt && data.endAt > defaultNow()) {
14✔
197
        endAtRef.current = data.endAt;
2✔
198
        startAtRef.current = null;
2✔
199
        setRunning(true);
2✔
200
        setValueMs(Math.max(0, data.endAt - defaultNow()));
2✔
201
        return true;
2✔
202
      }
2✔
203
      if (mode === 'countup' && data.startAt && data.startAt <= defaultNow()) {
14✔
204
        startAtRef.current = data.startAt;
1✔
205
        endAtRef.current = null;
1✔
206
        setRunning(true);
1✔
207
        setValueMs(Math.max(0, defaultNow() - data.startAt));
1✔
208
        return true;
1✔
209
      }
1✔
210
    } catch {
14!
211
      /* ignore */
212
    }
×
213
    // stale/invalid
214
    clearPersist();
×
215
    return false;
×
216
  }, [persistAdapter, mode, clearPersist]);
64✔
217

218
  // Tick
219
  const tick = useCallback(() => {
64✔
220
    if (mode === 'countdown') {
46✔
221
      const endAt = endAtRef.current;
41✔
222
      if (!endAt) return;
41!
223
      const left = endAt - defaultNow();
41✔
224
      if (left <= 0) {
41✔
225
        setValueMs(0);
6✔
226
        setRunning(false);
6✔
227
        endAtRef.current = null;
6✔
228
        if (!endedRef.current) {
6✔
229
          endedRef.current = true;
6✔
230
          clearPersist();
6✔
231
          onEnd?.();
6✔
232
        }
6✔
233
        onTick?.(0);
6!
234
      } else {
41✔
235
        setValueMs(left);
35✔
236
        onTick?.(left);
35!
237
      }
35✔
238
      return;
41✔
239
    }
41✔
240

241
    // countup
242
    const startAt = startAtRef.current;
5✔
243
    if (!startAt) return;
5!
244
    const elapsed = Math.max(0, defaultNow() - startAt);
5✔
245
    if (
5✔
246
      stopAtDuration &&
5✔
247
      baseDurationRef.current != null &&
4✔
248
      elapsed >= baseDurationRef.current
4✔
249
    ) {
46✔
250
      setValueMs(baseDurationRef.current);
1✔
251
      setRunning(false);
1✔
252
      if (!endedRef.current) {
1✔
253
        endedRef.current = true;
1✔
254
        clearPersist();
1✔
255
        onEnd?.();
1✔
256
      }
1✔
257
      onTick?.(baseDurationRef.current);
1!
258
    } else {
18✔
259
      setValueMs(elapsed);
4✔
260
      onTick?.(elapsed);
4!
261
    }
4✔
262
  }, [mode, onEnd, onTick, stopAtDuration, clearPersist]);
64✔
263

264
  // Interval driver
265
  useEffect(() => {
64✔
266
    if (!running) return;
38✔
267
    const id = setInterval(tick, intervalMs);
14✔
268
    tick(); // align immediately
14✔
269
    return () => clearInterval(id);
14✔
270
  }, [running, intervalMs, tick]);
64✔
271

272
  // Init: restore, then autoStart if nothing restored
273
  useEffect(() => {
64✔
274
    const restored = restorePersist();
14✔
275
    if (!restored && autoStart) {
14✔
276
      if (mode === 'countdown') {
1✔
277
        const dur = baseDurationRef.current ?? 0;
1!
278
        const endAt = endAtRef.current ?? defaultNow() + dur;
1✔
279
        endAtRef.current = endAt;
1✔
280
        setRunning(true);
1✔
281
        setValueMs(Math.max(0, endAt - defaultNow()));
1✔
282
        writePersist();
1✔
283
      } else {
1!
284
        const startAt = startAtRef.current ?? defaultNow();
×
285
        startAtRef.current = startAt;
×
286
        setRunning(true);
×
287
        setValueMs(Math.max(0, defaultNow() - startAt));
×
288
        writePersist();
×
289
      }
×
290
    }
1✔
291

292
    // cross-tab sync
293
    if (persistAdapter && persist?.crossTab && persistAdapter.subscribe) {
14✔
294
      const unsub = persistAdapter.subscribe((raw) => {
3✔
295
        if (!raw) {
1!
296
          // someone cleared in another tab; stop here
297
          endAtRef.current = null;
×
298
          startAtRef.current = null;
×
299
          setRunning(false);
×
300
          setValueMs(
×
301
            mode === 'countdown'
×
302
              ? Math.max(0, baseDurationRef.current ?? 0)
×
303
              : 0,
×
304
          );
×
305
          return;
×
306
        }
×
307
        try {
1✔
308
          const data = JSON.parse(raw) as PersistShape;
1✔
309
          if (data.mode !== mode) return;
1!
310
          baseDurationRef.current = data.durationMs;
1✔
311
          if (mode === 'countdown' && data.endAt) {
1✔
312
            endAtRef.current = data.endAt;
1✔
313
            setRunning(true);
1✔
314
            setValueMs(Math.max(0, data.endAt - defaultNow()));
1✔
315
          }
1✔
316
          if (mode === 'countup' && data.startAt) {
1!
317
            startAtRef.current = data.startAt;
×
318
            setRunning(true);
×
319
            setValueMs(Math.max(0, defaultNow() - data.startAt));
×
320
          }
×
321
        } catch {
1!
322
          /* ignore */
323
        }
×
324
      });
3✔
325
      return () => unsub();
3✔
326
    }
3✔
327
    // eslint-disable-next-line react-hooks/exhaustive-deps
328
  }, []);
64✔
329

330
  // Controls
331
  const start = useCallback(
64✔
332
    (opts?: { durationMs?: number; endAtMs?: number; startAtMs?: number }) => {
64✔
333
      endedRef.current = false;
9✔
334

335
      if (mode === 'countdown') {
9✔
336
        const dur = opts?.durationMs ?? baseDurationRef.current ?? 0;
8!
337
        baseDurationRef.current = dur;
8✔
338
        const endAt = opts?.endAtMs ?? defaultNow() + Math.max(0, dur);
8✔
339
        endAtRef.current = endAt;
8✔
340
        startAtRef.current = null;
8✔
341
        setRunning(true);
8✔
342
        setValueMs(Math.max(0, endAt - defaultNow()));
8✔
343
      } else {
9✔
344
        // countup
345
        if (opts?.durationMs != null)
1!
346
          baseDurationRef.current = Math.max(0, opts.durationMs);
1!
347
        const startAt = opts?.startAtMs ?? defaultNow();
1!
348
        startAtRef.current = startAt;
1✔
349
        endAtRef.current = null;
1✔
350
        setRunning(true);
1✔
351
        setValueMs(Math.max(0, defaultNow() - startAt));
1✔
352
      }
1✔
353
      writePersist();
9✔
354
    },
9✔
355
    [mode, writePersist],
64✔
356
  );
64✔
357

358
  const pause = useCallback(() => {
64✔
359
    setRunning(false);
2✔
360
    // keep anchors to allow resume
361
    writePersist();
2✔
362
  }, [writePersist]);
64✔
363

364
  const reset = useCallback(
64✔
365
    (opts?: { durationMs?: number }) => {
64✔
366
      setRunning(false);
3✔
367
      endedRef.current = false;
3✔
368
      if (opts?.durationMs != null)
3✔
369
        baseDurationRef.current = Math.max(0, opts.durationMs);
3✔
370
      if (mode === 'countdown') {
3✔
371
        endAtRef.current = null;
3✔
372
        startAtRef.current = null;
3✔
373
        setValueMs(Math.max(0, baseDurationRef.current ?? 0));
3!
374
      } else {
3!
375
        endAtRef.current = null;
×
376
        startAtRef.current = null;
×
377
        setValueMs(0);
×
378
      }
×
379
      clearPersist();
3✔
380
    },
3✔
381
    [mode, clearPersist],
64✔
382
  );
64✔
383

384
  return { valueMs, progress, running, ended, start, pause, reset };
64✔
385
}
64✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc